Wikilivres frwikibooks https://fr.wikibooks.org/wiki/Accueil MediaWiki 1.47.0-wmf.23 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 Wikijunior:Les civilisations anciennes 102 7140 774369 770658 2026-10-07T16:12:25Z Léandre Lsam 124684 774369 wikitext text/x-wiki Les civilisations anciennes sont connues au travers des éléments écrits ou archéologiques restants. Elles avaient des spécificités en fonction des lieux où elles se sont développées. Elles avaient des systèmes de valeurs, ou de relations parfois sensiblement différents des nôtres. Ces civilisations font aussi partie des événements marquants du monde entier. Sommaire : [[Fichier:Wikijunior Ancient Civlizations II.png|vignette|Les civilisations anciennes.]] *[[/L'Égypte/]] (Afrique) *[[/La Mésopotamie/]] (Asie) *[[/La Grèce antique/]] (Monde méditerranéen) *[[/La Rome antique/]] (Monde méditerranéen) *[[/La civilisation carthaginoise ou civilisation punique/]] (Monde méditerranéen) *[[/Les Mound Builders/]] (Amérique du Nord) *[[/Les Anasazis/]] (Amérique du Nord) *[[/Les Aztèques/]] (Amérique centrale) *[[/Les Mayas/]] (Amérique centrale) *[[/Les Incas/]] (Amérique du Sud) *[[/Les Arabes/]] (Moyen-Orient) *[[/Les Vikings/]] (Europe) *[[/La Chine/]] (Asie) *[[/Les Khmer/]] (Asie) *[[/Lapita/]] (Océanie) [[Catégorie:Wikijunior|Civilisations anciennes}]] [[Catégorie:Livres en cours de rédaction]] [[Catégorie:Wikijunior:Les civilisations anciennes (livre)|!]] [[de:Wikijunior Alte Zivilisationen]] [[en:Wikijunior:Ancient Civilizations]] [[es:Wikichicos/Grandes Civilizaciones]] [[fi:Wikijunior Muinaiset kulttuurit]] f9pydjzywaslt527zs2grf1ufqf9b95 Wikijunior:Système solaire/Nuage d'oort 102 13706 774418 714828 2026-10-08T05:06:45Z ~2026-54198-93 124691 774418 wikitext text/x-wiki [[Wikijunior:Système solaire]] Le nuage d'Oort est un grand auréole fait de millions de comètes qui se situent dans le bord extérieur du système solaire. == Qu’est-ce qu’un nuage Oort ? == Les scientifiques croient qu’il existe plusieurs objets distants, formés de roches et de glace, qui forment une sorte de nuage autour notre système solaire. Donc, en d’autres termes, c’est un nuage de comètes qui se trouve très loin du Soleil. Et même si les comètes sont vraiment espacées les unes des autres, il y en a des millions. La masse totales de toutes les comètes peu être jusqu’à 100 fois la masse de la Terre. Le nuage d’Oort est nommé d’après un astronome hollandais {{w|Jan Oort}} qui avait amélioré l'idée originale du nuage et l'a rendu publique. [[Image:Oort_cloud_Sedna_orbit.jpg|thumb|left|350px|Ce digramme montre la distance entre le nuage d’Oort et les autres planètes du système solaire. Commence dans le coin du cadre gauche, ensuite suis les images comme les aiguilles d’une montre. Chaque image montre un plus grand volume d’espace.]] Lorsqu’une comète fait plusieurs voyages dans le système solaire, le Soleil la fond lentement et élimine la glace pour que seulement quelques morceaux de solides soient laissés. Ces comètes se font donc détruire dans le système solaire mais pourtant il y en a toujours autant. Jan Oort a utilisé l’idée du nuage d’Oort pour expliquer l’apparition des nouvelles comètes. == Où se situe le nuage d'Oort ? == Si vous pouvez imaginer la distance de la Terre au Soleil, les comètes dans le nuage d'Oort sont {{formatnum:50000}} à {{formatnum:100000}} fois plus loin ! C'est aussi {{formatnum:1000}} fois plus loin du Soleil que Pluton, et à un quart de la distance de la plus proche étoile - {{w|Proxima Centauri}}. La lumière prend un année à voyager du Soleil jusqu'à l'extrémité du nuage d'Oort. == Comment est-ce que le nuage d'Oort a commencé ? == Le nuage d’Oort aurait pu commencer plus proche du soleil pendant la formation du système solaire. Lorsqu’ils passaient près de ces grandes boules de gaz, la gravité de ces étoiles attirait ces objets dans des orbites éloignées. Les objets du nuage d’Oort étaient projetés dans toutes les directions. Par conséquent, le nuage d’Oort a eu une forme sphérique au lieu d’une forme de disque. Le gravité d’autres étoiles passant a aussi fait l’orbite des objets circulaire et les attiraient plus loin du soleil. Mais, d'un autre côté la gravité des autres étoiles peuvent envoyer les objets vers notre soleil. Ceux-ci deviennent des comètes. [[Image:Kuiper belt oort cloud.svg|thumb|upright=1.3|Vue d'artiste de la {{w|Ceinture de Kuiper}} et du {{w|Nuage d'Oort}}]] == Quels objets sont dans le nuage d'Oort ? == Un objet appelé {{w|Sedna}} a été découvert qui a pu venir du nuage d’Oort. Il fait plus de {{formatnum:1800}} km de grandeur. Son orbite est de 76 à 928 fois plus loin du Soleil que la Terre. Cet objet orbite autour du Soleil une fois chaque {{formatnum:11250}} années terrestres. [[Catégorie:Wikijunior:Système solaire (livre)]] [[de:Wikijunior Sonnensystem/ Oortsche Wolke]] [[en:Wikijunior:Solar System/Oort Cloud]] 3k8olo25ox9rp150fkrf8s9bgctut1s MySQL/Fonctions 0 56770 774357 768243 2026-10-07T13:50:03Z JackPotte 5426 /* SUBSTR(chaine, début, taille) */ 774357 wikitext text/x-wiki <noinclude>{{MySQL}}</noinclude> == Syntaxe == À l'instar des mots réservés SQL, les noms des fonctions ne sont pas sensibles à la casse : <syntaxhighlight lang=sql> SELECT database() -- ok SELECT DataBase() -- ok SELECT DATABASE() -- ok </syntaxhighlight> Si le SQL_MODE IGNORE_SPACE SQL_MODE n'est pas défini, il est impossible de placer un espace entre le no de la fonction et la première parenthèse, sous peine de voir une erreur 1064. IGNORE_SPACE est généralement à 0, car cela accélère le parseur. Donc : <syntaxhighlight lang=sql> SELECT DATABASE () -- déconseillé SELECT DATABASE() -- recommandé </syntaxhighlight> Toutefois, cette restriction ne s'applique qu'aux fonctions natives de MySQL (pas aux procédures stockées). == Fonctions générales == Fonctions qui dépendent du type. === BENCHMARK(nombre, expression) === Exécute l'expression n fois et retourne toujours zéro<ref>http://dev.mysql.com/doc/refman/5.0/fr/information-functions.html</ref>, le chiffre pertinent est donc le temps pris par cette opération de simulation. Utile pour trouver les goulots d'étranglement des expressions SQL : <pre> SELECT BENCHMARK(10000, 'Bonjour'); -- Traitement en 0.0010 sec </pre> === CAST(valeur AS type) === Renvoie la valeur convertie en chaine de caractères, comme les apostrophes. <pre> SELECT CAST(20130101 AS date); -- 2013-01-01 </pre> Exemple avec encodage<ref>https://dev.mysql.com/doc/refman/8.0/en/cast-functions.html</ref> : <pre> SELECT CAST('test' AS CHAR CHARACTER SET utf8mb4) COLLATE utf8mb4_bin; </pre> {{attention|1= <pre> SELECT CAST(0.1 as FLOAT), -- 0.1 0.1 = 0.1, -- 1 CAST(0.1 as FLOAT) = 0.1; -- 0 </pre> }} === CHAR_LENGTH(chaine) === Renvoie le nombre de caractères de la chaine : <pre> SELECT CHARSET(20130101); -- 8 SELECT CHARSET('Hello'); -- 5 </pre> === CHARSET(chaine) === Renvoie le type de caractères de la chaine : <pre> SELECT CHARSET(20130101); -- binary SHOW CHARACTER SET; -- montre tous les CHARACTER SET installés </pre> === COALESCE(valeur, ...) === Renvoie le premier paramètre non nul. S'ils sont tous nuls, renvoie <code>NULL</code>. <pre> SELECT COALESCE(null, 'Bonjour', null); -- bonjour </pre> === COERCIBILITY(chaine) === Renvoie la ''{{wt|coercibility}}'' d'une chaine (entre 0 et 5) : <pre> SELECT COERCIBILITY('bonjour'); -- 4 </pre> {|class="wikitable" !Coercibility<ref>http://dev.mysql.com/doc/refman/5.0/en/information-functions.html#function_coercibility</ref>!! Signification !! Exemple |- | 0 || Explicit collation || Value with COLLATE clause |- | 1 || No collation || Concatenation of strings with different collations |- | 2 || Implicit collation || Column value |- | 3 || System constant || USER() return value |- | 4 || Coercible || Literal string |- | 5 || Ignorable || NULL or an expression derived from NULL |} === COLLATION(chaine) === Renvoie la ''{{wt|collation}}'' d'une chaine : <pre> SELECT COLLATION('bonjour'); -- utf8_general_ci </pre> Pour obtenir celle par défaut d'une base : <pre> SELECT DEFAULT_COLLATION_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = 'maBase1' </pre> === CONCAT() === Concaténation sans séparateur : <pre> SELECT CONCAT('Hello', 'World'); # HelloWorld </pre> === CONCAT_WS() === Concaténation avec séparateur de son choix : <pre> SELECT CONCAT_WS(', ', 'Hello', 'World'); # Hello, World </pre> === CONNECTION_ID() === Renvoie l'identifiant du thread courant : <pre> SELECT CONNECTION_ID(); -- 31 </pre> === CONVERT(valeur, type) === Tout comme <code>CAST()</code>, retourne la valeur convertie dans le type mentionné : <pre> SELECT CONVERT (20130101, date); -- 2013-01-01 </pre> === CONVERT(chaine USING charset) === Convertit la chaine string passée dans le CHARACTER SET spécifié : <pre> SELECT CONVERT ('Voici une écriture' USING utf8); -- Voici une écriture SELECT CONVERT ('Voici une écriture' USING ASCII); -- Voici une ?criture </pre> === CURRENT_USER() === Retourne les noms de l'utilisateur et de l'hôte courants : <pre> SELECT CURRENT_USER(); -- root@localhost </pre> === DATABASE() === Retourne le nom de la base de données courante : <pre> SELECT DATABASE(); -- wiki1 </pre> === FOUND_ROWS() === Après un <code>SELECT</code> avec une <code>LIMIT</code> et le mot clé <code>SQL_CALC_FOUND_ROWS</code>, il est possible de lancer un autre <code>SELECT</code> avec <code>FOUND_ROWS()</code>. En effet il renvoie le nombre de ligne de la clause précédente, sans la limite : <pre> SELECT FOUND_ROWS() AS n; -- 0 SELECT SQL_CALC_FOUND_ROWS * FROM wiki1_page ORDER BY page_id LIMIT 10 OFFSET 2; -- deux lignes SELECT FOUND_ROWS() AS n; -- 1 </pre> === GREATEST(valeur1, valeur2, ...) === Renvoie la plus grande valeur des paramètres : <pre> SELECT GREATEST(1, 2, 21, 3); -- 21 </pre> === INTERVAL(valeur1, valeur2, valeur3, ...) === Renvoie l'emplacement du premier argument supérieur au premier, en partant du zéro dans la liste des entiers en paramètres : <pre> SELECT INTERVAL(10, 20, 9, 8, 7); -- 0 SELECT INTERVAL(10, 9, 20, 8, 7); -- 1 SELECT INTERVAL(10, 9, 8, 20, 7); -- 2 SELECT INTERVAL(10, 9, 8, 7, 20); -- 3 </pre> === IF(valeur1, valeur2, valeur3) === If ''valeur1'' est vraie, renvoie ''valeur2'', sinon (fausse ou nulle) renvoie ''valeur3''. <pre> select if(1=2, 'irréel', 'réel'); -- réel </pre> === IFNULL(valeur1, valeur2) === Si ''valeur1'' est nulle, renvoie ''valeur2'', sinon ''valeur1''. <pre> SELECT IFNULL('variable1', 'défaut'); -- variable1 </pre> === ISNULL(valeur) === Si la valeur passée est nulle, renvoie 1, sinon 0. <pre> SELECT ISNULL('variable1'); -- 0 </pre> === NULLIF(valeur1, valeur2) === Renvoie <code>NULL</code> si valeur1 = valeur2, sinon valeur1. <pre> SELECT NULLIF(10, 20); -- 10 </pre> === LAST_INSERT_ID() === Renvoie le dernier ID en AUTO_INCREMENT inséré dans la base, ce qui évite un SELECT lorsque l'on a besoin d'insérer deux enregistrements dont la deuxième avec une clé étrangère vers la première. === LEAST(valeur1, valeur2, ...) === Renvoie la plus petite valeur dans la liste des paramètres passés : <pre> SELECT LEAST(1, 2, 21, 3, -1); -- -1 </pre> === LENGTH(chaine) === Affiche la taille d'une chaine de caractères en octets. {{attention|Pour avoir le nombre de caractères, utiliser <code>CHAR_LENGTH</code>.}} === LOCATE(sous-chaine, chaine) === Affiche la première position d'une sous-chaine dans une chaine de caractères. === REPLACE(chaine, 'sous-chaine à remplacer', 'par') === <pre> SELECT REPLACE('helloworld', 'o', ''); -- hellwrld </pre> === SUBSTR(chaine, début, taille) === Son alias "substring()" fonctionne aussi. Découpe une chaine de caractère (son premier caractère a l'indice "1"). Ex : <pre> SELECT SUBSTR('Hello World!', 7, 5); -- World SELECT SUBSTR('Hello World!', -6); -- World! </pre> === SUBSTRING_INDEX === Découpe une chaine selon un séparateur. Le troisième paramètre désigne la énième occurrence de ce dernier : <pre> SELECT SUBSTRING_INDEX('Hello World!', ' ', 1); # Hello SELECT SUBSTRING_INDEX('Hello World!', ' ', 2); # Hello World! </pre> === Fonctions JSON === MySQL (et MariaDB<ref>https://mariadb.com/kb/en/json_search/</ref>) offrent plusieurs fonctions de manipulation de champ varchar contenant du JSON. ==== JSON_VALID ==== Renvoie vrai si le champ est un JSON valide, faux sinon. ==== JSON_UNQUOTE ==== Retire les guillemets au début et à la fin d'une chaine (pour l'utiliser dans un JSON sans qu'ils soient interprétés). Ex : <pre> SELECT JSON_UNQUOTE('"1"'); -- 1 </pre> ==== JSON_KEYS ==== Renvoie toutes les clés du JSON en paramètre. ==== JSON_EXTRACT ==== Extrait un morceau du JSON par sa clé. Ex : <pre> SET @json = '{"1": "2", "3": "4"}'; SELECT JSON_EXTRACT(@json, '$.1'); -- "2" </pre> Cette fonction est combinable avec JSON_UNQUOTE pour les recherches dans des données JSON<ref>https://www.startutorial.com/articles/view/how-to-search-json-data-in-mysql</ref>.. Ex : <pre> SELECT JSON_UNQUOTE(JSON_EXTRACT(varchar_avec_json,'$.ma_clé')) AS ma_clé FROM users; </pre> ==== JSON_SEARCH ==== Retourne les clés correspondant à la valeur recherchée. Son deuxième paramètre peut être "one" pour la première clé, ou "all" pour toutes. Ex : <pre> SET @json = '{"1": "2", "3": "4"}'; SELECT JSON_SEARCH(@json, 'one', '4'); -- "$.3" </pre> ==== JSON_VALUE ==== Retourne la valeur à partir de la clé en paramètre. ==== JSON_CONTAINS ==== Renvoie 1 si la JSON contient la valeur en paramètre, ou 0 sinon. ==== JSON_REMOVE ==== Retire la paire clé/valeur de la clé passée en paramètre. == Date et heure == Il existe des dizaines de fonctions liées aux dates<ref>https://dev.mysql.com/doc/refman/5.5/en/date-and-time-functions.html</ref>. Pour trouver la date de l'an dernier : <syntaxhighlight lang=sql> SELECT CURDATE() - INTERVAL 1 YEAR </syntaxhighlight> Sélectionner toutes les pages du wiki non lues depuis plus un an : <syntaxhighlight lang=sql> SELECT * FROM wiki1_page WHERE page_touched <= (CURDATE() - INTERVAL 1 YEAR); </syntaxhighlight> Autres exemples de sélections : <syntaxhighlight lang=sql> SELECT IF(DAYOFMONTH(CURDATE()) <= 15, DATE_FORMAT(CURDATE(), '%Y-%m-15'), DATE_FORMAT(CURDATE() + INTERVAL 1 MONTH, '%Y-%m-15')) AS next15 FROM table; SELECT YEAR('2002-05-10'), MONTH('2002-05-10'), DAYOFMONTH('2002-05-10') SELECT PurchaseDate FROM table WHERE YEAR(PurchaseDate) <= YEAR(CURDATE()) SELECT columns FROM table WHERE start_time >= '2004-06-01 10:00:00' AND end_time <= '2004-06-03 18:00:00' SELECT * FROM t1 WHERE DATE_FORMAT(datetime_column, '%T') BETWEEN 'HH:MM:SS' AND 'HH:MM:SS' SELECT Start_time, End_time FROM Table WHERE Start_time >= NOW() - INTERVAL 4 HOUR SELECT NOW() + INTERVAL 60 SECOND SELECT UNIX_TIMESTAMP('2007-05-01'); -- 1177970400 SELECT FROM_UNIXTIME(1177970400); -- 2007-05-01 00:00:00 </syntaxhighlight> {{Attention|<code>convert('17/02/2016 15:49:03',datetime)</code> ou <code>convert('17-02-2016 15:49:03',datetime)</code> donne ''null'', donc une requête d'insertion le remplace par le même résultat que <code>now()</code>. La syntaxe doit être <code>convert('2016-02-17 15:49:03',datetime)</code> ou <code>convert('2016/02/17 15:49:03',datetime)</code>.}} === DATE_ADD() === Pour additionner deux dates. Par exemple pour calculer le jour d'une livraison prenant 48 h : SELECT DATE_ADD(NOW(), INTERVAL 2 DAY) Pour la date d'hier : SELECT DATE_ADD(NOW(), INTERVAL -1 DAY) Les unités à additionner ou soustraire les plus courantes sont<ref>https://www.w3schools.com/sql/func_mysql_date_add.asp</ref> : SECOND MINUTE HOUR DAY WEEK MONTH YEAR === DATEDIFF() === Pour soustraire une date à une autre. Par exemple pour calculer un âge : <pre> SELECT DATEDIFF(NOW(), birthday_date) / 365 FROM user WHERE ISNULL(birthday_date) = 0 AND birthday_date != '0000-00-00' </pre> === STR_TO_DATE() === Un BETWEEN ou ">" de champ date avec une chaine de caractères ne marche pas. Il faut donc convertir la chaine en date : <pre> SELECT * FROM my_table WHERE start_date > STR_TO_DATE('2024-08-10', '%Y-%m-%d') </pre> == Fonctions d'agrégation == ===COUNT(champ)=== Si le paramètre est "*" au lieu d'un nom de colonne, <code>COUNT()</code> renvoie les nombre de lignes total de la requête. Cela peut permettre de savoir combien de lignes possède une table, par exemple le nombre de pages d'un wiki : <syntaxhighlight lang=mysql> SELECT COUNT(*) FROM `wiki1_page`; </syntaxhighlight> Si le mot <code>DISTINCT</code> est employé, cela ignore les doublons : <syntaxhighlight lang=mysql> SELECT COUNT(DISTINCT id) FROM `wiki1_page`; </syntaxhighlight> Si le nom d'un champ est précisé, cela renvoie le nombre de valeurs non nulles : <syntaxhighlight lang=mysql> SELECT COUNT(`user_real_name`) FROM `wiki1_user`; SELECT COUNT(DISTINCT `user_real_name`) FROM `wiki1_user`; </syntaxhighlight> Cela fonctionne aussi pour des expressions, des combinaisons de champs : <syntaxhighlight lang=mysql> SELECT COUNT(`user_name` + `user_real_name`) FROM `wiki1_user`; </syntaxhighlight> Pour afficher le décompte de plusieurs tables non jointes : <syntaxhighlight lang=mysql> SELECT (SELECT COUNT(*) FROM maTable1) as t1, (SELECT COUNT(*) FROM maTable2) as t2 </syntaxhighlight> === MAX(champ) === <code>MAX()</code> renvoie la valeur maximum d'une expression issue du résultat d'une requête, ou <code>NULL</code> s'il n'y en a pas : <syntaxhighlight lang=sql> SELECT MAX(`user_editcount`) FROM `wiki1_user`; SELECT MAX(LENGTH(CONCAT(`user_name`, ' ', `user_real_name`))) FROM `wiki1_user`; </syntaxhighlight> ==== Alternatives ==== Selon le contexte, la fonction <code>MAX()</code> n'est pas toujours la meilleure option pour obtenir un maximum. Par exemple en cas de sous-requêtes ou sans agrégation possible : * <code>SELECT `user_editcount` FROM `wiki1_user` ORDER BY user_editcount DESC LIMIT 1;</code> * <syntaxhighlight lang=mysql> SELECT `user_editcount` FROM `wiki1_user` wu1 LEFT JOIN `wiki1_user` wu2 ON wu1.user_editcount > wu2.user_editcount WHERE wu2.user_editcount is null; </syntaxhighlight> ===MIN(champ)=== MIN() renvoie la valeur minimum d'une expression issue du résultat d'une requête, ou <code>NULL</code> s'il n'y en a pas : <syntaxhighlight lang=sql> SELECT MIN(`user_editcount`) FROM `wiki1_user`; SELECT MIN(LENGTH(CONCAT(`user_name`, ' ', `user_real_name`))) FROM `wiki1_user`; </syntaxhighlight> ===AVG(champ)=== <code>AVG()</code> renvoie la valeur moyenne d'une expression, ou <code>NULL</code> s'il n'y en a pas : * Moyenne des valeurs d'un champ entier : <pre> SELECT AVG(`user_editcount`) FROM `wiki1_user`; </pre> * Moyenne des valeurs d'un champ date : <pre> SELECT from_unixtime(avg(unix_timestamp(`user_registration`))) FROM `wiki1_user`; </pre> * Moyenne d'un total obtenu par COUNT : <pre> SELECT AVG(p.nb) as moyenne FROM ( SELECT count(*) as nb FROM wiki1_user where user_editcount > 1000 group by user_country ) as p; </pre> ===SUM(champ)=== <code>SUM()</code> dresse la somme des valeurs d'une expression, ou <code>NULL</code> s'il n'y en a pas. Si <code>SUM(DISTINCT expression)</code> est utilisé, les valeurs identiques ne sont ajoutées qu'une seule fois. Il a été ajouté après MySQL 5.1. <syntaxhighlight lang=sql> SELECT SUM( DISTINCT user_editcount ) FROM wiki1_user </syntaxhighlight> {{Attention|Cette fonction est impactée quand on ajoute des <code>LEFT JOIN</code> dans la même requête. Il faut alors les séparer dans des sous-requêtes.}} ===GROUP_CONCAT(champ)=== <code>GROUP_CONCAT()</code> concatène les valeurs de tous les enregistrements d'un groupe dans une seule chaine séparée par une virgule par défaut. En effet, le deuxième paramètre facultatif permet de définir un autre séparateur. <syntaxhighlight lang=sql> CREATE TEMPORARY TABLE product ( id INTEGER, product_type VARCHAR(10), product_name VARCHAR(50) ); INSERT INTO product VALUES (1, 'mp3', 'iPod'), (2, 'mp3', 'Zune'), (3, 'mp3', 'ZEN'), (4, 'notebook', 'Acer Eee PC'), (4, 'notebook', 'Everex CloudBook'); SELECT * FROM product; SELECT product_type, group_concat(product_name), group_concat(' ', product_name) FROM product GROUP BY product_type; /* # product_type, group_concat(product_name), group_concat(' ', product_name) 'mp3', 'iPod,Zune,ZEN', ' iPod, Zune, ZEN' 'notebook', 'Acer Eee PC,Everex CloudBook', ' Acer Eee PC, Everex CloudBook' */ </syntaxhighlight> === Fonctions d'agrégation de bit === Syntaxe générale : FUNCTION_NAME(''expression'') Ces fonctions bit à bit calculent ''expression'' pour chaque ligne du résultat et entre les ''expression''s. La précision est de 64 bit. ==== AND ==== <syntaxhighlight lang=sql> SELECT BIT_AND(ip) FROM log </syntaxhighlight> ==== OR ==== <syntaxhighlight lang=sql> SELECT BIT_OR(ip) FROM log </syntaxhighlight> (retourne 0 s'il n'y a aucun résultat) ==== XOR ==== <syntaxhighlight lang=sql> SELECT BIT_XOR(ip) FROM log </syntaxhighlight> (retourne 0 s'il n'y a aucun résultat) == Références == {{Références}} [[en:MySQL/Language/Functions]] 3mzq6qcug921dy75wun56hul8uzyjga Fonctionnement d'un ordinateur/L'abstraction mémoire et la mémoire virtuelle 0 65813 774380 771166 2026-10-07T18:02:09Z Mewtow 31375 /* Segmentation versus pagination */ 774380 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. ==Un cas particulier : la z/architecture d'IBM== IBM a sa propre gamme de ''mainframes'', qui utilisent des processeurs fait maison par IBM lui-même. Les CPU IBM ont leur propre jeu d'instruction CISC, appelé '''''z/architecture'''''. Et ce jeu d'instruction a des possibilités en termes d'adressage qui sont extrêmement intéressantes, en plus d'être assez inédites. Pour ce chapitre, nous allons nous concentrer sur une fonctionnalité : les programmes ont accès à plusieurs espaces d'adressage simultannément. La ''z/architecture'' supporte 65536 espaces d'adressage simultanés. Par contre, un programme ne peut en utiliser que 2048 en même temps. De plus, ils ne sont accessibles que quand le processeur est dans un mode de fonctionnement bien précis. Un ^processeur ''z/architecture'' peut en effet fonctionner dans deux modes, qu'on va appeler "standard" et "étendu". ===Le mode standard de la ''z/architecture''=== En mode standard, un programme exécuté sur la ''z/architecture'' utilise deux espaces d'adressages : l''''espace d'adressage primaire''' où se situent le programme exécuté et les données importantes, un '''espace d'adressage secondaire''' utilisé pour des tâches/données annexes. : Un troisième espace d'adressage l''''espace d'adressage ''home''''', est réservé au système d'exploitation. Il a pour particularité de ne pas avoir d'ASN. Il n'est concrètement accesible qu'en mode superviseur, donc par le système d'exploitation. C'est en quelque sorte un espace d'adressage pour le noyau, séparé des espaces d'adressage utilisateur. Nous n'en parlerons pas dans ce qui suit. Mais comment le processeur pour utiliser ces espaces d'adressages ? Par exemple, s'il veut faire une lecture, comment précise-t-il l'espace d'adressage courant ? La réponse est que le processeur peut être dans deux états : soit primaire, soit secondaire. Dans le premier état, toutes les lectures/écritures se font dans l'espace d'adressage primaire. Dans le second, elles se font toutes dans l'espace d'adressage secondaire. Il peut passer de l'un à l'autre en utilisant l'instruction ''Set Address Space Control''. Le processeur dispose aussi d'instructions ''MOVE TO PRIMARY'' et ''MOVE TO SECONDARY'' pour échanger des données dans les deux espaces d'adressage. ===Les registres d'accès de la ''z/architecture''=== Un programme peut donc passer de l'espace d'adressage primaire à secondaire (ou inversement) en utilisant l’instruction ''SET ADDRESS SPACE CONTROL''. Mais il peut en profiter pour passer dans un mode où il a accès à 1024 espaces d'adressage différents, en même temps. Le processeur n'a pas besoin d'utiliser d'instructions particulières pour copier des données d'un espace d'adressage à un autre, un simple MOV suffit. Le mode en question est appelé le '''mode ''access register'''''. Le mode ''access register'' donne accès aux '''registres d'accès''', qui lui donnent son nom. Les registres d'accès permettent de sélectionner un espace d'adressage, afin qu'une instruction puisse lire/écrire dedans. Il y a en tout 16 registres d'accès, chacun étant appairé avec un registre général. Par exemple, le registre général numéro 5 est appairé au registre d'accès de même numéro. En mode ''access register'', toute lecture/écriture lit à la fois un registre général et le registre d'accès associé. Le registre général contient un pointeur, l'adresse à lire/écrire, et le registre d'accès indique dans quel espace d'adressage se trouve cette adresse. Une implémentation simple numéroterait les espaces d'adressage et mettrait les numéros dans les registres d'accès. Les registres d'accès seraient idéalement protégés en écriture et seulement accessibles par le système d'exploitation. Mais ce n'est pas ce qui est fait. Les registres d'accès font 32 bits chacun, ce qui ne colle pas avec le fait que les ASNs sont codés sur 16 bits. La raison est que les registres d'accès ne mémorisent pas des ASNs, et on peut même aller plus loin : les ASNs ne sont pas utilisés pour adresser les espaces d'adressage. Un programme peut accéder à certains espaces d'adressage bien précis, que lui a attribué le système d'exploitation. Les espaces d'adressages d'un programme sont mémorisés dans une '''liste d'accès privée'''. La liste d'accès est en réalité non pas une liste chainée, mais un tableau. Il contient jusqu’à 1024 entrées, chacune contenant toutes les informations nécessaires pour utiliser un espace d'adressage. Une entrée contient notamment l'adresse mémoire à laquelle commence l'espace d'adressage, qui est utilisée dans divers calculs d'adresse. La liste d'accès privée est gérée par le système d'exploitation, lui seul peut la modifier. Je dis une liste d'accès privée, mais il y en a deux en réalité. Mais la distinction exacte demanderait de parler de ''threads'' et de processus. Pour ceux qui sont familiers avec ces concepts, chaque ''thread'' a sa propre liste privée, avec en plus une liste par processus partagée entre tous les ''threads''. Deux listes de 1024 entrées maximum, cela fait 2048 entrées au total. Les deux listes portent les noms de ''Dispatchable Unit Access List'' et de ''PASN-AL (Primary Address Space Access List''. Inutile de retenir ces deux termes, cependant. Un registre d'accès mémorise un '''''Access List Entry Token''''' ou ALET. Un ALET regroupe plusieurs informations. Déjà, il contient un bit qui indique quelle liste privée utiliser, laquelle des deux. Ensuite, il contient un indice qui sélectionne une entrée dans la liste privée choisie. L'ALET peut avoir plusieurs valeurs, mais deux d'entre elles sont pré-déterminées : 0 pointe vers l'espace d'adressage principal, 1 vers l'espace d'adressage secondaire. Les autres valeurs sont configurables. : Précisons que le registre d'accès numéro 0 contient obligatoirement la valeur 0, ce qui fait qu'il pointe vers l'espace d'adressage primaire. Lors d'une lecture/écriture, l'ALETs est utilisé pour sélectionner l'espace d'adressage. Les valeurs 0 et 1 sont traitées telles quelles, l'espace d'adressage est déterminé automatiquement par le processeur. Pour les autres valeurs, récupérer l'adresse mémoire demande de consulter la liste d'accès privée. L'ALET est envoyé à la liste d'accès privée, l'adresse est récupérée. Le processeur connait l'adresse de la liste privée, car son adresse mémoire est mémorisée dans les registres de contrôle numéro 2 et 5. Une subtilité est que le système d'exploitation peut à tout moment modifier le contenu de la liste privée. Il se peut qu'un ALET qui correspondait à un espace d'adressage se mette à pointer vers un autre. Pour détecter cette situation, une entrée de la liste privée contient un identifiant, qui est modifié à chaque fois que le système d'exploitation modifie l'entrée. L'ALET mémorise aussi cet identifiant, qui est obtenu lors de la création de l'ALET. <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> hbxkbk3ydmyp977l4cikuj9wuqk69uy 774382 774380 2026-10-07T18:02:46Z Mewtow 31375 /* Les registres d'accès de la z/architecture */ 774382 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. ==Un cas particulier : la z/architecture d'IBM== IBM a sa propre gamme de ''mainframes'', qui utilisent des processeurs fait maison par IBM lui-même. Les CPU IBM ont leur propre jeu d'instruction CISC, appelé '''''z/architecture'''''. Et ce jeu d'instruction a des possibilités en termes d'adressage qui sont extrêmement intéressantes, en plus d'être assez inédites. Pour ce chapitre, nous allons nous concentrer sur une fonctionnalité : les programmes ont accès à plusieurs espaces d'adressage simultannément. La ''z/architecture'' supporte 65536 espaces d'adressage simultanés. Par contre, un programme ne peut en utiliser que 2048 en même temps. De plus, ils ne sont accessibles que quand le processeur est dans un mode de fonctionnement bien précis. Un ^processeur ''z/architecture'' peut en effet fonctionner dans deux modes, qu'on va appeler "standard" et "étendu". ===Le mode standard de la ''z/architecture''=== En mode standard, un programme exécuté sur la ''z/architecture'' utilise deux espaces d'adressages : l''''espace d'adressage primaire''' où se situent le programme exécuté et les données importantes, un '''espace d'adressage secondaire''' utilisé pour des tâches/données annexes. : Un troisième espace d'adressage l''''espace d'adressage ''home''''', est réservé au système d'exploitation. Il a pour particularité de ne pas avoir d'ASN. Il n'est concrètement accesible qu'en mode superviseur, donc par le système d'exploitation. C'est en quelque sorte un espace d'adressage pour le noyau, séparé des espaces d'adressage utilisateur. Nous n'en parlerons pas dans ce qui suit. Mais comment le processeur pour utiliser ces espaces d'adressages ? Par exemple, s'il veut faire une lecture, comment précise-t-il l'espace d'adressage courant ? La réponse est que le processeur peut être dans deux états : soit primaire, soit secondaire. Dans le premier état, toutes les lectures/écritures se font dans l'espace d'adressage primaire. Dans le second, elles se font toutes dans l'espace d'adressage secondaire. Il peut passer de l'un à l'autre en utilisant l'instruction ''Set Address Space Control''. Le processeur dispose aussi d'instructions ''MOVE TO PRIMARY'' et ''MOVE TO SECONDARY'' pour échanger des données dans les deux espaces d'adressage. ===Les registres d'accès de la ''z/architecture''=== Un programme peut donc passer de l'espace d'adressage primaire à secondaire (ou inversement) en utilisant l’instruction ''SET ADDRESS SPACE CONTROL''. Mais il peut en profiter pour passer dans un mode où il a accès à 1024 espaces d'adressage différents, en même temps. Le processeur n'a pas besoin d'utiliser d'instructions particulières pour copier des données d'un espace d'adressage à un autre, un simple MOV suffit. Le mode en question est appelé le '''mode ''access register'''''. Le mode ''access register'' donne accès aux '''registres d'accès''', qui lui donnent son nom. Les registres d'accès permettent de sélectionner un espace d'adressage, afin qu'une instruction puisse lire/écrire dedans. Il y a en tout 16 registres d'accès, chacun étant appairé avec un registre général. Par exemple, le registre général numéro 5 est appairé au registre d'accès de même numéro. En mode ''access register'', toute lecture/écriture lit à la fois un registre général et le registre d'accès associé. Le registre général contient un pointeur, l'adresse à lire/écrire, et le registre d'accès indique dans quel espace d'adressage se trouve cette adresse. Une implémentation simple numéroterait les espaces d'adressage et mettrait les numéros dans les registres d'accès. Les registres d'accès seraient idéalement protégés en écriture et seulement accessibles par le système d'exploitation. Mais ce n'est pas ce qui est fait. Un programme peut accéder à certains espaces d'adressage bien précis, que lui a attribué le système d'exploitation. Les espaces d'adressages d'un programme sont mémorisés dans une '''liste d'accès privée'''. La liste d'accès est en réalité non pas une liste chainée, mais un tableau. Il contient jusqu’à 1024 entrées, chacune contenant toutes les informations nécessaires pour utiliser un espace d'adressage. Une entrée contient notamment l'adresse mémoire à laquelle commence l'espace d'adressage, qui est utilisée dans divers calculs d'adresse. La liste d'accès privée est gérée par le système d'exploitation, lui seul peut la modifier. Je dis une liste d'accès privée, mais il y en a deux en réalité. Mais la distinction exacte demanderait de parler de ''threads'' et de processus. Pour ceux qui sont familiers avec ces concepts, chaque ''thread'' a sa propre liste privée, avec en plus une liste par processus partagée entre tous les ''threads''. Deux listes de 1024 entrées maximum, cela fait 2048 entrées au total. Les deux listes portent les noms de ''Dispatchable Unit Access List'' et de ''PASN-AL (Primary Address Space Access List''. Inutile de retenir ces deux termes, cependant. Un registre d'accès mémorise un '''''Access List Entry Token''''' ou ALET. Un ALET regroupe plusieurs informations. Déjà, il contient un bit qui indique quelle liste privée utiliser, laquelle des deux. Ensuite, il contient un indice qui sélectionne une entrée dans la liste privée choisie. L'ALET peut avoir plusieurs valeurs, mais deux d'entre elles sont pré-déterminées : 0 pointe vers l'espace d'adressage principal, 1 vers l'espace d'adressage secondaire. Les autres valeurs sont configurables. : Précisons que le registre d'accès numéro 0 contient obligatoirement la valeur 0, ce qui fait qu'il pointe vers l'espace d'adressage primaire. Lors d'une lecture/écriture, l'ALETs est utilisé pour sélectionner l'espace d'adressage. Les valeurs 0 et 1 sont traitées telles quelles, l'espace d'adressage est déterminé automatiquement par le processeur. Pour les autres valeurs, récupérer l'adresse mémoire demande de consulter la liste d'accès privée. L'ALET est envoyé à la liste d'accès privée, l'adresse est récupérée. Le processeur connait l'adresse de la liste privée, car son adresse mémoire est mémorisée dans les registres de contrôle numéro 2 et 5. Une subtilité est que le système d'exploitation peut à tout moment modifier le contenu de la liste privée. Il se peut qu'un ALET qui correspondait à un espace d'adressage se mette à pointer vers un autre. Pour détecter cette situation, une entrée de la liste privée contient un identifiant, qui est modifié à chaque fois que le système d'exploitation modifie l'entrée. L'ALET mémorise aussi cet identifiant, qui est obtenu lors de la création de l'ALET. <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> 5gzkp37hcpo6liil04xdbxz659ghgl3 774383 774382 2026-10-07T18:17:38Z Mewtow 31375 /* Un cas particulier : la z/architecture d'IBM */ 774383 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. ==Un cas particulier : la z/architecture d'IBM== IBM a sa propre gamme de ''mainframes'', qui utilisent des processeurs fait maison par IBM lui-même. Les CPU IBM ont leur propre jeu d'instruction CISC, appelé '''''z/architecture'''''. Et ce jeu d'instruction a des possibilités en termes d'adressage qui sont extrêmement intéressantes, en plus d'être assez inédites. La ''z/architecture'' gère des pages de 4 kibioctets, en plus de pages larges introduites dans les dernières versions. L traduction d’adresse se fait cependant en passant des regroupements de 4 pages virtuelles, appelés des ''segments'', qui n'ont rien à voir avec les segments au sens de la segmentation. Pour éviter les confusions, je vais les appeler des ''super-pages''. Les super-pages sont des groupes de 4 pages virtuelles, ce qui veut dire que les 4 pages peuvent être très éloignées en mémoire physique. Les super-pages sont concrètement des intermédiaires utilisés pour la traduction d'adresse. La traduction d'adresse démarre à une table des super-pages, qui point vers une table des pages à plusieurs niveaux (5 maximum). Pour ce chapitre, nous allons nous concentrer sur une fonctionnalité : les programmes ont accès à plusieurs espaces d'adressage simultanément. La ''z/architecture'' supporte 65536 espaces d'adressage simultanés. Par contre, un programme ne peut en utiliser que 2048 en même temps. De plus, ils ne sont accessibles que quand le processeur est dans un mode de fonctionnement bien précis. Un ^processeur ''z/architecture'' peut en effet fonctionner dans deux modes, qu'on va appeler "standard" et "étendu". ===Le mode standard de la ''z/architecture''=== En mode standard, un programme exécuté sur la ''z/architecture'' utilise deux espaces d'adressages : l''''espace d'adressage primaire''' où se situent le programme exécuté et les données importantes, un '''espace d'adressage secondaire''' utilisé pour des tâches/données annexes. : Un troisième espace d'adressage l''''espace d'adressage ''home''''', est réservé au système d'exploitation. Il a pour particularité de ne pas avoir d'ASN. Il n'est concrètement accesible qu'en mode superviseur, donc par le système d'exploitation. C'est en quelque sorte un espace d'adressage pour le noyau, séparé des espaces d'adressage utilisateur. Nous n'en parlerons pas dans ce qui suit. Mais comment le processeur pour utiliser ces espaces d'adressages ? Par exemple, s'il veut faire une lecture, comment précise-t-il l'espace d'adressage courant ? La réponse est que le processeur peut être dans deux états : soit primaire, soit secondaire. Dans le premier état, toutes les lectures/écritures se font dans l'espace d'adressage primaire. Dans le second, elles se font toutes dans l'espace d'adressage secondaire. Il peut passer de l'un à l'autre en utilisant l'instruction ''Set Address Space Control''. Le processeur dispose aussi d'instructions ''MOVE TO PRIMARY'' et ''MOVE TO SECONDARY'' pour échanger des données dans les deux espaces d'adressage. ===Les registres d'accès de la ''z/architecture''=== Un programme peut donc passer de l'espace d'adressage primaire à secondaire (ou inversement) en utilisant l’instruction ''SET ADDRESS SPACE CONTROL''. Mais il peut en profiter pour passer dans un mode où il a accès à 1024 espaces d'adressage différents, en même temps. Le processeur n'a pas besoin d'utiliser d'instructions particulières pour copier des données d'un espace d'adressage à un autre, un simple MOV suffit. Le mode en question est appelé le '''mode ''access register'''''. Le mode ''access register'' donne accès aux '''registres d'accès''', qui lui donnent son nom. Les registres d'accès permettent de sélectionner un espace d'adressage, afin qu'une instruction puisse lire/écrire dedans. Il y a en tout 16 registres d'accès, chacun étant appairé avec un registre général. Par exemple, le registre général numéro 5 est appairé au registre d'accès de même numéro. En mode ''access register'', toute lecture/écriture lit à la fois un registre général et le registre d'accès associé. Le registre général contient un pointeur, l'adresse à lire/écrire, et le registre d'accès indique dans quel espace d'adressage se trouve cette adresse. Une implémentation simple numéroterait les espaces d'adressage et mettrait les numéros dans les registres d'accès. Les registres d'accès seraient idéalement protégés en écriture et seulement accessibles par le système d'exploitation. Mais ce n'est pas ce qui est fait. Un programme peut accéder à certains espaces d'adressage bien précis, que lui a attribué le système d'exploitation. Les espaces d'adressages d'un programme sont mémorisés dans une '''liste d'accès privée'''. La liste d'accès est en réalité non pas une liste chainée, mais un tableau. Il contient jusqu’à 1024 entrées, chacune contenant toutes les informations nécessaires pour utiliser un espace d'adressage. Une entrée contient notamment l'adresse mémoire à laquelle commence l'espace d'adressage, qui est utilisée dans divers calculs d'adresse. La liste d'accès privée est gérée par le système d'exploitation, lui seul peut la modifier. Je dis une liste d'accès privée, mais il y en a deux en réalité. Mais la distinction exacte demanderait de parler de ''threads'' et de processus. Pour ceux qui sont familiers avec ces concepts, chaque ''thread'' a sa propre liste privée, avec en plus une liste par processus partagée entre tous les ''threads''. Deux listes de 1024 entrées maximum, cela fait 2048 entrées au total. Les deux listes portent les noms de ''Dispatchable Unit Access List'' et de ''PASN-AL (Primary Address Space Access List''. Inutile de retenir ces deux termes, cependant. Un registre d'accès mémorise un '''''Access List Entry Token''''' ou ALET. Un ALET regroupe plusieurs informations. Déjà, il contient un bit qui indique quelle liste privée utiliser, laquelle des deux. Ensuite, il contient un indice qui sélectionne une entrée dans la liste privée choisie. L'ALET peut avoir plusieurs valeurs, mais deux d'entre elles sont pré-déterminées : 0 pointe vers l'espace d'adressage principal, 1 vers l'espace d'adressage secondaire. Les autres valeurs sont configurables. : Précisons que le registre d'accès numéro 0 contient obligatoirement la valeur 0, ce qui fait qu'il pointe vers l'espace d'adressage primaire. Lors d'une lecture/écriture, l'ALETs est utilisé pour sélectionner l'espace d'adressage. Les valeurs 0 et 1 sont traitées telles quelles, l'espace d'adressage est déterminé automatiquement par le processeur. Pour les autres valeurs, récupérer l'adresse mémoire demande de consulter la liste d'accès privée. L'ALET est envoyé à la liste d'accès privée, l'adresse est récupérée. Le processeur connait l'adresse de la liste privée, car son adresse mémoire est mémorisée dans les registres de contrôle numéro 2 et 5. Une subtilité est que le système d'exploitation peut à tout moment modifier le contenu de la liste privée. Il se peut qu'un ALET qui correspondait à un espace d'adressage se mette à pointer vers un autre. Pour détecter cette situation, une entrée de la liste privée contient un identifiant, qui est modifié à chaque fois que le système d'exploitation modifie l'entrée. L'ALET mémorise aussi cet identifiant, qui est obtenu lors de la création de l'ALET. <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> ce9b8bc1w462qj7ms58gwyppro402s6 774384 774383 2026-10-07T18:26:39Z Mewtow 31375 /* Le mode standard de la z/architecture */ 774384 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. ==Un cas particulier : la z/architecture d'IBM== IBM a sa propre gamme de ''mainframes'', qui utilisent des processeurs fait maison par IBM lui-même. Les CPU IBM ont leur propre jeu d'instruction CISC, appelé '''''z/architecture'''''. Et ce jeu d'instruction a des possibilités en termes d'adressage qui sont extrêmement intéressantes, en plus d'être assez inédites. La ''z/architecture'' gère des pages de 4 kibioctets, en plus de pages larges introduites dans les dernières versions. L traduction d’adresse se fait cependant en passant des regroupements de 4 pages virtuelles, appelés des ''segments'', qui n'ont rien à voir avec les segments au sens de la segmentation. Pour éviter les confusions, je vais les appeler des ''super-pages''. Les super-pages sont des groupes de 4 pages virtuelles, ce qui veut dire que les 4 pages peuvent être très éloignées en mémoire physique. Les super-pages sont concrètement des intermédiaires utilisés pour la traduction d'adresse. La traduction d'adresse démarre à une table des super-pages, qui point vers une table des pages à plusieurs niveaux (5 maximum). Pour ce chapitre, nous allons nous concentrer sur une fonctionnalité : les programmes ont accès à plusieurs espaces d'adressage simultanément. La ''z/architecture'' supporte 65536 espaces d'adressage simultanés. Par contre, un programme ne peut en utiliser que 2048 en même temps. De plus, ils ne sont accessibles que quand le processeur est dans un mode de fonctionnement bien précis. Un ^processeur ''z/architecture'' peut en effet fonctionner dans deux modes, qu'on va appeler "standard" et "étendu". ===Le mode standard de la ''z/architecture''=== En mode standard, un programme exécuté sur la ''z/architecture'' utilise deux espaces d'adressages : l''''espace d'adressage primaire''' où se situent le programme exécuté et les données importantes, un '''espace d'adressage secondaire''' utilisé pour des tâches/données annexes. : Un troisième espace d'adressage l''''espace d'adressage ''home''''', est réservé au système d'exploitation. Il a pour particularité de ne pas avoir d'ASN. Il n'est concrètement accesible qu'en mode superviseur, donc par le système d'exploitation. C'est en quelque sorte un espace d'adressage pour le noyau, séparé des espaces d'adressage utilisateur. Nous n'en parlerons pas dans ce qui suit. Mais comment le processeur pour utiliser ces espaces d'adressages ? Par exemple, s'il veut faire une lecture, comment précise-t-il l'espace d'adressage courant ? La réponse est que le processeur peut être dans deux états : soit primaire, soit secondaire. Dans le premier état, toutes les lectures/écritures se font dans l'espace d'adressage primaire. Dans le second, elles se font toutes dans l'espace d'adressage secondaire. Il peut passer de l'un à l'autre en utilisant l'instruction ''Set Address Space Control''. Le processeur dispose aussi d'instructions ''MOVE TO PRIMARY'' et ''MOVE TO SECONDARY'' pour échanger des données dans les deux espaces d'adressage. L'espace d'adressage primaire a une table des pages dédiée, ou plutôt devrais-je dire une table des super-pages. La table des pages est une table des pages emboitée. Il y a peu à détailler dessus, si ce n'est pour cette histoire de segments. L'adresse de la table des super-page est mémorisée dans le registre de contrôle CR1. ===Les registres d'accès de la ''z/architecture''=== Un programme peut donc passer de l'espace d'adressage primaire à secondaire (ou inversement) en utilisant l’instruction ''SET ADDRESS SPACE CONTROL''. Mais il peut en profiter pour passer dans un mode où il a accès à 1024 espaces d'adressage différents, en même temps. Le processeur n'a pas besoin d'utiliser d'instructions particulières pour copier des données d'un espace d'adressage à un autre, un simple MOV suffit. Le mode en question est appelé le '''mode ''access register'''''. Le mode ''access register'' donne accès aux '''registres d'accès''', qui lui donnent son nom. Les registres d'accès permettent de sélectionner un espace d'adressage, afin qu'une instruction puisse lire/écrire dedans. Il y a en tout 16 registres d'accès, chacun étant appairé avec un registre général. Par exemple, le registre général numéro 5 est appairé au registre d'accès de même numéro. En mode ''access register'', toute lecture/écriture lit à la fois un registre général et le registre d'accès associé. Le registre général contient un pointeur, l'adresse à lire/écrire, et le registre d'accès indique dans quel espace d'adressage se trouve cette adresse. Une implémentation simple numéroterait les espaces d'adressage et mettrait les numéros dans les registres d'accès. Les registres d'accès seraient idéalement protégés en écriture et seulement accessibles par le système d'exploitation. Mais ce n'est pas ce qui est fait. Un programme peut accéder à certains espaces d'adressage bien précis, que lui a attribué le système d'exploitation. Les espaces d'adressages d'un programme sont mémorisés dans une '''liste d'accès privée'''. La liste d'accès est en réalité non pas une liste chainée, mais un tableau. Il contient jusqu’à 1024 entrées, chacune contenant toutes les informations nécessaires pour utiliser un espace d'adressage. Une entrée contient notamment l'adresse mémoire à laquelle commence l'espace d'adressage, qui est utilisée dans divers calculs d'adresse. La liste d'accès privée est gérée par le système d'exploitation, lui seul peut la modifier. Je dis une liste d'accès privée, mais il y en a deux en réalité. Mais la distinction exacte demanderait de parler de ''threads'' et de processus. Pour ceux qui sont familiers avec ces concepts, chaque ''thread'' a sa propre liste privée, avec en plus une liste par processus partagée entre tous les ''threads''. Deux listes de 1024 entrées maximum, cela fait 2048 entrées au total. Les deux listes portent les noms de ''Dispatchable Unit Access List'' et de ''PASN-AL (Primary Address Space Access List''. Inutile de retenir ces deux termes, cependant. Un registre d'accès mémorise un '''''Access List Entry Token''''' ou ALET. Un ALET regroupe plusieurs informations. Déjà, il contient un bit qui indique quelle liste privée utiliser, laquelle des deux. Ensuite, il contient un indice qui sélectionne une entrée dans la liste privée choisie. L'ALET peut avoir plusieurs valeurs, mais deux d'entre elles sont pré-déterminées : 0 pointe vers l'espace d'adressage principal, 1 vers l'espace d'adressage secondaire. Les autres valeurs sont configurables. : Précisons que le registre d'accès numéro 0 contient obligatoirement la valeur 0, ce qui fait qu'il pointe vers l'espace d'adressage primaire. Lors d'une lecture/écriture, l'ALETs est utilisé pour sélectionner l'espace d'adressage. Les valeurs 0 et 1 sont traitées telles quelles, l'espace d'adressage est déterminé automatiquement par le processeur. Pour les autres valeurs, récupérer l'adresse mémoire demande de consulter la liste d'accès privée. L'ALET est envoyé à la liste d'accès privée, l'adresse est récupérée. Le processeur connait l'adresse de la liste privée, car son adresse mémoire est mémorisée dans les registres de contrôle numéro 2 et 5. Une subtilité est que le système d'exploitation peut à tout moment modifier le contenu de la liste privée. Il se peut qu'un ALET qui correspondait à un espace d'adressage se mette à pointer vers un autre. Pour détecter cette situation, une entrée de la liste privée contient un identifiant, qui est modifié à chaque fois que le système d'exploitation modifie l'entrée. L'ALET mémorise aussi cet identifiant, qui est obtenu lors de la création de l'ALET. <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> ogt4tt430qoad4ciosxr608r9rk12i1 774385 774384 2026-10-07T18:27:59Z Mewtow 31375 /* Le mode standard de la z/architecture */ 774385 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. ==Un cas particulier : la z/architecture d'IBM== IBM a sa propre gamme de ''mainframes'', qui utilisent des processeurs fait maison par IBM lui-même. Les CPU IBM ont leur propre jeu d'instruction CISC, appelé '''''z/architecture'''''. Et ce jeu d'instruction a des possibilités en termes d'adressage qui sont extrêmement intéressantes, en plus d'être assez inédites. La ''z/architecture'' gère des pages de 4 kibioctets, en plus de pages larges introduites dans les dernières versions. L traduction d’adresse se fait cependant en passant des regroupements de 4 pages virtuelles, appelés des ''segments'', qui n'ont rien à voir avec les segments au sens de la segmentation. Pour éviter les confusions, je vais les appeler des ''super-pages''. Les super-pages sont des groupes de 4 pages virtuelles, ce qui veut dire que les 4 pages peuvent être très éloignées en mémoire physique. Les super-pages sont concrètement des intermédiaires utilisés pour la traduction d'adresse. La traduction d'adresse démarre à une table des super-pages, qui point vers une table des pages à plusieurs niveaux (5 maximum). Pour ce chapitre, nous allons nous concentrer sur une fonctionnalité : les programmes ont accès à plusieurs espaces d'adressage simultanément. La ''z/architecture'' supporte 65536 espaces d'adressage simultanés. Par contre, un programme ne peut en utiliser que 2048 en même temps. De plus, ils ne sont accessibles que quand le processeur est dans un mode de fonctionnement bien précis. Un ^processeur ''z/architecture'' peut en effet fonctionner dans deux modes, qu'on va appeler "standard" et "étendu". ===Le mode standard de la ''z/architecture''=== En mode standard, un programme exécuté sur la ''z/architecture'' utilise deux espaces d'adressages : l''''espace d'adressage primaire''' où se situent le programme exécuté et les données importantes, un '''espace d'adressage secondaire''' utilisé pour des tâches/données annexes. : Un troisième espace d'adressage l''''espace d'adressage ''home''''', est réservé au système d'exploitation. Il a pour particularité de ne pas avoir d'ASN. Il n'est concrètement accesible qu'en mode superviseur, donc par le système d'exploitation. C'est en quelque sorte un espace d'adressage pour le noyau, séparé des espaces d'adressage utilisateur. Nous n'en parlerons pas dans ce qui suit. Mais comment le processeur pour utiliser ces espaces d'adressages ? Par exemple, s'il veut faire une lecture, comment précise-t-il l'espace d'adressage courant ? La réponse est que le processeur peut être dans deux états : soit primaire, soit secondaire. Dans le premier état, toutes les lectures/écritures se font dans l'espace d'adressage primaire. Dans le second, elles se font toutes dans l'espace d'adressage secondaire. Il peut passer de l'un à l'autre en utilisant l'instruction ''Set Address Space Control''. Le processeur dispose aussi d'instructions ''MOVE TO PRIMARY'' et ''MOVE TO SECONDARY'' pour échanger des données dans les deux espaces d'adressage. L'espace d'adressage primaire a une table des pages dédiée, séparée de celle de l'espace d'adressage secondaire. Ou plutôt devrais-je dire une table des super-pages. La table des pages est une table des pages emboitée. Il y a peu à détailler dessus, si ce n'est pour cette histoire de segments/super-pages. L'adresse de la table des super-page est mémorisée dans le registre de contrôle CR1 pour l'espace d'adressage primaire, CR7 pour l'espace d'adressage secondaire. ===Les registres d'accès de la ''z/architecture''=== Un programme peut donc passer de l'espace d'adressage primaire à secondaire (ou inversement) en utilisant l’instruction ''SET ADDRESS SPACE CONTROL''. Mais il peut en profiter pour passer dans un mode où il a accès à 1024 espaces d'adressage différents, en même temps. Le processeur n'a pas besoin d'utiliser d'instructions particulières pour copier des données d'un espace d'adressage à un autre, un simple MOV suffit. Le mode en question est appelé le '''mode ''access register'''''. Le mode ''access register'' donne accès aux '''registres d'accès''', qui lui donnent son nom. Les registres d'accès permettent de sélectionner un espace d'adressage, afin qu'une instruction puisse lire/écrire dedans. Il y a en tout 16 registres d'accès, chacun étant appairé avec un registre général. Par exemple, le registre général numéro 5 est appairé au registre d'accès de même numéro. En mode ''access register'', toute lecture/écriture lit à la fois un registre général et le registre d'accès associé. Le registre général contient un pointeur, l'adresse à lire/écrire, et le registre d'accès indique dans quel espace d'adressage se trouve cette adresse. Une implémentation simple numéroterait les espaces d'adressage et mettrait les numéros dans les registres d'accès. Les registres d'accès seraient idéalement protégés en écriture et seulement accessibles par le système d'exploitation. Mais ce n'est pas ce qui est fait. Un programme peut accéder à certains espaces d'adressage bien précis, que lui a attribué le système d'exploitation. Les espaces d'adressages d'un programme sont mémorisés dans une '''liste d'accès privée'''. La liste d'accès est en réalité non pas une liste chainée, mais un tableau. Il contient jusqu’à 1024 entrées, chacune contenant toutes les informations nécessaires pour utiliser un espace d'adressage. Une entrée contient notamment l'adresse mémoire à laquelle commence l'espace d'adressage, qui est utilisée dans divers calculs d'adresse. La liste d'accès privée est gérée par le système d'exploitation, lui seul peut la modifier. Je dis une liste d'accès privée, mais il y en a deux en réalité. Mais la distinction exacte demanderait de parler de ''threads'' et de processus. Pour ceux qui sont familiers avec ces concepts, chaque ''thread'' a sa propre liste privée, avec en plus une liste par processus partagée entre tous les ''threads''. Deux listes de 1024 entrées maximum, cela fait 2048 entrées au total. Les deux listes portent les noms de ''Dispatchable Unit Access List'' et de ''PASN-AL (Primary Address Space Access List''. Inutile de retenir ces deux termes, cependant. Un registre d'accès mémorise un '''''Access List Entry Token''''' ou ALET. Un ALET regroupe plusieurs informations. Déjà, il contient un bit qui indique quelle liste privée utiliser, laquelle des deux. Ensuite, il contient un indice qui sélectionne une entrée dans la liste privée choisie. L'ALET peut avoir plusieurs valeurs, mais deux d'entre elles sont pré-déterminées : 0 pointe vers l'espace d'adressage principal, 1 vers l'espace d'adressage secondaire. Les autres valeurs sont configurables. : Précisons que le registre d'accès numéro 0 contient obligatoirement la valeur 0, ce qui fait qu'il pointe vers l'espace d'adressage primaire. Lors d'une lecture/écriture, l'ALETs est utilisé pour sélectionner l'espace d'adressage. Les valeurs 0 et 1 sont traitées telles quelles, l'espace d'adressage est déterminé automatiquement par le processeur. Pour les autres valeurs, récupérer l'adresse mémoire demande de consulter la liste d'accès privée. L'ALET est envoyé à la liste d'accès privée, l'adresse est récupérée. Le processeur connait l'adresse de la liste privée, car son adresse mémoire est mémorisée dans les registres de contrôle numéro 2 et 5. Une subtilité est que le système d'exploitation peut à tout moment modifier le contenu de la liste privée. Il se peut qu'un ALET qui correspondait à un espace d'adressage se mette à pointer vers un autre. Pour détecter cette situation, une entrée de la liste privée contient un identifiant, qui est modifié à chaque fois que le système d'exploitation modifie l'entrée. L'ALET mémorise aussi cet identifiant, qui est obtenu lors de la création de l'ALET. <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> kpgrvaubptguvixj62yhbrwfjazieom 774386 774385 2026-10-07T18:32:24Z Mewtow 31375 /* Le mode standard de la z/architecture */ 774386 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. ==Un cas particulier : la z/architecture d'IBM== IBM a sa propre gamme de ''mainframes'', qui utilisent des processeurs fait maison par IBM lui-même. Les CPU IBM ont leur propre jeu d'instruction CISC, appelé '''''z/architecture'''''. Et ce jeu d'instruction a des possibilités en termes d'adressage qui sont extrêmement intéressantes, en plus d'être assez inédites. La ''z/architecture'' gère des pages de 4 kibioctets, en plus de pages larges introduites dans les dernières versions. L traduction d’adresse se fait cependant en passant des regroupements de 4 pages virtuelles, appelés des ''segments'', qui n'ont rien à voir avec les segments au sens de la segmentation. Pour éviter les confusions, je vais les appeler des ''super-pages''. Les super-pages sont des groupes de 4 pages virtuelles, ce qui veut dire que les 4 pages peuvent être très éloignées en mémoire physique. Les super-pages sont concrètement des intermédiaires utilisés pour la traduction d'adresse. La traduction d'adresse démarre à une table des super-pages, qui point vers une table des pages à plusieurs niveaux (5 maximum). Pour ce chapitre, nous allons nous concentrer sur une fonctionnalité : les programmes ont accès à plusieurs espaces d'adressage simultanément. La ''z/architecture'' supporte 65536 espaces d'adressage simultanés. Par contre, un programme ne peut en utiliser que 2048 en même temps. De plus, ils ne sont accessibles que quand le processeur est dans un mode de fonctionnement bien précis. Un ^processeur ''z/architecture'' peut en effet fonctionner dans deux modes, qu'on va appeler "standard" et "étendu". ===Le mode standard de la ''z/architecture''=== En mode standard, un programme exécuté sur la ''z/architecture'' utilise deux espaces d'adressages : l''''espace d'adressage primaire''' où se situent le programme exécuté et les données importantes, un '''espace d'adressage secondaire''' utilisé pour des tâches/données annexes. : Un troisième espace d'adressage l''''espace d'adressage ''home''''', est réservé au système d'exploitation. Il a pour particularité de ne pas avoir d'ASN. Il n'est concrètement accesible qu'en mode superviseur, donc par le système d'exploitation. C'est en quelque sorte un espace d'adressage pour le noyau, séparé des espaces d'adressage utilisateur. Nous n'en parlerons pas dans ce qui suit. Mais comment le processeur pour utiliser ces espaces d'adressages ? Par exemple, s'il veut faire une lecture, comment précise-t-il l'espace d'adressage courant ? La réponse est que le processeur peut être dans deux états : soit primaire, soit secondaire. Dans le premier état, toutes les lectures/écritures se font dans l'espace d'adressage primaire. Dans le second, elles se font toutes dans l'espace d'adressage secondaire. Il peut passer de l'un à l'autre en utilisant l'instruction ''Set Address Space Control''. Le processeur dispose aussi d'instructions ''MOVE TO PRIMARY'' et ''MOVE TO SECONDARY'' pour échanger des données dans les deux espaces d'adressage. L'espace d'adressage primaire a une table des pages dédiée, séparée de celle de l'espace d'adressage secondaire. Ou plutôt devrais-je dire une table des super-pages. La table des pages est une table des pages emboitée. Il y a peu à détailler dessus, si ce n'est pour cette histoire de segments/super-pages. L'adresse de la table des super-page est mémorisée dans le registre de contrôle CR1 pour l'espace d'adressage primaire, CR7 pour l'espace d'adressage secondaire. L'un ou l'autre est utilisé, selon que le processeur est en mode "primaire" ou "secondaire". ===Les registres d'accès de la ''z/architecture''=== Un programme peut donc passer de l'espace d'adressage primaire à secondaire (ou inversement) en utilisant l’instruction ''SET ADDRESS SPACE CONTROL''. Mais il peut en profiter pour passer dans un mode où il a accès à 1024 espaces d'adressage différents, en même temps. Le processeur n'a pas besoin d'utiliser d'instructions particulières pour copier des données d'un espace d'adressage à un autre, un simple MOV suffit. Le mode en question est appelé le '''mode ''access register'''''. Le mode ''access register'' donne accès aux '''registres d'accès''', qui lui donnent son nom. Les registres d'accès permettent de sélectionner un espace d'adressage, afin qu'une instruction puisse lire/écrire dedans. Il y a en tout 16 registres d'accès, chacun étant appairé avec un registre général. Par exemple, le registre général numéro 5 est appairé au registre d'accès de même numéro. En mode ''access register'', toute lecture/écriture lit à la fois un registre général et le registre d'accès associé. Le registre général contient un pointeur, l'adresse à lire/écrire, et le registre d'accès indique dans quel espace d'adressage se trouve cette adresse. Une implémentation simple numéroterait les espaces d'adressage et mettrait les numéros dans les registres d'accès. Les registres d'accès seraient idéalement protégés en écriture et seulement accessibles par le système d'exploitation. Mais ce n'est pas ce qui est fait. Un programme peut accéder à certains espaces d'adressage bien précis, que lui a attribué le système d'exploitation. Les espaces d'adressages d'un programme sont mémorisés dans une '''liste d'accès privée'''. La liste d'accès est en réalité non pas une liste chainée, mais un tableau. Il contient jusqu’à 1024 entrées, chacune contenant toutes les informations nécessaires pour utiliser un espace d'adressage. Une entrée contient notamment l'adresse mémoire à laquelle commence l'espace d'adressage, qui est utilisée dans divers calculs d'adresse. La liste d'accès privée est gérée par le système d'exploitation, lui seul peut la modifier. Je dis une liste d'accès privée, mais il y en a deux en réalité. Mais la distinction exacte demanderait de parler de ''threads'' et de processus. Pour ceux qui sont familiers avec ces concepts, chaque ''thread'' a sa propre liste privée, avec en plus une liste par processus partagée entre tous les ''threads''. Deux listes de 1024 entrées maximum, cela fait 2048 entrées au total. Les deux listes portent les noms de ''Dispatchable Unit Access List'' et de ''PASN-AL (Primary Address Space Access List''. Inutile de retenir ces deux termes, cependant. Un registre d'accès mémorise un '''''Access List Entry Token''''' ou ALET. Un ALET regroupe plusieurs informations. Déjà, il contient un bit qui indique quelle liste privée utiliser, laquelle des deux. Ensuite, il contient un indice qui sélectionne une entrée dans la liste privée choisie. L'ALET peut avoir plusieurs valeurs, mais deux d'entre elles sont pré-déterminées : 0 pointe vers l'espace d'adressage principal, 1 vers l'espace d'adressage secondaire. Les autres valeurs sont configurables. : Précisons que le registre d'accès numéro 0 contient obligatoirement la valeur 0, ce qui fait qu'il pointe vers l'espace d'adressage primaire. Lors d'une lecture/écriture, l'ALETs est utilisé pour sélectionner l'espace d'adressage. Les valeurs 0 et 1 sont traitées telles quelles, l'espace d'adressage est déterminé automatiquement par le processeur. Pour les autres valeurs, récupérer l'adresse mémoire demande de consulter la liste d'accès privée. L'ALET est envoyé à la liste d'accès privée, l'adresse est récupérée. Le processeur connait l'adresse de la liste privée, car son adresse mémoire est mémorisée dans les registres de contrôle numéro 2 et 5. Une subtilité est que le système d'exploitation peut à tout moment modifier le contenu de la liste privée. Il se peut qu'un ALET qui correspondait à un espace d'adressage se mette à pointer vers un autre. Pour détecter cette situation, une entrée de la liste privée contient un identifiant, qui est modifié à chaque fois que le système d'exploitation modifie l'entrée. L'ALET mémorise aussi cet identifiant, qui est obtenu lors de la création de l'ALET. <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> qvgfoez6mv1yya7kqpyxxt3e74spwbq 774387 774386 2026-10-07T18:52:16Z Mewtow 31375 /* Les registres d'accès de la z/architecture */ 774387 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. ==Un cas particulier : la z/architecture d'IBM== IBM a sa propre gamme de ''mainframes'', qui utilisent des processeurs fait maison par IBM lui-même. Les CPU IBM ont leur propre jeu d'instruction CISC, appelé '''''z/architecture'''''. Et ce jeu d'instruction a des possibilités en termes d'adressage qui sont extrêmement intéressantes, en plus d'être assez inédites. La ''z/architecture'' gère des pages de 4 kibioctets, en plus de pages larges introduites dans les dernières versions. L traduction d’adresse se fait cependant en passant des regroupements de 4 pages virtuelles, appelés des ''segments'', qui n'ont rien à voir avec les segments au sens de la segmentation. Pour éviter les confusions, je vais les appeler des ''super-pages''. Les super-pages sont des groupes de 4 pages virtuelles, ce qui veut dire que les 4 pages peuvent être très éloignées en mémoire physique. Les super-pages sont concrètement des intermédiaires utilisés pour la traduction d'adresse. La traduction d'adresse démarre à une table des super-pages, qui point vers une table des pages à plusieurs niveaux (5 maximum). Pour ce chapitre, nous allons nous concentrer sur une fonctionnalité : les programmes ont accès à plusieurs espaces d'adressage simultanément. La ''z/architecture'' supporte 65536 espaces d'adressage simultanés. Par contre, un programme ne peut en utiliser que 2048 en même temps. De plus, ils ne sont accessibles que quand le processeur est dans un mode de fonctionnement bien précis. Un ^processeur ''z/architecture'' peut en effet fonctionner dans deux modes, qu'on va appeler "standard" et "étendu". ===Le mode standard de la ''z/architecture''=== En mode standard, un programme exécuté sur la ''z/architecture'' utilise deux espaces d'adressages : l''''espace d'adressage primaire''' où se situent le programme exécuté et les données importantes, un '''espace d'adressage secondaire''' utilisé pour des tâches/données annexes. : Un troisième espace d'adressage l''''espace d'adressage ''home''''', est réservé au système d'exploitation. Il a pour particularité de ne pas avoir d'ASN. Il n'est concrètement accesible qu'en mode superviseur, donc par le système d'exploitation. C'est en quelque sorte un espace d'adressage pour le noyau, séparé des espaces d'adressage utilisateur. Nous n'en parlerons pas dans ce qui suit. Mais comment le processeur pour utiliser ces espaces d'adressages ? Par exemple, s'il veut faire une lecture, comment précise-t-il l'espace d'adressage courant ? La réponse est que le processeur peut être dans deux états : soit primaire, soit secondaire. Dans le premier état, toutes les lectures/écritures se font dans l'espace d'adressage primaire. Dans le second, elles se font toutes dans l'espace d'adressage secondaire. Il peut passer de l'un à l'autre en utilisant l'instruction ''Set Address Space Control''. Le processeur dispose aussi d'instructions ''MOVE TO PRIMARY'' et ''MOVE TO SECONDARY'' pour échanger des données dans les deux espaces d'adressage. L'espace d'adressage primaire a une table des pages dédiée, séparée de celle de l'espace d'adressage secondaire. Ou plutôt devrais-je dire une table des super-pages. La table des pages est une table des pages emboitée. Il y a peu à détailler dessus, si ce n'est pour cette histoire de segments/super-pages. L'adresse de la table des super-page est mémorisée dans le registre de contrôle CR1 pour l'espace d'adressage primaire, CR7 pour l'espace d'adressage secondaire. L'un ou l'autre est utilisé, selon que le processeur est en mode "primaire" ou "secondaire". ===Les registres d'accès de la ''z/architecture''=== Un programme peut donc passer de l'espace d'adressage primaire à secondaire (ou inversement) en utilisant l’instruction ''SET ADDRESS SPACE CONTROL''. Mais il peut en profiter pour passer dans un mode où il a accès à 2048 espaces d'adressage différents, en même temps. Le processeur n'a pas besoin d'utiliser d'instructions particulières pour copier des données d'un espace d'adressage à un autre, un simple MOV suffit. Le mode en question est appelé le '''mode ''access register'''''. Le mode ''access register'' donne accès aux '''registres d'accès''', qui lui donnent son nom. Les registres d'accès mémorisent des identifiants qui permettent de sélectionner un espace d'adressage parmi 2048. Il y a en tout 16 registres d'accès, chacun étant appairé avec un registre général. Par exemple, le registre général numéro 5 est appairé au registre d'accès de même numéro. En mode ''access register'', toute lecture/écriture lit à la fois un registre général et le registre d'accès associé. Le registre général contient un pointeur, l'adresse à lire/écrire, et le registre d'accès indique dans quel espace d'adressage se trouve cette adresse. Par exemple, prenons une lecture en mode d'adressage indirect. L'adresse à lire est, par exemple dans le registre général numéro 7, l'espace d'adressage associé sera dans le registre d'accès numéro 7. Une implémentation simple numéroterait les espaces d'adressage et mettrait les numéros dans les registres d'accès. Les registres d'accès seraient idéalement protégés en écriture et seulement accessibles par le système d'exploitation. Mais ce n'est pas exactement ce qui est fait. Le système d'exploitation utilise une table des super-pages par espace d'adressage mémoire. Elles sont toutes mémorisées en mémoire RAM, et chacune commence à une adresse mémoire bien précise. Mais le processeur ne va pas mémoriser 2048 adresses différentes dans ses registres de contrôle. A la place, il mémorise l'adresse de départ de chaque table des super-pages dans des tables dédiées. La table principale est l''''''Access Space Table Entry''''' (ASTE). Elle contient une entrée par espace d'adressage, chaque entrée mémorisant : * l'adresse des tables des super-pages ; * les droits d'accès à cet espace d'adressage ; * un bit de validité, qui permet d'invalider une entrée en modifiant ce bit ; * des données de protection pour éviter une corruption des entrées, qui seront décrites plus bas. Les programmes n'accèdent pas directement à l'ASTE. Il faut dire que l'ASTE est une table globale, est unique pour un espace d'adressage, qui peut être partagée par plusieurs programmes. Si deux programmes partagent une même zone de mémoire, partagent un espace d'adressage, cela ne veut pas dire qu'ils ont les mêmes droits d'accès dessus. Pour gérer la situation, la ''z/architecture'' ajoute un niveau d'indirection. Les programmes n'utilisent pas l'ASTE, mais passent par une seconde table avant l'arriver à l'ASTE. La second table, qui n'est jamais partagée entre deux programmes, s'appelle la '''liste privée'''. Elle mémorise une liste des espaces d'adressage auquel le programme peut accéder, auxquels le système d'exploitation lui a accordé l'accès. Je dis une liste d'accès privée, mais il y en a deux en réalité. Mais la distinction exacte demanderait de parler de ''threads'' et de processus. Pour ceux qui sont familiers avec ces concepts, chaque ''thread'' a sa propre liste privée, avec en plus une liste par processus partagée entre tous les ''threads''. Deux listes de 1024 entrées maximum, cela fait 2048 entrées au total. Les deux listes portent les noms de ''Dispatchable Unit Access List'' et de ''PASN-AL (Primary Address Space Access List''. Inutile de retenir ces deux termes, cependant. Un registre d'accès mémorise un '''''Access List Entry Token''''' ou ALET. L'ALET peut avoir plusieurs valeurs, mais deux d'entre elles sont pré-déterminées : 0 pointe vers l'espace d'adressage principal, 1 vers l'espace d'adressage secondaire. Les autres valeurs sont configurables. Les autres valeurs regroupent plusieurs informations. Déjà, il contient un bit qui indique quelle liste privée utiliser, entre la ''Dispatchable Unit Access List'' et la ''PASN-AL (Primary Address Space Access List''. Ensuite, il contient un indice qui sélectionne une entrée dans la liste privée choisie. : Précisons que le registre d'accès numéro 0 contient obligatoirement la valeur 0, ce qui fait qu'il pointe vers l'espace d'adressage primaire. Lors d'une lecture/écriture, l'ALETs est utilisé pour sélectionner l'espace d'adressage. Les valeurs 0 et 1 sont traitées telles quelles, l'espace d'adressage est déterminé automatiquement par le processeur. Pour les autres valeurs, récupérer l'adresse mémoire demande de consulter la liste d'accès privée. L'ALET est envoyé à la liste d'accès privée, et cette dernière fournit l'adresse de l'entrée de l'ASTE à consulter. Le processeur connait l'adresse de la liste privée, car son adresse mémoire est mémorisée dans les registres de contrôle numéro 2 et 5. Une subtilité est que le système d'exploitation peut à tout moment modifier le contenu de la liste privée. Il se peut qu'un ALET qui correspondait à un espace d'adressage se mette à pointer vers un autre. Pour détecter cette situation, une entrée de la liste privée contient un identifiant, qui est modifié à chaque fois que le système d'exploitation modifie l'entrée. L'ALET mémorise aussi cet identifiant, qui est obtenu lors de la création de l'ALET. <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> f65v6ujn2yvv3eg75kbstkco52n4g3i 774388 774387 2026-10-07T19:03:41Z Mewtow 31375 /* Les registres d'accès de la z/architecture */ 774388 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. ==Un cas particulier : la z/architecture d'IBM== IBM a sa propre gamme de ''mainframes'', qui utilisent des processeurs fait maison par IBM lui-même. Les CPU IBM ont leur propre jeu d'instruction CISC, appelé '''''z/architecture'''''. Et ce jeu d'instruction a des possibilités en termes d'adressage qui sont extrêmement intéressantes, en plus d'être assez inédites. La ''z/architecture'' gère des pages de 4 kibioctets, en plus de pages larges introduites dans les dernières versions. L traduction d’adresse se fait cependant en passant des regroupements de 4 pages virtuelles, appelés des ''segments'', qui n'ont rien à voir avec les segments au sens de la segmentation. Pour éviter les confusions, je vais les appeler des ''super-pages''. Les super-pages sont des groupes de 4 pages virtuelles, ce qui veut dire que les 4 pages peuvent être très éloignées en mémoire physique. Les super-pages sont concrètement des intermédiaires utilisés pour la traduction d'adresse. La traduction d'adresse démarre à une table des super-pages, qui point vers une table des pages à plusieurs niveaux (5 maximum). Pour ce chapitre, nous allons nous concentrer sur une fonctionnalité : les programmes ont accès à plusieurs espaces d'adressage simultanément. La ''z/architecture'' supporte 65536 espaces d'adressage simultanés. Par contre, un programme ne peut en utiliser que 2048 en même temps. De plus, ils ne sont accessibles que quand le processeur est dans un mode de fonctionnement bien précis. Un ^processeur ''z/architecture'' peut en effet fonctionner dans deux modes, qu'on va appeler "standard" et "étendu". ===Le mode standard de la ''z/architecture''=== En mode standard, un programme exécuté sur la ''z/architecture'' utilise deux espaces d'adressages : l''''espace d'adressage primaire''' où se situent le programme exécuté et les données importantes, un '''espace d'adressage secondaire''' utilisé pour des tâches/données annexes. : Un troisième espace d'adressage l''''espace d'adressage ''home''''', est réservé au système d'exploitation. Il a pour particularité de ne pas avoir d'ASN. Il n'est concrètement accesible qu'en mode superviseur, donc par le système d'exploitation. C'est en quelque sorte un espace d'adressage pour le noyau, séparé des espaces d'adressage utilisateur. Nous n'en parlerons pas dans ce qui suit. Mais comment le processeur pour utiliser ces espaces d'adressages ? Par exemple, s'il veut faire une lecture, comment précise-t-il l'espace d'adressage courant ? La réponse est que le processeur peut être dans deux états : soit primaire, soit secondaire. Dans le premier état, toutes les lectures/écritures se font dans l'espace d'adressage primaire. Dans le second, elles se font toutes dans l'espace d'adressage secondaire. Il peut passer de l'un à l'autre en utilisant l'instruction ''Set Address Space Control''. Le processeur dispose aussi d'instructions ''MOVE TO PRIMARY'' et ''MOVE TO SECONDARY'' pour échanger des données dans les deux espaces d'adressage. L'espace d'adressage primaire a une table des pages dédiée, séparée de celle de l'espace d'adressage secondaire. Ou plutôt devrais-je dire une table des super-pages. La table des pages est une table des pages emboitée. Il y a peu à détailler dessus, si ce n'est pour cette histoire de segments/super-pages. L'adresse de la table des super-page est mémorisée dans le registre de contrôle CR1 pour l'espace d'adressage primaire, CR7 pour l'espace d'adressage secondaire. L'un ou l'autre est utilisé, selon que le processeur est en mode "primaire" ou "secondaire". ===Les registres d'accès de la ''z/architecture''=== Un programme peut donc passer de l'espace d'adressage primaire à secondaire (ou inversement) en utilisant l’instruction ''SET ADDRESS SPACE CONTROL''. Mais il peut en profiter pour passer dans un mode où il a accès à 2048 espaces d'adressage différents, en même temps. Le processeur n'a pas besoin d'utiliser d'instructions particulières pour copier des données d'un espace d'adressage à un autre, un simple MOV suffit. Le mode en question est appelé le '''mode ''access register'''''. Le mode ''access register'' donne accès aux '''registres d'accès''', qui lui donnent son nom. Les registres d'accès mémorisent des identifiants qui permettent de sélectionner un espace d'adressage parmi 2048. Il y a en tout 16 registres d'accès, chacun étant appairé avec un registre général. Par exemple, le registre général numéro 5 est appairé au registre d'accès de même numéro. En mode ''access register'', toute lecture/écriture lit à la fois un registre général et le registre d'accès associé. Le registre général contient un pointeur, l'adresse à lire/écrire, et le registre d'accès indique dans quel espace d'adressage se trouve cette adresse. Par exemple, prenons une lecture en mode d'adressage indirect. L'adresse à lire est, par exemple dans le registre général numéro 7, l'espace d'adressage associé sera dans le registre d'accès numéro 7. Une implémentation simple numéroterait les espaces d'adressage et mettrait les numéros dans les registres d'accès. Un espace d'adressage serait associé à une table des super-pages. Reste à mémoriser la correspondance ''numéro d'espace d'adressage - adresse de la table des super-pages''. Pour cela, l'idéal serait de mémoriser ces 2048 adresses dans un tableau en mémoire RAM. Le numéro d'espace d'adressage serait alors un indice permettant d'adresser l'entrée adéquate de cette table. Les registres d'accès seraient idéalement protégés en écriture et seulement accessibles par le système d'exploitation. Mais ce n'est pas exactement ce qui est fait. Déjà, il n'y a pas une table de correspondance unique, mais un système à deux tableaux imbriqués. Le premier est consulté, puis le second. Le second tableau possède 65536 entrées, une par espace d'adressage supporté par le processeur. C'est donc une table partagée par tous les programmes. Par contre, la première table est bien unique pour chaque programme, chaque programme a la sienne. Faire ainsi permet de simplifier le partage de la mémoire. La table partagée est l''''''Access Space Table Entry''''' (ASTE). Elle contient une entrée par espace d'adressage, chaque entrée mémorisant : * l'adresse des tables des super-pages ; * les droits d'accès à cet espace d'adressage ; * un bit de validité, qui permet d'invalider une entrée en modifiant ce bit ; * des données de protection pour éviter une corruption des entrées, qui seront décrites plus bas. Les programmes n'accèdent pas directement à l'ASTE, mais à une table qui leur est propre : la '''liste privée'''. Elle mémorise une liste des espaces d'adressage auquel le programme peut accéder, auxquels le système d'exploitation lui a accordé l'accès. Pour chaque espace d'adressage, elle renvoie vers l'entrée de l'ASTE associée. En plus de mémoriser l'adresse de cette entrée, elle mémorise les droits d'accès que le programme a sur l'espace d'adressage associé. Par exemple, si deux programmes partagent un espace d'adressage, l'un pourra y avoir accès en lecture seule, l'autre pourra avoir un accès total. Pour cela, ils ont des droits différents dans la liste privée, mais l'entrée de l'ASTE sera la même. Je dis une liste d'accès privée, mais il y en a deux en réalité. Mais la distinction exacte demanderait de parler de ''threads'' et de processus. Pour ceux qui sont familiers avec ces concepts, chaque ''thread'' a sa propre liste privée, avec en plus une liste par processus partagée entre tous les ''threads''. Deux listes de 1024 entrées maximum, cela fait 2048 entrées au total. Les deux listes portent les noms de ''Dispatchable Unit Access List'' et de ''PASN-AL (Primary Address Space Access List''. Inutile de retenir ces deux termes, cependant. Un registre d'accès mémorise un '''''Access List Entry Token''''' ou ALET. L'ALET peut avoir plusieurs valeurs, mais deux d'entre elles sont pré-déterminées : 0 pointe vers l'espace d'adressage principal, 1 vers l'espace d'adressage secondaire. Les autres valeurs sont configurables. Les autres valeurs regroupent plusieurs informations. Déjà, il contient un bit qui indique quelle liste privée utiliser, entre la ''Dispatchable Unit Access List'' et la ''PASN-AL (Primary Address Space Access List''. Ensuite, il contient un indice qui sélectionne une entrée dans la liste privée choisie. : Précisons que le registre d'accès numéro 0 contient obligatoirement la valeur 0, ce qui fait qu'il pointe vers l'espace d'adressage primaire. Lors d'une lecture/écriture, l'ALETs est utilisé pour sélectionner l'espace d'adressage. Les valeurs 0 et 1 sont traitées telles quelles, l'espace d'adressage est déterminé automatiquement par le processeur. Pour les autres valeurs, récupérer l'adresse mémoire demande de consulter la liste d'accès privée. L'ALET est envoyé à la liste d'accès privée, et cette dernière fournit l'adresse de l'entrée de l'ASTE à consulter. Le processeur connait l'adresse de la liste privée, car son adresse mémoire est mémorisée dans les registres de contrôle numéro 2 et 5. Une subtilité est que le système d'exploitation peut à tout moment modifier le contenu de la liste privée. Il se peut qu'un ALET qui correspondait à un espace d'adressage se mette à pointer vers un autre. Pour détecter cette situation, une entrée de la liste privée contient un identifiant, qui est modifié à chaque fois que le système d'exploitation modifie l'entrée. L'ALET mémorise aussi cet identifiant, qui est obtenu lors de la création de l'ALET. <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> q3fex0zn3xfacmue4qqn5ngg8b3v1lk 774389 774388 2026-10-07T19:16:54Z Mewtow 31375 /* Les registres d'accès de la z/architecture */ 774389 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. ==Un cas particulier : la z/architecture d'IBM== IBM a sa propre gamme de ''mainframes'', qui utilisent des processeurs fait maison par IBM lui-même. Les CPU IBM ont leur propre jeu d'instruction CISC, appelé '''''z/architecture'''''. Et ce jeu d'instruction a des possibilités en termes d'adressage qui sont extrêmement intéressantes, en plus d'être assez inédites. La ''z/architecture'' gère des pages de 4 kibioctets, en plus de pages larges introduites dans les dernières versions. L traduction d’adresse se fait cependant en passant des regroupements de 4 pages virtuelles, appelés des ''segments'', qui n'ont rien à voir avec les segments au sens de la segmentation. Pour éviter les confusions, je vais les appeler des ''super-pages''. Les super-pages sont des groupes de 4 pages virtuelles, ce qui veut dire que les 4 pages peuvent être très éloignées en mémoire physique. Les super-pages sont concrètement des intermédiaires utilisés pour la traduction d'adresse. La traduction d'adresse démarre à une table des super-pages, qui point vers une table des pages à plusieurs niveaux (5 maximum). Pour ce chapitre, nous allons nous concentrer sur une fonctionnalité : les programmes ont accès à plusieurs espaces d'adressage simultanément. La ''z/architecture'' supporte 65536 espaces d'adressage simultanés. Par contre, un programme ne peut en utiliser que 2048 en même temps. De plus, ils ne sont accessibles que quand le processeur est dans un mode de fonctionnement bien précis. Un ^processeur ''z/architecture'' peut en effet fonctionner dans deux modes, qu'on va appeler "standard" et "étendu". ===Le mode standard de la ''z/architecture''=== En mode standard, un programme exécuté sur la ''z/architecture'' utilise deux espaces d'adressages : l''''espace d'adressage primaire''' où se situent le programme exécuté et les données importantes, un '''espace d'adressage secondaire''' utilisé pour des tâches/données annexes. : Un troisième espace d'adressage l''''espace d'adressage ''home''''', est réservé au système d'exploitation. Il a pour particularité de ne pas avoir d'ASN. Il n'est concrètement accesible qu'en mode superviseur, donc par le système d'exploitation. C'est en quelque sorte un espace d'adressage pour le noyau, séparé des espaces d'adressage utilisateur. Nous n'en parlerons pas dans ce qui suit. Mais comment le processeur pour utiliser ces espaces d'adressages ? Par exemple, s'il veut faire une lecture, comment précise-t-il l'espace d'adressage courant ? La réponse est que le processeur peut être dans deux états : soit primaire, soit secondaire. Dans le premier état, toutes les lectures/écritures se font dans l'espace d'adressage primaire. Dans le second, elles se font toutes dans l'espace d'adressage secondaire. Il peut passer de l'un à l'autre en utilisant l'instruction ''Set Address Space Control''. Le processeur dispose aussi d'instructions ''MOVE TO PRIMARY'' et ''MOVE TO SECONDARY'' pour échanger des données dans les deux espaces d'adressage. L'espace d'adressage primaire a une table des pages dédiée, séparée de celle de l'espace d'adressage secondaire. Ou plutôt devrais-je dire une table des super-pages. La table des pages est une table des pages emboitée. Il y a peu à détailler dessus, si ce n'est pour cette histoire de segments/super-pages. L'adresse de la table des super-page est mémorisée dans le registre de contrôle CR1 pour l'espace d'adressage primaire, CR7 pour l'espace d'adressage secondaire. L'un ou l'autre est utilisé, selon que le processeur est en mode "primaire" ou "secondaire". ===Les registres d'accès de la ''z/architecture''=== Un programme peut donc passer de l'espace d'adressage primaire à secondaire (ou inversement) en utilisant l’instruction ''SET ADDRESS SPACE CONTROL''. Mais il peut en profiter pour passer dans un mode où il a accès à 2048 espaces d'adressage différents, en même temps. Le processeur n'a pas besoin d'utiliser d'instructions particulières pour copier des données d'un espace d'adressage à un autre, un simple MOV suffit. Le mode en question est appelé le '''mode ''access register'''''. Le mode ''access register'' donne accès aux '''registres d'accès''', qui lui donnent son nom. Les registres d'accès mémorisent des identifiants qui permettent de sélectionner un espace d'adressage parmi 2048. Il y a en tout 16 registres d'accès, chacun étant appairé avec un registre général. Par exemple, le registre général numéro 5 est appairé au registre d'accès de même numéro. En mode ''access register'', toute lecture/écriture lit à la fois un registre général et le registre d'accès associé. Le registre général contient un pointeur, l'adresse à lire/écrire, et le registre d'accès indique dans quel espace d'adressage se trouve cette adresse. Par exemple, prenons une lecture en mode d'adressage indirect. L'adresse à lire est, par exemple dans le registre général numéro 7, l'espace d'adressage associé sera dans le registre d'accès numéro 7. Une implémentation simple numéroterait les espaces d'adressage et mettrait les numéros dans les registres d'accès. Un espace d'adressage serait associé à une table des super-pages. Reste à mémoriser la correspondance ''numéro d'espace d'adressage - adresse de la table des super-pages''. Pour cela, l'idéal serait de mémoriser ces 2048 adresses dans un tableau en mémoire RAM. Le numéro d'espace d'adressage serait alors un indice permettant d'adresser l'entrée adéquate de cette table. Les registres d'accès seraient idéalement protégés en écriture et seulement accessibles par le système d'exploitation. Mais ce n'est pas exactement ce qui est fait. Déjà, il n'y a pas une table de correspondance unique, mais un système à deux tableaux imbriqués. Le premier est consulté, puis le second. Le second tableau possède 65536 entrées, une par espace d'adressage supporté par le processeur. C'est donc une table partagée par tous les programmes. Par contre, la première table est bien unique pour chaque programme, chaque programme a la sienne. Faire ainsi permet de simplifier le partage de la mémoire. La table partagée est l''''''Access Space Table Entry''''' (ASTE). Elle contient une entrée par espace d'adressage, chaque entrée mémorisant : * l'adresse des tables des super-pages ; * les droits d'accès à cet espace d'adressage ; * un bit de validité, qui permet d'invalider une entrée en modifiant ce bit ; * des données de protection pour éviter une corruption des entrées, qui seront décrites plus bas. Les programmes n'accèdent pas directement à l'ASTE, mais à une table qui leur est propre : la '''liste privée'''. Elle mémorise une liste des espaces d'adressage auquel le programme peut accéder, auxquels le système d'exploitation lui a accordé l'accès. Pour chaque espace d'adressage, elle renvoie vers l'entrée de l'ASTE associée. En plus de mémoriser l'adresse de cette entrée, elle mémorise les droits d'accès que le programme a sur l'espace d'adressage associé. Par exemple, si deux programmes partagent un espace d'adressage, l'un pourra y avoir accès en lecture seule, l'autre pourra avoir un accès total. Pour cela, ils ont des droits différents dans la liste privée, mais l'entrée de l'ASTE sera la même. Je dis une liste d'accès privée, mais il y en a deux en réalité. Mais la distinction exacte demanderait de parler de ''threads'' et de processus. Pour ceux qui sont familiers avec ces concepts, chaque ''thread'' a sa propre liste privée, avec en plus une liste par processus partagée entre tous les ''threads''. Deux listes de 1024 entrées maximum, cela fait 2048 entrées au total. Les deux listes portent les noms de ''Dispatchable Unit Access List'' et de ''PASN-AL (Primary Address Space Access List''. Inutile de retenir ces deux termes, cependant. Un registre d'accès mémorise un '''''Access List Entry Token''''' ou ALET. L'ALET peut avoir plusieurs valeurs, mais deux d'entre elles sont pré-déterminées : 0 pointe vers l'espace d'adressage principal, 1 vers l'espace d'adressage secondaire. Les autres valeurs sont configurables. Les autres valeurs regroupent plusieurs informations. Déjà, il contient un bit qui indique quelle liste privée utiliser, entre la ''Dispatchable Unit Access List'' et la ''PASN-AL (Primary Address Space Access List''. Ensuite, il contient un indice qui sélectionne une entrée dans la liste privée choisie. : Précisons que le registre d'accès numéro 0 contient obligatoirement la valeur 0, ce qui fait qu'il pointe vers l'espace d'adressage primaire. Lors d'une lecture/écriture, l'ALETs est utilisé pour sélectionner l'espace d'adressage. Les valeurs 0 et 1 sont traitées telles quelles, l'espace d'adressage est déterminé automatiquement par le processeur. Pour les autres valeurs, récupérer l'adresse mémoire demande de consulter la liste d'accès privée. L'ALET est envoyé à la liste d'accès privée, et cette dernière fournit l'adresse de l'entrée de l'ASTE à consulter. Le processeur connait l'adresse de la liste privée, car son adresse mémoire est mémorisée dans les registres de contrôle numéro 2 et 5. [[File:Tradiction d'adresse sur la zarchitecture d'IBM.png|centre|vignette|upright=2|Traduction d'adresse sur la zarchitecture d'IBM.]] Une subtilité est que le système d'exploitation peut à tout moment modifier le contenu de la liste privée. Il se peut qu'un ALET qui correspondait à un espace d'adressage se mette à pointer vers un autre. Pour détecter cette situation, une entrée de la liste privée contient un identifiant, qui est modifié à chaque fois que le système d'exploitation modifie l'entrée. L'ALET mémorise aussi cet identifiant, qui est obtenu lors de la création de l'ALET. <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> 6ixhvqrhye0a6wnq9leiml6fzqz3kia 774390 774389 2026-10-07T19:28:26Z Mewtow 31375 /* Les registres d'accès de la z/architecture */ 774390 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. ==Un cas particulier : la z/architecture d'IBM== IBM a sa propre gamme de ''mainframes'', qui utilisent des processeurs fait maison par IBM lui-même. Les CPU IBM ont leur propre jeu d'instruction CISC, appelé '''''z/architecture'''''. Et ce jeu d'instruction a des possibilités en termes d'adressage qui sont extrêmement intéressantes, en plus d'être assez inédites. La ''z/architecture'' gère des pages de 4 kibioctets, en plus de pages larges introduites dans les dernières versions. L traduction d’adresse se fait cependant en passant des regroupements de 4 pages virtuelles, appelés des ''segments'', qui n'ont rien à voir avec les segments au sens de la segmentation. Pour éviter les confusions, je vais les appeler des ''super-pages''. Les super-pages sont des groupes de 4 pages virtuelles, ce qui veut dire que les 4 pages peuvent être très éloignées en mémoire physique. Les super-pages sont concrètement des intermédiaires utilisés pour la traduction d'adresse. La traduction d'adresse démarre à une table des super-pages, qui point vers une table des pages à plusieurs niveaux (5 maximum). Pour ce chapitre, nous allons nous concentrer sur une fonctionnalité : les programmes ont accès à plusieurs espaces d'adressage simultanément. La ''z/architecture'' supporte 65536 espaces d'adressage simultanés. Par contre, un programme ne peut en utiliser que 2048 en même temps. De plus, ils ne sont accessibles que quand le processeur est dans un mode de fonctionnement bien précis. Un ^processeur ''z/architecture'' peut en effet fonctionner dans deux modes, qu'on va appeler "standard" et "étendu". ===Le mode standard de la ''z/architecture''=== En mode standard, un programme exécuté sur la ''z/architecture'' utilise deux espaces d'adressages : l''''espace d'adressage primaire''' où se situent le programme exécuté et les données importantes, un '''espace d'adressage secondaire''' utilisé pour des tâches/données annexes. : Un troisième espace d'adressage l''''espace d'adressage ''home''''', est réservé au système d'exploitation. Il a pour particularité de ne pas avoir d'ASN. Il n'est concrètement accesible qu'en mode superviseur, donc par le système d'exploitation. C'est en quelque sorte un espace d'adressage pour le noyau, séparé des espaces d'adressage utilisateur. Nous n'en parlerons pas dans ce qui suit. Mais comment le processeur pour utiliser ces espaces d'adressages ? Par exemple, s'il veut faire une lecture, comment précise-t-il l'espace d'adressage courant ? La réponse est que le processeur peut être dans deux états : soit primaire, soit secondaire. Dans le premier état, toutes les lectures/écritures se font dans l'espace d'adressage primaire. Dans le second, elles se font toutes dans l'espace d'adressage secondaire. Il peut passer de l'un à l'autre en utilisant l'instruction ''Set Address Space Control''. Le processeur dispose aussi d'instructions ''MOVE TO PRIMARY'' et ''MOVE TO SECONDARY'' pour échanger des données dans les deux espaces d'adressage. L'espace d'adressage primaire a une table des pages dédiée, séparée de celle de l'espace d'adressage secondaire. Ou plutôt devrais-je dire une table des super-pages. La table des pages est une table des pages emboitée. Il y a peu à détailler dessus, si ce n'est pour cette histoire de segments/super-pages. L'adresse de la table des super-page est mémorisée dans le registre de contrôle CR1 pour l'espace d'adressage primaire, CR7 pour l'espace d'adressage secondaire. L'un ou l'autre est utilisé, selon que le processeur est en mode "primaire" ou "secondaire". ===Les registres d'accès de la ''z/architecture''=== Un programme peut donc passer de l'espace d'adressage primaire à secondaire (ou inversement) en utilisant l’instruction ''SET ADDRESS SPACE CONTROL''. Mais il peut en profiter pour passer dans un mode où il a accès à 2048 espaces d'adressage différents, en même temps. Le processeur n'a pas besoin d'utiliser d'instructions particulières pour copier des données d'un espace d'adressage à un autre, un simple MOV suffit. Le mode en question est appelé le '''mode ''access register'''''. Le mode ''access register'' donne accès aux '''registres d'accès''', qui lui donnent son nom. Les registres d'accès mémorisent des identifiants qui permettent de sélectionner un espace d'adressage parmi 2048. Il y a en tout 16 registres d'accès, chacun étant appairé avec un registre général. Par exemple, le registre général numéro 5 est appairé au registre d'accès de même numéro. En mode ''access register'', toute lecture/écriture lit l'adresse dans un registre général et le registre d'accès associé indique dans quel espace d'adressage se trouve cette adresse. Par exemple, une lecture utilise l'adresse dans le registre général numéro 7, l'espace d'adressage associé sera dans le registre d'accès numéro 7. Une implémentation simple numéroterait les espaces d'adressage et mettrait les numéros dans les registres d'accès. Un espace d'adressage serait associé à une table des super-pages, reste à mémoriser la correspondance ''numéro d'espace d'adressage - adresse de la table des super-pages''. Pour cela, l'idéal est de mémoriser ces 2048 adresses dans un tableau en mémoire RAM. Le numéro d'espace d'adressage serait alors un indice permettant d'adresser l'entrée adéquate de cette table. Il devrait y avoir un tableau par programme, qui serait protégé en écriture et seulement accessible par le système d'exploitation, comme le serait une table des pages. Mais ce n'est pas exactement ce qui est fait. Déjà, il n'y a pas une table de correspondance unique, mais un système à deux tableaux imbriqués. Le premier tableau possède 65536 entrées, une par espace d'adressage supporté par le processeur. C'est donc une table partagée par tous les programmes. Par contre, la seconde table est bien unique pour chaque programme, chaque programme a la sienne. Faire ainsi permet de simplifier le partage de la mémoire. La table partagée est l''''''Access Space Table Entry''''' (ASTE). Elle contient une entrée par espace d'adressage, chaque entrée mémorisant : * l'adresse des tables des super-pages ; * les droits d'accès à cet espace d'adressage ; * un bit de validité, qui permet d'invalider une entrée en modifiant ce bit ; * des données de protection pour éviter une corruption des entrées, qui seront décrites plus bas. Les programmes n'accèdent pas directement à l'ASTE, mais à la première table mentionnée plus haut : la '''liste privée''', sous-entendu "privée pour un programme". Elle mémorise une liste des espaces d'adressage auquel le programme peut accéder, auxquels le système d'exploitation lui a accordé l'accès. Pour chaque espace d'adressage, elle renvoie vers l'entrée de l'ASTE associée. En plus de mémoriser l'adresse de cette entrée, elle mémorise les droits d'accès que le programme a sur l'espace d'adressage associé. Par exemple, si deux programmes partagent un espace d'adressage, l'un pourra y avoir accès en lecture seule, l'autre pourra avoir un accès total. Pour cela, ils ont des droits différents dans la liste privée, mais l'entrée de l'ASTE sera la même. Je dis une liste d'accès privée, mais il y en a deux en réalité. Mais la distinction exacte demanderait de parler de ''threads'' et de processus. Pour ceux qui sont familiers avec ces concepts, chaque ''thread'' a sa propre liste privée, avec en plus une liste par processus partagée entre tous les ''threads''. Deux listes de 1024 entrées maximum, cela fait 2048 entrées au total. Les deux listes portent les noms de ''Dispatchable Unit Access List'' et de ''PASN-AL (Primary Address Space Access List''. Inutile de retenir ces deux termes, cependant. Un registre d'accès mémorise un '''''Access List Entry Token''''' ou ALET. L'ALET contient l'indice d'une entrée dans la liste privée. Elle contient aussi un bit qui indique quelle liste privée utiliser, entre la ''Dispatchable Unit Access List'' et la ''PASN-AL (Primary Address Space Access List''. Précisons cependant que deux valeurs font exceptions : 0 pointe vers l'espace d'adressage principal, 1 vers l'espace d'adressage secondaire. Les autres valeurs sont configurables. : Précisons que le registre d'accès numéro 0 contient obligatoirement la valeur 0, ce qui fait qu'il pointe vers l'espace d'adressage primaire. Lors d'une lecture/écriture, l'ALETs est utilisé pour sélectionner l'espace d'adressage. Les valeurs 0 et 1 sont traitées telles quelles, l'espace d'adressage est déterminé automatiquement par le processeur. Pour les autres valeurs, récupérer l'adresse mémoire demande de consulter la liste d'accès privée. L'ALET est envoyé à la liste d'accès privée, et cette dernière fournit l'adresse de l'entrée de l'ASTE à consulter. Le processeur connait l'adresse de la liste privée, car son adresse mémoire est mémorisée dans les registres de contrôle numéro 2 et 5. [[File:Tradiction d'adresse sur la zarchitecture d'IBM.png|centre|vignette|upright=2|Traduction d'adresse sur la zarchitecture d'IBM.]] Une subtilité est que le système d'exploitation peut à tout moment modifier le contenu de la liste privée. Il se peut qu'un ALET qui correspondait à un espace d'adressage se mette à pointer vers un autre. Pour détecter cette situation, une entrée de la liste privée contient un identifiant, qui est modifié à chaque fois que le système d'exploitation modifie l'entrée. L'ALET mémorise aussi cet identifiant, qui est obtenu lors de la création de l'ALET. <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> n0jz67yglqdo6czxbjyizw4yjbdpr4z 774391 774390 2026-10-07T19:34:58Z Mewtow 31375 /* Les registres d'accès de la z/architecture */ 774391 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. ==Un cas particulier : la z/architecture d'IBM== IBM a sa propre gamme de ''mainframes'', qui utilisent des processeurs fait maison par IBM lui-même. Les CPU IBM ont leur propre jeu d'instruction CISC, appelé '''''z/architecture'''''. Et ce jeu d'instruction a des possibilités en termes d'adressage qui sont extrêmement intéressantes, en plus d'être assez inédites. La ''z/architecture'' gère des pages de 4 kibioctets, en plus de pages larges introduites dans les dernières versions. L traduction d’adresse se fait cependant en passant des regroupements de 4 pages virtuelles, appelés des ''segments'', qui n'ont rien à voir avec les segments au sens de la segmentation. Pour éviter les confusions, je vais les appeler des ''super-pages''. Les super-pages sont des groupes de 4 pages virtuelles, ce qui veut dire que les 4 pages peuvent être très éloignées en mémoire physique. Les super-pages sont concrètement des intermédiaires utilisés pour la traduction d'adresse. La traduction d'adresse démarre à une table des super-pages, qui point vers une table des pages à plusieurs niveaux (5 maximum). Pour ce chapitre, nous allons nous concentrer sur une fonctionnalité : les programmes ont accès à plusieurs espaces d'adressage simultanément. La ''z/architecture'' supporte 65536 espaces d'adressage simultanés. Par contre, un programme ne peut en utiliser que 2048 en même temps. De plus, ils ne sont accessibles que quand le processeur est dans un mode de fonctionnement bien précis. Un ^processeur ''z/architecture'' peut en effet fonctionner dans deux modes, qu'on va appeler "standard" et "étendu". ===Le mode standard de la ''z/architecture''=== En mode standard, un programme exécuté sur la ''z/architecture'' utilise deux espaces d'adressages : l''''espace d'adressage primaire''' où se situent le programme exécuté et les données importantes, un '''espace d'adressage secondaire''' utilisé pour des tâches/données annexes. : Un troisième espace d'adressage l''''espace d'adressage ''home''''', est réservé au système d'exploitation. Il a pour particularité de ne pas avoir d'ASN. Il n'est concrètement accesible qu'en mode superviseur, donc par le système d'exploitation. C'est en quelque sorte un espace d'adressage pour le noyau, séparé des espaces d'adressage utilisateur. Nous n'en parlerons pas dans ce qui suit. Mais comment le processeur pour utiliser ces espaces d'adressages ? Par exemple, s'il veut faire une lecture, comment précise-t-il l'espace d'adressage courant ? La réponse est que le processeur peut être dans deux états : soit primaire, soit secondaire. Dans le premier état, toutes les lectures/écritures se font dans l'espace d'adressage primaire. Dans le second, elles se font toutes dans l'espace d'adressage secondaire. Il peut passer de l'un à l'autre en utilisant l'instruction ''Set Address Space Control''. Le processeur dispose aussi d'instructions ''MOVE TO PRIMARY'' et ''MOVE TO SECONDARY'' pour échanger des données dans les deux espaces d'adressage. L'espace d'adressage primaire a une table des pages dédiée, séparée de celle de l'espace d'adressage secondaire. Ou plutôt devrais-je dire une table des super-pages. La table des pages est une table des pages emboitée. Il y a peu à détailler dessus, si ce n'est pour cette histoire de segments/super-pages. L'adresse de la table des super-page est mémorisée dans le registre de contrôle CR1 pour l'espace d'adressage primaire, CR7 pour l'espace d'adressage secondaire. L'un ou l'autre est utilisé, selon que le processeur est en mode "primaire" ou "secondaire". ===Les registres d'accès de la ''z/architecture''=== Un programme peut donc passer de l'espace d'adressage primaire à secondaire (ou inversement) en utilisant l’instruction ''SET ADDRESS SPACE CONTROL''. Mais il peut en profiter pour passer dans un mode où il a accès à 2048 espaces d'adressage différents, en même temps. Le processeur n'a pas besoin d'utiliser d'instructions particulières pour copier des données d'un espace d'adressage à un autre, un simple MOV suffit. Le mode en question est appelé le '''mode ''access register'''''. Le mode ''access register'' donne accès aux '''registres d'accès''', qui lui donnent son nom. Les registres d'accès mémorisent des identifiants qui permettent de sélectionner un espace d'adressage parmi 2048. Il y a en tout 16 registres d'accès, chacun étant appairé avec un registre général. Par exemple, le registre général numéro 5 est appairé au registre d'accès de même numéro. En mode ''access register'', toute lecture/écriture lit l'adresse dans un registre général et le registre d'accès associé indique dans quel espace d'adressage se trouve cette adresse. Par exemple, une lecture utilise l'adresse dans le registre général numéro 7, l'espace d'adressage associé sera dans le registre d'accès numéro 7. Une implémentation simple numéroterait les espaces d'adressage et mettrait les numéros dans les registres d'accès. Un espace d'adressage serait associé à une table des super-pages, reste à mémoriser la correspondance ''numéro d'espace d'adressage - adresse de la table des super-pages''. Pour cela, l'idéal est de mémoriser ces 2048 adresses dans un tableau en mémoire RAM. Le numéro d'espace d'adressage serait alors un indice permettant d'adresser l'entrée adéquate de cette table. Il devrait y avoir un tableau par programme, qui serait protégé en écriture et seulement accessible par le système d'exploitation, comme le serait une table des pages. Mais ce n'est pas exactement ce qui est fait. Déjà, il n'y a pas une table de correspondance unique, mais un système à deux tableaux imbriqués. Le premier tableau possède 65536 entrées, une par espace d'adressage supporté par le processeur. C'est donc une table partagée par tous les programmes. Par contre, la seconde table est bien unique pour chaque programme, chaque programme a la sienne. Faire ainsi permet de simplifier le partage de la mémoire. La table partagée est l''''''Access Space Table Entry''''' (ASTE), alors que la seconde table est la '''liste privée''' (''access list''), sous-entendu "privée pour un programme". La liste privée mémorise une liste des espaces d'adressage auquel le programme peut accéder, auxquels le système d'exploitation lui a accordé l'accès. Pour chaque espace d'adressage, elle renvoie vers l'entrée de l'ASTE associée. En plus de mémoriser l'adresse de cette entrée, elle mémorise les droits d'accès que le programme a sur l'espace d'adressage associé. Par exemple, si deux programmes partagent un espace d'adressage, l'un pourra y avoir accès en lecture seule, l'autre pourra avoir un accès total. Pour cela, ils ont des droits différents dans la liste privée, mais l'entrée de l'ASTE sera la même. [[File:Tradiction d'adresse sur la zarchitecture d'IBM.png|centre|vignette|upright=2|Traduction d'adresse sur la zarchitecture d'IBM.]] Je dis une liste d'accès privée, mais il y en a deux en réalité. Mais la distinction exacte demanderait de parler de ''threads'' et de processus. Pour ceux qui sont familiers avec ces concepts, chaque ''thread'' a sa propre liste privée, avec en plus une liste par processus partagée entre tous les ''threads''. Deux listes de 1024 entrées maximum, cela fait 2048 entrées au total. Les deux listes portent les noms de ''Dispatchable Unit Access List'' et de ''PASN-AL (Primary Address Space Access List''. Inutile de retenir ces deux termes, cependant. Un registre d'accès mémorise un '''''Access List Entry Token''''' ou ALET. L'ALET contient l'indice d'une entrée dans la liste privée. Elle contient aussi un bit qui indique quelle liste privée utiliser, entre la ''Dispatchable Unit Access List'' et la ''PASN-AL (Primary Address Space Access List''. Précisons cependant que deux valeurs font exceptions : 0 pointe vers l'espace d'adressage principal, 1 vers l'espace d'adressage secondaire. Les autres valeurs sont configurables. : Précisons que le registre d'accès numéro 0 contient obligatoirement la valeur 0, ce qui fait qu'il pointe vers l'espace d'adressage primaire. Lors d'une lecture/écriture, l'ALETs est utilisé pour sélectionner l'espace d'adressage. Les valeurs 0 et 1 sont traitées telles quelles, l'espace d'adressage est déterminé automatiquement par le processeur. Pour les autres valeurs, récupérer l'adresse mémoire demande de consulter la liste d'accès privée. L'ALET est envoyé à la liste d'accès privée, et cette dernière fournit l'adresse de l'entrée de l'ASTE à consulter. Le processeur connait l'adresse de la liste privée, car son adresse mémoire est mémorisée dans les registres de contrôle numéro 2 et 5. Une subtilité est que le système d'exploitation peut à tout moment modifier le contenu de la liste privée. Il se peut qu'un ALET qui correspondait à un espace d'adressage se mette à pointer vers un autre. Pour détecter cette situation, une entrée de la liste privée contient un identifiant, qui est modifié à chaque fois que le système d'exploitation modifie l'entrée. L'ALET mémorise aussi cet identifiant, qui est obtenu lors de la création de l'ALET. <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> 4k9vaa2qse95f95mjogzlq41669rwwp 774392 774391 2026-10-07T19:39:17Z Mewtow 31375 /* Un cas particulier : la z/architecture d'IBM */ 774392 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. ==Un cas particulier : la z/architecture d'IBM== IBM a sa propre gamme de ''mainframes'', qui utilisent des processeurs fait maison par IBM lui-même. Les CPU IBM ont leur propre jeu d'instruction CISC, appelé '''''z/architecture'''''. Et ce jeu d'instruction a des possibilités en termes d'adressage qui sont extrêmement intéressantes, en plus d'être assez inédites. La ''z/architecture'' gère des pages de 4 kibioctets, en plus de pages larges introduites dans les dernières versions. L traduction d’adresse se fait cependant en passant des regroupements de 4 pages virtuelles, appelés des ''segments'', qui n'ont rien à voir avec les segments au sens de la segmentation. Pour éviter les confusions, je vais les appeler des ''super-pages''. Les super-pages sont des groupes de 4 pages virtuelles, ce qui veut dire que les 4 pages peuvent être très éloignées en mémoire physique. Les super-pages sont concrètement des intermédiaires utilisés pour la traduction d'adresse. La traduction d'adresse démarre à une table des super-pages, qui point vers une table des pages à plusieurs niveaux (5 maximum). Mais ce n'est pas ce qui va nous intéresser dans ce qui suit. A la place, nous allons nous concentrer sur une fonctionnalité : les programmes ont accès à plusieurs espaces d'adressage simultanément. La ''z/architecture'' supporte 65536 espaces d'adressage simultanés. Par contre, un programme ne peut en utiliser que 2048 en même temps. De plus, ils ne sont accessibles que quand le processeur est dans un mode de fonctionnement bien précis. Un ^processeur ''z/architecture'' peut en effet fonctionner dans deux modes, qu'on va appeler "standard" et "étendu". ===Le mode standard de la ''z/architecture''=== En mode standard, un programme exécuté sur la ''z/architecture'' utilise deux espaces d'adressages : l''''espace d'adressage primaire''' où se situent le programme exécuté et les données importantes, un '''espace d'adressage secondaire''' utilisé pour des tâches/données annexes. : Un troisième espace d'adressage l''''espace d'adressage ''home''''', est réservé au système d'exploitation. Il a pour particularité de ne pas avoir d'ASN. Il n'est concrètement accesible qu'en mode superviseur, donc par le système d'exploitation. C'est en quelque sorte un espace d'adressage pour le noyau, séparé des espaces d'adressage utilisateur. Nous n'en parlerons pas dans ce qui suit. Mais comment le processeur pour utiliser ces espaces d'adressages ? Par exemple, s'il veut faire une lecture, comment précise-t-il l'espace d'adressage courant ? La réponse est que le processeur peut être dans deux états : soit primaire, soit secondaire. Dans le premier état, toutes les lectures/écritures se font dans l'espace d'adressage primaire. Dans le second, elles se font toutes dans l'espace d'adressage secondaire. Il peut passer de l'un à l'autre en utilisant l'instruction ''Set Address Space Control''. Le processeur dispose aussi d'instructions ''MOVE TO PRIMARY'' et ''MOVE TO SECONDARY'' pour échanger des données dans les deux espaces d'adressage. L'espace d'adressage primaire a une table des pages dédiée, séparée de celle de l'espace d'adressage secondaire. Ou plutôt devrais-je dire une table des super-pages. La table des pages est une table des pages emboitée. Il y a peu à détailler dessus, si ce n'est pour cette histoire de segments/super-pages. L'adresse de la table des super-page est mémorisée dans le registre de contrôle CR1 pour l'espace d'adressage primaire, CR7 pour l'espace d'adressage secondaire. L'un ou l'autre est utilisé, selon que le processeur est en mode "primaire" ou "secondaire". ===Les registres d'accès de la ''z/architecture''=== Un programme peut donc passer de l'espace d'adressage primaire à secondaire (ou inversement) en utilisant l’instruction ''SET ADDRESS SPACE CONTROL''. Mais il peut en profiter pour passer dans un mode où il a accès à 2048 espaces d'adressage différents, en même temps. Le processeur n'a pas besoin d'utiliser d'instructions particulières pour copier des données d'un espace d'adressage à un autre, un simple MOV suffit. Le mode en question est appelé le '''mode ''access register'''''. Le mode ''access register'' donne accès aux '''registres d'accès''', qui lui donnent son nom. Les registres d'accès mémorisent des identifiants qui permettent de sélectionner un espace d'adressage parmi 2048. Il y a en tout 16 registres d'accès, chacun étant appairé avec un registre général. Par exemple, le registre général numéro 5 est appairé au registre d'accès de même numéro. En mode ''access register'', toute lecture/écriture lit l'adresse dans un registre général et le registre d'accès associé indique dans quel espace d'adressage se trouve cette adresse. Par exemple, une lecture utilise l'adresse dans le registre général numéro 7, l'espace d'adressage associé sera dans le registre d'accès numéro 7. Une implémentation simple numéroterait les espaces d'adressage et mettrait les numéros dans les registres d'accès. Un espace d'adressage serait associé à une table des super-pages, reste à mémoriser la correspondance ''numéro d'espace d'adressage - adresse de la table des super-pages''. Pour cela, l'idéal est de mémoriser ces 2048 adresses dans un tableau en mémoire RAM. Le numéro d'espace d'adressage serait alors un indice permettant d'adresser l'entrée adéquate de cette table. Il devrait y avoir un tableau par programme, qui serait protégé en écriture et seulement accessible par le système d'exploitation, comme le serait une table des pages. Mais ce n'est pas exactement ce qui est fait. Déjà, il n'y a pas une table de correspondance unique, mais un système à deux tableaux imbriqués. Le premier tableau possède 65536 entrées, une par espace d'adressage supporté par le processeur. C'est donc une table partagée par tous les programmes. Par contre, la seconde table est bien unique pour chaque programme, chaque programme a la sienne. Faire ainsi permet de simplifier le partage de la mémoire. La table partagée est l''''''Access Space Table Entry''''' (ASTE), alors que la seconde table est la '''liste privée''' (''access list''), sous-entendu "privée pour un programme". La liste privée mémorise une liste des espaces d'adressage auquel le programme peut accéder, auxquels le système d'exploitation lui a accordé l'accès. Pour chaque espace d'adressage, elle renvoie vers l'entrée de l'ASTE associée. En plus de mémoriser l'adresse de cette entrée, elle mémorise les droits d'accès que le programme a sur l'espace d'adressage associé. Par exemple, si deux programmes partagent un espace d'adressage, l'un pourra y avoir accès en lecture seule, l'autre pourra avoir un accès total. Pour cela, ils ont des droits différents dans la liste privée, mais l'entrée de l'ASTE sera la même. [[File:Tradiction d'adresse sur la zarchitecture d'IBM.png|centre|vignette|upright=2|Traduction d'adresse sur la zarchitecture d'IBM.]] Il y a donc une vérification des droits d'accès, suivi d'un accès à une table des super-pages globale. Si un programme veut accéder à un espace d'adressage qui lui est interdit, l'accès mémoire stoppera dans la liste privée. Le processeur utilisera l'identifiant d'espace d'adressage, mais la liste privée n'aura pas d'entrée associée. Elle lèvera alors une exception matérielle et le système d'exploitation gérera la situation, vraisemblablement en tuant le programme fautif. Mais si l'accès est autorisé, alors le programme accède à la table des super-pages et l'accès mémoire se poursuit. Je dis une liste d'accès privée, mais il y en a deux en réalité. Mais la distinction exacte demanderait de parler de ''threads'' et de processus. Pour ceux qui sont familiers avec ces concepts, chaque ''thread'' a sa propre liste privée, avec en plus une liste par processus partagée entre tous les ''threads''. Deux listes de 1024 entrées maximum, cela fait 2048 entrées au total. Les deux listes portent les noms de ''Dispatchable Unit Access List'' et de ''PASN-AL (Primary Address Space Access List''. Inutile de retenir ces deux termes, cependant. Un registre d'accès mémorise un '''''Access List Entry Token''''' ou ALET. L'ALET contient l'indice d'une entrée dans la liste privée. Elle contient aussi un bit qui indique quelle liste privée utiliser, entre la ''Dispatchable Unit Access List'' et la ''PASN-AL (Primary Address Space Access List''. Précisons cependant que deux valeurs font exceptions : 0 pointe vers l'espace d'adressage principal, 1 vers l'espace d'adressage secondaire. Les autres valeurs sont configurables. : Précisons que le registre d'accès numéro 0 contient obligatoirement la valeur 0, ce qui fait qu'il pointe vers l'espace d'adressage primaire. Lors d'une lecture/écriture, l'ALETs est utilisé pour sélectionner l'espace d'adressage. Les valeurs 0 et 1 sont traitées telles quelles, l'espace d'adressage est déterminé automatiquement par le processeur. Pour les autres valeurs, récupérer l'adresse mémoire demande de consulter la liste d'accès privée. L'ALET est envoyé à la liste d'accès privée, et cette dernière fournit l'adresse de l'entrée de l'ASTE à consulter. Le processeur connait l'adresse de la liste privée, car son adresse mémoire est mémorisée dans les registres de contrôle numéro 2 et 5. Une subtilité est que le système d'exploitation peut à tout moment modifier le contenu de la liste privée. Il se peut qu'un ALET qui correspondait à un espace d'adressage se mette à pointer vers un autre. Pour détecter cette situation, une entrée de la liste privée contient un identifiant, qui est modifié à chaque fois que le système d'exploitation modifie l'entrée. L'ALET mémorise aussi cet identifiant, qui est obtenu lors de la création de l'ALET. <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> b2rhdd48y3qtazsdx8r0gar0ox5mpnk 774393 774392 2026-10-07T19:40:38Z Mewtow 31375 /* Les registres d'accès de la z/architecture */ 774393 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. ==Un cas particulier : la z/architecture d'IBM== IBM a sa propre gamme de ''mainframes'', qui utilisent des processeurs fait maison par IBM lui-même. Les CPU IBM ont leur propre jeu d'instruction CISC, appelé '''''z/architecture'''''. Et ce jeu d'instruction a des possibilités en termes d'adressage qui sont extrêmement intéressantes, en plus d'être assez inédites. La ''z/architecture'' gère des pages de 4 kibioctets, en plus de pages larges introduites dans les dernières versions. L traduction d’adresse se fait cependant en passant des regroupements de 4 pages virtuelles, appelés des ''segments'', qui n'ont rien à voir avec les segments au sens de la segmentation. Pour éviter les confusions, je vais les appeler des ''super-pages''. Les super-pages sont des groupes de 4 pages virtuelles, ce qui veut dire que les 4 pages peuvent être très éloignées en mémoire physique. Les super-pages sont concrètement des intermédiaires utilisés pour la traduction d'adresse. La traduction d'adresse démarre à une table des super-pages, qui point vers une table des pages à plusieurs niveaux (5 maximum). Mais ce n'est pas ce qui va nous intéresser dans ce qui suit. A la place, nous allons nous concentrer sur une fonctionnalité : les programmes ont accès à plusieurs espaces d'adressage simultanément. La ''z/architecture'' supporte 65536 espaces d'adressage simultanés. Par contre, un programme ne peut en utiliser que 2048 en même temps. De plus, ils ne sont accessibles que quand le processeur est dans un mode de fonctionnement bien précis. Un ^processeur ''z/architecture'' peut en effet fonctionner dans deux modes, qu'on va appeler "standard" et "étendu". ===Le mode standard de la ''z/architecture''=== En mode standard, un programme exécuté sur la ''z/architecture'' utilise deux espaces d'adressages : l''''espace d'adressage primaire''' où se situent le programme exécuté et les données importantes, un '''espace d'adressage secondaire''' utilisé pour des tâches/données annexes. : Un troisième espace d'adressage l''''espace d'adressage ''home''''', est réservé au système d'exploitation. Il a pour particularité de ne pas avoir d'ASN. Il n'est concrètement accesible qu'en mode superviseur, donc par le système d'exploitation. C'est en quelque sorte un espace d'adressage pour le noyau, séparé des espaces d'adressage utilisateur. Nous n'en parlerons pas dans ce qui suit. Mais comment le processeur pour utiliser ces espaces d'adressages ? Par exemple, s'il veut faire une lecture, comment précise-t-il l'espace d'adressage courant ? La réponse est que le processeur peut être dans deux états : soit primaire, soit secondaire. Dans le premier état, toutes les lectures/écritures se font dans l'espace d'adressage primaire. Dans le second, elles se font toutes dans l'espace d'adressage secondaire. Il peut passer de l'un à l'autre en utilisant l'instruction ''Set Address Space Control''. Le processeur dispose aussi d'instructions ''MOVE TO PRIMARY'' et ''MOVE TO SECONDARY'' pour échanger des données dans les deux espaces d'adressage. L'espace d'adressage primaire a une table des pages dédiée, séparée de celle de l'espace d'adressage secondaire. Ou plutôt devrais-je dire une table des super-pages. La table des pages est une table des pages emboitée. Il y a peu à détailler dessus, si ce n'est pour cette histoire de segments/super-pages. L'adresse de la table des super-page est mémorisée dans le registre de contrôle CR1 pour l'espace d'adressage primaire, CR7 pour l'espace d'adressage secondaire. L'un ou l'autre est utilisé, selon que le processeur est en mode "primaire" ou "secondaire". ===Les registres d'accès de la ''z/architecture''=== Un programme peut donc passer de l'espace d'adressage primaire à secondaire (ou inversement) en utilisant l’instruction ''SET ADDRESS SPACE CONTROL''. Mais il peut en profiter pour passer dans un mode où il a accès à 2048 espaces d'adressage différents, en même temps. Le processeur n'a pas besoin d'utiliser d'instructions particulières pour copier des données d'un espace d'adressage à un autre, un simple MOV suffit. Le mode en question est appelé le '''mode ''access register'''''. Le mode ''access register'' donne accès aux '''registres d'accès''', qui lui donnent son nom. Les registres d'accès mémorisent des identifiants qui permettent de sélectionner un espace d'adressage parmi 2048. Il y a en tout 16 registres d'accès, chacun étant appairé avec un registre général. Par exemple, le registre général numéro 5 est appairé au registre d'accès de même numéro. En mode ''access register'', toute lecture/écriture lit l'adresse dans un registre général et le registre d'accès associé indique dans quel espace d'adressage se trouve cette adresse. Par exemple, une lecture utilise l'adresse dans le registre général numéro 7, l'espace d'adressage associé sera dans le registre d'accès numéro 7. Une implémentation simple numéroterait les espaces d'adressage et mettrait les numéros dans les registres d'accès. Un espace d'adressage serait associé à une table des super-pages, reste à mémoriser la correspondance ''numéro d'espace d'adressage - adresse de la table des super-pages''. Pour cela, l'idéal est de mémoriser ces 2048 adresses dans un tableau en mémoire RAM. Le numéro d'espace d'adressage serait alors un indice permettant d'adresser l'entrée adéquate de cette table. Il devrait y avoir un tableau par programme, qui serait protégé en écriture et seulement accessible par le système d'exploitation, comme le serait une table des pages. Mais ce n'est pas exactement ce qui est fait. La différence est qu'il n'y a pas une table de correspondance par programme, mais un système à deux tableaux imbriqués. Le premier tableau possède 65536 entrées, une par espace d'adressage supporté par le processeur. C'est donc une table partagée par tous les programmes. Par contre, la seconde table est bien unique pour chaque programme, chaque programme a la sienne. Faire ainsi permet de simplifier le partage de la mémoire. La table partagée est l''''''Access Space Table Entry''''' (ASTE), alors que la seconde table est la '''liste privée''' (''access list''), sous-entendu "privée pour un programme". La liste privée mémorise une liste des espaces d'adressage auquel le programme peut accéder, auxquels le système d'exploitation lui a accordé l'accès. Pour chaque espace d'adressage, elle renvoie vers l'entrée de l'ASTE associée. En plus de mémoriser l'adresse de cette entrée, elle mémorise les droits d'accès que le programme a sur l'espace d'adressage associé. Par exemple, si deux programmes partagent un espace d'adressage, l'un pourra y avoir accès en lecture seule, l'autre pourra avoir un accès total. Pour cela, ils ont des droits différents dans la liste privée, mais l'entrée de l'ASTE sera la même. [[File:Tradiction d'adresse sur la zarchitecture d'IBM.png|centre|vignette|upright=2|Traduction d'adresse sur la zarchitecture d'IBM.]] Il y a donc une vérification des droits d'accès, suivi d'un accès à une table des super-pages globale. Si un programme veut accéder à un espace d'adressage qui lui est interdit, l'accès mémoire stoppera dans la liste privée. Le processeur utilisera l'identifiant d'espace d'adressage, mais la liste privée n'aura pas d'entrée associée. Elle lèvera alors une exception matérielle et le système d'exploitation gérera la situation, vraisemblablement en tuant le programme fautif. Mais si l'accès est autorisé, alors le programme accède à la table des super-pages et l'accès mémoire se poursuit. Je dis une liste d'accès privée, mais il y en a deux en réalité. Mais la distinction exacte demanderait de parler de ''threads'' et de processus. Pour ceux qui sont familiers avec ces concepts, chaque ''thread'' a sa propre liste privée, avec en plus une liste par processus partagée entre tous les ''threads''. Deux listes de 1024 entrées maximum, cela fait 2048 entrées au total. Les deux listes portent les noms de ''Dispatchable Unit Access List'' et de ''PASN-AL (Primary Address Space Access List''. Inutile de retenir ces deux termes, cependant. Un registre d'accès mémorise un '''''Access List Entry Token''''' ou ALET. L'ALET contient l'indice d'une entrée dans la liste privée. Elle contient aussi un bit qui indique quelle liste privée utiliser, entre la ''Dispatchable Unit Access List'' et la ''PASN-AL (Primary Address Space Access List''. Précisons cependant que deux valeurs font exceptions : 0 pointe vers l'espace d'adressage principal, 1 vers l'espace d'adressage secondaire. Les autres valeurs sont configurables. : Précisons que le registre d'accès numéro 0 contient obligatoirement la valeur 0, ce qui fait qu'il pointe vers l'espace d'adressage primaire. Lors d'une lecture/écriture, l'ALETs est utilisé pour sélectionner l'espace d'adressage. Les valeurs 0 et 1 sont traitées telles quelles, l'espace d'adressage est déterminé automatiquement par le processeur. Pour les autres valeurs, récupérer l'adresse mémoire demande de consulter la liste d'accès privée. L'ALET est envoyé à la liste d'accès privée, et cette dernière fournit l'adresse de l'entrée de l'ASTE à consulter. Le processeur connait l'adresse de la liste privée, car son adresse mémoire est mémorisée dans les registres de contrôle numéro 2 et 5. Une subtilité est que le système d'exploitation peut à tout moment modifier le contenu de la liste privée. Il se peut qu'un ALET qui correspondait à un espace d'adressage se mette à pointer vers un autre. Pour détecter cette situation, une entrée de la liste privée contient un identifiant, qui est modifié à chaque fois que le système d'exploitation modifie l'entrée. L'ALET mémorise aussi cet identifiant, qui est obtenu lors de la création de l'ALET. <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> ce15xbc0wzr37o5a0kuwbr8al8b857q 774394 774393 2026-10-07T19:41:27Z Mewtow 31375 /* Les registres d'accès de la z/architecture */ 774394 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. ==Un cas particulier : la z/architecture d'IBM== IBM a sa propre gamme de ''mainframes'', qui utilisent des processeurs fait maison par IBM lui-même. Les CPU IBM ont leur propre jeu d'instruction CISC, appelé '''''z/architecture'''''. Et ce jeu d'instruction a des possibilités en termes d'adressage qui sont extrêmement intéressantes, en plus d'être assez inédites. La ''z/architecture'' gère des pages de 4 kibioctets, en plus de pages larges introduites dans les dernières versions. L traduction d’adresse se fait cependant en passant des regroupements de 4 pages virtuelles, appelés des ''segments'', qui n'ont rien à voir avec les segments au sens de la segmentation. Pour éviter les confusions, je vais les appeler des ''super-pages''. Les super-pages sont des groupes de 4 pages virtuelles, ce qui veut dire que les 4 pages peuvent être très éloignées en mémoire physique. Les super-pages sont concrètement des intermédiaires utilisés pour la traduction d'adresse. La traduction d'adresse démarre à une table des super-pages, qui point vers une table des pages à plusieurs niveaux (5 maximum). Mais ce n'est pas ce qui va nous intéresser dans ce qui suit. A la place, nous allons nous concentrer sur une fonctionnalité : les programmes ont accès à plusieurs espaces d'adressage simultanément. La ''z/architecture'' supporte 65536 espaces d'adressage simultanés. Par contre, un programme ne peut en utiliser que 2048 en même temps. De plus, ils ne sont accessibles que quand le processeur est dans un mode de fonctionnement bien précis. Un ^processeur ''z/architecture'' peut en effet fonctionner dans deux modes, qu'on va appeler "standard" et "étendu". ===Le mode standard de la ''z/architecture''=== En mode standard, un programme exécuté sur la ''z/architecture'' utilise deux espaces d'adressages : l''''espace d'adressage primaire''' où se situent le programme exécuté et les données importantes, un '''espace d'adressage secondaire''' utilisé pour des tâches/données annexes. : Un troisième espace d'adressage l''''espace d'adressage ''home''''', est réservé au système d'exploitation. Il a pour particularité de ne pas avoir d'ASN. Il n'est concrètement accesible qu'en mode superviseur, donc par le système d'exploitation. C'est en quelque sorte un espace d'adressage pour le noyau, séparé des espaces d'adressage utilisateur. Nous n'en parlerons pas dans ce qui suit. Mais comment le processeur pour utiliser ces espaces d'adressages ? Par exemple, s'il veut faire une lecture, comment précise-t-il l'espace d'adressage courant ? La réponse est que le processeur peut être dans deux états : soit primaire, soit secondaire. Dans le premier état, toutes les lectures/écritures se font dans l'espace d'adressage primaire. Dans le second, elles se font toutes dans l'espace d'adressage secondaire. Il peut passer de l'un à l'autre en utilisant l'instruction ''Set Address Space Control''. Le processeur dispose aussi d'instructions ''MOVE TO PRIMARY'' et ''MOVE TO SECONDARY'' pour échanger des données dans les deux espaces d'adressage. L'espace d'adressage primaire a une table des pages dédiée, séparée de celle de l'espace d'adressage secondaire. Ou plutôt devrais-je dire une table des super-pages. La table des pages est une table des pages emboitée. Il y a peu à détailler dessus, si ce n'est pour cette histoire de segments/super-pages. L'adresse de la table des super-page est mémorisée dans le registre de contrôle CR1 pour l'espace d'adressage primaire, CR7 pour l'espace d'adressage secondaire. L'un ou l'autre est utilisé, selon que le processeur est en mode "primaire" ou "secondaire". ===Les registres d'accès de la ''z/architecture''=== Un programme peut donc passer de l'espace d'adressage primaire à secondaire (ou inversement) en utilisant l’instruction ''SET ADDRESS SPACE CONTROL''. Mais il peut en profiter pour passer dans un mode où il a accès à 2048 espaces d'adressage différents, en même temps. Le processeur n'a pas besoin d'utiliser d'instructions particulières pour copier des données d'un espace d'adressage à un autre, un simple MOV suffit. Le mode en question est appelé le '''mode ''access register'''''. Le mode ''access register'' donne accès aux '''registres d'accès''', qui lui donnent son nom. Les registres d'accès mémorisent des identifiants qui permettent de sélectionner un espace d'adressage parmi 2048. Il y a en tout 16 registres d'accès, chacun étant appairé avec un registre général. Par exemple, le registre général numéro 5 est appairé au registre d'accès de même numéro. En mode ''access register'', toute lecture/écriture lit l'adresse dans un registre général et le registre d'accès associé indique dans quel espace d'adressage se trouve cette adresse. Par exemple, une lecture utilise l'adresse dans le registre général numéro 7, l'espace d'adressage associé sera dans le registre d'accès numéro 7. Une implémentation simple numéroterait les espaces d'adressage et mettrait les numéros dans les registres d'accès. Un espace d'adressage serait associé à une table des super-pages, reste à mémoriser la correspondance ''numéro d'espace d'adressage - adresse de la table des super-pages''. Pour cela, l'idéal est de mémoriser ces correspondances dans un tableau en mémoire RAM. Le numéro d'espace d'adressage est alors un indice, qui pointe sur l'entrée adéquate dans cette table. Il devrait y avoir un tableau par programme, qui serait protégé en écriture et seulement accessible par le système d'exploitation, comme le serait une table des pages. Mais ce n'est pas exactement ce qui est fait. La différence est qu'il n'y a pas une table de correspondance par programme, mais un système à deux tableaux imbriqués. Le premier tableau possède 65536 entrées, une par espace d'adressage supporté par le processeur. C'est donc une table partagée par tous les programmes. Par contre, la seconde table est bien unique pour chaque programme, chaque programme a la sienne. Faire ainsi permet de simplifier le partage de la mémoire. La table partagée est l''''''Access Space Table Entry''''' (ASTE), alors que la seconde table est la '''liste privée''' (''access list''), sous-entendu "privée pour un programme". La liste privée mémorise une liste des espaces d'adressage auquel le programme peut accéder, auxquels le système d'exploitation lui a accordé l'accès. Pour chaque espace d'adressage, elle renvoie vers l'entrée de l'ASTE associée. En plus de mémoriser l'adresse de cette entrée, elle mémorise les droits d'accès que le programme a sur l'espace d'adressage associé. Par exemple, si deux programmes partagent un espace d'adressage, l'un pourra y avoir accès en lecture seule, l'autre pourra avoir un accès total. Pour cela, ils ont des droits différents dans la liste privée, mais l'entrée de l'ASTE sera la même. [[File:Tradiction d'adresse sur la zarchitecture d'IBM.png|centre|vignette|upright=2|Traduction d'adresse sur la zarchitecture d'IBM.]] Il y a donc une vérification des droits d'accès, suivi d'un accès à une table des super-pages globale. Si un programme veut accéder à un espace d'adressage qui lui est interdit, l'accès mémoire stoppera dans la liste privée. Le processeur utilisera l'identifiant d'espace d'adressage, mais la liste privée n'aura pas d'entrée associée. Elle lèvera alors une exception matérielle et le système d'exploitation gérera la situation, vraisemblablement en tuant le programme fautif. Mais si l'accès est autorisé, alors le programme accède à la table des super-pages et l'accès mémoire se poursuit. Je dis une liste d'accès privée, mais il y en a deux en réalité. Mais la distinction exacte demanderait de parler de ''threads'' et de processus. Pour ceux qui sont familiers avec ces concepts, chaque ''thread'' a sa propre liste privée, avec en plus une liste par processus partagée entre tous les ''threads''. Deux listes de 1024 entrées maximum, cela fait 2048 entrées au total. Les deux listes portent les noms de ''Dispatchable Unit Access List'' et de ''PASN-AL (Primary Address Space Access List''. Inutile de retenir ces deux termes, cependant. Un registre d'accès mémorise un '''''Access List Entry Token''''' ou ALET. L'ALET contient l'indice d'une entrée dans la liste privée. Elle contient aussi un bit qui indique quelle liste privée utiliser, entre la ''Dispatchable Unit Access List'' et la ''PASN-AL (Primary Address Space Access List''. Précisons cependant que deux valeurs font exceptions : 0 pointe vers l'espace d'adressage principal, 1 vers l'espace d'adressage secondaire. Les autres valeurs sont configurables. : Précisons que le registre d'accès numéro 0 contient obligatoirement la valeur 0, ce qui fait qu'il pointe vers l'espace d'adressage primaire. Lors d'une lecture/écriture, l'ALETs est utilisé pour sélectionner l'espace d'adressage. Les valeurs 0 et 1 sont traitées telles quelles, l'espace d'adressage est déterminé automatiquement par le processeur. Pour les autres valeurs, récupérer l'adresse mémoire demande de consulter la liste d'accès privée. L'ALET est envoyé à la liste d'accès privée, et cette dernière fournit l'adresse de l'entrée de l'ASTE à consulter. Le processeur connait l'adresse de la liste privée, car son adresse mémoire est mémorisée dans les registres de contrôle numéro 2 et 5. Une subtilité est que le système d'exploitation peut à tout moment modifier le contenu de la liste privée. Il se peut qu'un ALET qui correspondait à un espace d'adressage se mette à pointer vers un autre. Pour détecter cette situation, une entrée de la liste privée contient un identifiant, qui est modifié à chaque fois que le système d'exploitation modifie l'entrée. L'ALET mémorise aussi cet identifiant, qui est obtenu lors de la création de l'ALET. <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> jya0njx0zbtc4iy4xxc1y5syjxooosz 774395 774394 2026-10-07T19:48:11Z Mewtow 31375 /* Les registres d'accès de la z/architecture */ 774395 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. ==Un cas particulier : la z/architecture d'IBM== IBM a sa propre gamme de ''mainframes'', qui utilisent des processeurs fait maison par IBM lui-même. Les CPU IBM ont leur propre jeu d'instruction CISC, appelé '''''z/architecture'''''. Et ce jeu d'instruction a des possibilités en termes d'adressage qui sont extrêmement intéressantes, en plus d'être assez inédites. La ''z/architecture'' gère des pages de 4 kibioctets, en plus de pages larges introduites dans les dernières versions. L traduction d’adresse se fait cependant en passant des regroupements de 4 pages virtuelles, appelés des ''segments'', qui n'ont rien à voir avec les segments au sens de la segmentation. Pour éviter les confusions, je vais les appeler des ''super-pages''. Les super-pages sont des groupes de 4 pages virtuelles, ce qui veut dire que les 4 pages peuvent être très éloignées en mémoire physique. Les super-pages sont concrètement des intermédiaires utilisés pour la traduction d'adresse. La traduction d'adresse démarre à une table des super-pages, qui point vers une table des pages à plusieurs niveaux (5 maximum). Mais ce n'est pas ce qui va nous intéresser dans ce qui suit. A la place, nous allons nous concentrer sur une fonctionnalité : les programmes ont accès à plusieurs espaces d'adressage simultanément. La ''z/architecture'' supporte 65536 espaces d'adressage simultanés. Par contre, un programme ne peut en utiliser que 2048 en même temps. De plus, ils ne sont accessibles que quand le processeur est dans un mode de fonctionnement bien précis. Un ^processeur ''z/architecture'' peut en effet fonctionner dans deux modes, qu'on va appeler "standard" et "étendu". ===Le mode standard de la ''z/architecture''=== En mode standard, un programme exécuté sur la ''z/architecture'' utilise deux espaces d'adressages : l''''espace d'adressage primaire''' où se situent le programme exécuté et les données importantes, un '''espace d'adressage secondaire''' utilisé pour des tâches/données annexes. : Un troisième espace d'adressage l''''espace d'adressage ''home''''', est réservé au système d'exploitation. Il a pour particularité de ne pas avoir d'ASN. Il n'est concrètement accesible qu'en mode superviseur, donc par le système d'exploitation. C'est en quelque sorte un espace d'adressage pour le noyau, séparé des espaces d'adressage utilisateur. Nous n'en parlerons pas dans ce qui suit. Mais comment le processeur pour utiliser ces espaces d'adressages ? Par exemple, s'il veut faire une lecture, comment précise-t-il l'espace d'adressage courant ? La réponse est que le processeur peut être dans deux états : soit primaire, soit secondaire. Dans le premier état, toutes les lectures/écritures se font dans l'espace d'adressage primaire. Dans le second, elles se font toutes dans l'espace d'adressage secondaire. Il peut passer de l'un à l'autre en utilisant l'instruction ''Set Address Space Control''. Le processeur dispose aussi d'instructions ''MOVE TO PRIMARY'' et ''MOVE TO SECONDARY'' pour échanger des données dans les deux espaces d'adressage. L'espace d'adressage primaire a une table des pages dédiée, séparée de celle de l'espace d'adressage secondaire. Ou plutôt devrais-je dire une table des super-pages. La table des pages est une table des pages emboitée. Il y a peu à détailler dessus, si ce n'est pour cette histoire de segments/super-pages. L'adresse de la table des super-page est mémorisée dans le registre de contrôle CR1 pour l'espace d'adressage primaire, CR7 pour l'espace d'adressage secondaire. L'un ou l'autre est utilisé, selon que le processeur est en mode "primaire" ou "secondaire". ===Les registres d'accès de la ''z/architecture''=== Un programme peut donc passer de l'espace d'adressage primaire à secondaire (ou inversement) en utilisant l’instruction ''SET ADDRESS SPACE CONTROL''. Mais il peut en profiter pour passer dans un mode où il a accès à 2048 espaces d'adressage différents, en même temps. Le processeur n'a pas besoin d'utiliser d'instructions particulières pour copier des données d'un espace d'adressage à un autre, un simple MOV suffit. Le mode en question est appelé le '''mode ''access register'''''. Le mode ''access register'' donne accès aux '''registres d'accès''', qui lui donnent son nom. Les registres d'accès mémorisent des identifiants qui permettent de sélectionner un espace d'adressage parmi 2048. Il y a en tout 16 registres d'accès, chacun étant appairé avec un registre général. Par exemple, le registre général numéro 5 est appairé au registre d'accès de même numéro. En mode ''access register'', toute lecture/écriture lit l'adresse dans un registre général et le registre d'accès associé indique dans quel espace d'adressage se trouve cette adresse. Par exemple, une lecture utilise l'adresse dans le registre général numéro 7, l'espace d'adressage associé sera dans le registre d'accès numéro 7. Une implémentation simple numéroterait les espaces d'adressage et mettrait les numéros dans les registres d'accès. Un espace d'adressage serait associé à une table des super-pages, reste à mémoriser la correspondance ''numéro d'espace d'adressage - adresse de la table des super-pages''. Pour cela, l'idéal est de mémoriser ces correspondances dans un tableau en mémoire RAM. Le numéro d'espace d'adressage est alors un indice, qui pointe sur l'entrée adéquate dans cette table. Il devrait y avoir un tableau par programme, qui serait protégé en écriture et seulement accessible par le système d'exploitation, comme le serait une table des pages. Mais ce n'est pas exactement ce qui est fait. La différence est qu'il n'y a pas une table de correspondance par programme, mais un système à deux tableaux imbriqués. Le premier tableau possède 65536 entrées, une par espace d'adressage supporté par le processeur. C'est donc une table partagée par tous les programmes. Par contre, la seconde table est bien unique pour chaque programme, chaque programme a la sienne. Faire ainsi permet de simplifier le partage de la mémoire. La table partagée est l''''''Access Space Table Entry''''' (ASTE), alors que la seconde table est la '''liste privée''' (''access list''), sous-entendu "privée pour un programme". La liste privée mémorise une liste des espaces d'adressage auquel le programme peut accéder, auxquels le système d'exploitation lui a accordé l'accès. Pour chaque espace d'adressage, elle renvoie vers l'entrée de l'ASTE associée. En plus de mémoriser l'adresse de cette entrée, elle mémorise les droits d'accès que le programme a sur l'espace d'adressage associé. Par exemple, si deux programmes partagent un espace d'adressage, l'un pourra y avoir accès en lecture seule, l'autre pourra avoir un accès total. Pour cela, ils ont des droits différents dans la liste privée, mais l'entrée de l'ASTE sera la même. [[File:Tradiction d'adresse sur la zarchitecture d'IBM.png|centre|vignette|upright=2|Traduction d'adresse sur la zarchitecture d'IBM.]] Il y a donc une vérification des droits d'accès, suivi d'un accès à une table des super-pages globale. Si un programme veut accéder à un espace d'adressage qui lui est interdit, l'accès mémoire stoppera dans la liste privée. Le processeur utilisera l'identifiant d'espace d'adressage, mais la liste privée n'aura pas d'entrée associée. Elle lèvera alors une exception matérielle et le système d'exploitation gérera la situation, vraisemblablement en tuant le programme fautif. Mais si l'accès est autorisé, alors le programme accède à la table des super-pages et l'accès mémoire se poursuit. Je dis une liste d'accès privée, mais il y en a deux en réalité. Mais la distinction exacte demanderait de parler de ''threads'' et de processus. Pour ceux qui sont familiers avec ces concepts, chaque ''thread'' a sa propre liste privée, avec en plus une liste par processus partagée entre tous les ''threads''. Deux listes de 1024 entrées maximum, cela fait 2048 entrées au total. Les deux listes portent les noms de ''Dispatchable Unit Access List'' et de ''PASN-AL (Primary Address Space Access List''. Inutile de retenir ces deux termes, cependant. Un registre d'accès mémorise un '''''Access List Entry Token''''' ou ALET. L'ALET contient l'indice d'une entrée dans la liste privée. Elle contient aussi un bit qui indique quelle liste privée utiliser, entre la ''Dispatchable Unit Access List'' et la ''PASN-AL (Primary Address Space Access List''. Précisons cependant que deux valeurs font exceptions : 0 pointe vers l'espace d'adressage principal, 1 vers l'espace d'adressage secondaire. Les autres valeurs sont configurables. : Précisons que le registre d'accès numéro 0 contient obligatoirement la valeur 0, ce qui fait qu'il pointe vers l'espace d'adressage primaire. Lors d'une lecture/écriture, l'ALETs est utilisé pour sélectionner l'espace d'adressage. Les valeurs 0 et 1 sont traitées telles quelles, l'espace d'adressage est déterminé automatiquement par le processeur. Pour les autres valeurs, récupérer l'adresse mémoire demande de consulter la liste d'accès privée. L'ALET est envoyé à la liste d'accès privée, et cette dernière fournit l'adresse de l'entrée de l'ASTE à consulter. Le processeur connait l'adresse de la liste privée, car son adresse mémoire est mémorisée dans les registres de contrôle numéro 2 et 5. [[File:Traduction d'adresse - trois espaces d'adressage, zarchitecture d'IBM.png|centre|vignette|upright=2|Traduction d'adresse - trois espaces d'adressage, zarchitecture d'IBM.]] Une subtilité est que le système d'exploitation peut à tout moment modifier le contenu de la liste privée. Il se peut qu'un ALET qui correspondait à un espace d'adressage se mette à pointer vers un autre. Pour détecter cette situation, une entrée de la liste privée contient un identifiant, qui est modifié à chaque fois que le système d'exploitation modifie l'entrée. L'ALET mémorise aussi cet identifiant, qui est obtenu lors de la création de l'ALET. <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> m74gfie6ht7tdfxa1iuhlb1dsghjz4v 774396 774395 2026-10-07T19:52:06Z Mewtow 31375 /* Les registres d'accès de la z/architecture */ 774396 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. ==Un cas particulier : la z/architecture d'IBM== IBM a sa propre gamme de ''mainframes'', qui utilisent des processeurs fait maison par IBM lui-même. Les CPU IBM ont leur propre jeu d'instruction CISC, appelé '''''z/architecture'''''. Et ce jeu d'instruction a des possibilités en termes d'adressage qui sont extrêmement intéressantes, en plus d'être assez inédites. La ''z/architecture'' gère des pages de 4 kibioctets, en plus de pages larges introduites dans les dernières versions. L traduction d’adresse se fait cependant en passant des regroupements de 4 pages virtuelles, appelés des ''segments'', qui n'ont rien à voir avec les segments au sens de la segmentation. Pour éviter les confusions, je vais les appeler des ''super-pages''. Les super-pages sont des groupes de 4 pages virtuelles, ce qui veut dire que les 4 pages peuvent être très éloignées en mémoire physique. Les super-pages sont concrètement des intermédiaires utilisés pour la traduction d'adresse. La traduction d'adresse démarre à une table des super-pages, qui point vers une table des pages à plusieurs niveaux (5 maximum). Mais ce n'est pas ce qui va nous intéresser dans ce qui suit. A la place, nous allons nous concentrer sur une fonctionnalité : les programmes ont accès à plusieurs espaces d'adressage simultanément. La ''z/architecture'' supporte 65536 espaces d'adressage simultanés. Par contre, un programme ne peut en utiliser que 2048 en même temps. De plus, ils ne sont accessibles que quand le processeur est dans un mode de fonctionnement bien précis. Un ^processeur ''z/architecture'' peut en effet fonctionner dans deux modes, qu'on va appeler "standard" et "étendu". ===Le mode standard de la ''z/architecture''=== En mode standard, un programme exécuté sur la ''z/architecture'' utilise deux espaces d'adressages : l''''espace d'adressage primaire''' où se situent le programme exécuté et les données importantes, un '''espace d'adressage secondaire''' utilisé pour des tâches/données annexes. : Un troisième espace d'adressage l''''espace d'adressage ''home''''', est réservé au système d'exploitation. Il a pour particularité de ne pas avoir d'ASN. Il n'est concrètement accesible qu'en mode superviseur, donc par le système d'exploitation. C'est en quelque sorte un espace d'adressage pour le noyau, séparé des espaces d'adressage utilisateur. Nous n'en parlerons pas dans ce qui suit. Mais comment le processeur pour utiliser ces espaces d'adressages ? Par exemple, s'il veut faire une lecture, comment précise-t-il l'espace d'adressage courant ? La réponse est que le processeur peut être dans deux états : soit primaire, soit secondaire. Dans le premier état, toutes les lectures/écritures se font dans l'espace d'adressage primaire. Dans le second, elles se font toutes dans l'espace d'adressage secondaire. Il peut passer de l'un à l'autre en utilisant l'instruction ''Set Address Space Control''. Le processeur dispose aussi d'instructions ''MOVE TO PRIMARY'' et ''MOVE TO SECONDARY'' pour échanger des données dans les deux espaces d'adressage. L'espace d'adressage primaire a une table des pages dédiée, séparée de celle de l'espace d'adressage secondaire. Ou plutôt devrais-je dire une table des super-pages. La table des pages est une table des pages emboitée. Il y a peu à détailler dessus, si ce n'est pour cette histoire de segments/super-pages. L'adresse de la table des super-page est mémorisée dans le registre de contrôle CR1 pour l'espace d'adressage primaire, CR7 pour l'espace d'adressage secondaire. L'un ou l'autre est utilisé, selon que le processeur est en mode "primaire" ou "secondaire". ===Les registres d'accès de la ''z/architecture''=== Un programme peut donc passer de l'espace d'adressage primaire à secondaire (ou inversement) en utilisant l’instruction ''SET ADDRESS SPACE CONTROL''. Mais il peut en profiter pour passer dans un mode où il a accès à 2048 espaces d'adressage différents, en même temps. Le processeur n'a pas besoin d'utiliser d'instructions particulières pour copier des données d'un espace d'adressage à un autre, un simple MOV suffit. Le mode en question est appelé le '''mode ''access register'''''. Le mode ''access register'' donne accès aux '''registres d'accès''', qui lui donnent son nom. Les registres d'accès mémorisent des identifiants qui permettent de sélectionner un espace d'adressage parmi 2048. Il y a en tout 16 registres d'accès, chacun étant appairé avec un registre général. Par exemple, le registre général numéro 5 est appairé au registre d'accès de même numéro. En mode ''access register'', toute lecture/écriture lit l'adresse dans un registre général et le registre d'accès associé indique dans quel espace d'adressage se trouve cette adresse. Par exemple, une lecture utilise l'adresse dans le registre général numéro 7, l'espace d'adressage associé sera dans le registre d'accès numéro 7. Une implémentation simple numéroterait les espaces d'adressage et mettrait les numéros dans les registres d'accès. Un espace d'adressage serait associé à une table des super-pages, reste à mémoriser la correspondance ''numéro d'espace d'adressage - adresse de la table des super-pages''. Pour cela, l'idéal est de mémoriser ces correspondances dans un tableau en mémoire RAM. Le numéro d'espace d'adressage est alors un indice, qui pointe sur l'entrée adéquate dans cette table. Il devrait y avoir un tableau par programme, qui serait protégé en écriture et seulement accessible par le système d'exploitation, comme le serait une table des pages. Mais ce n'est pas exactement ce qui est fait. La différence est qu'il n'y a pas une table de correspondance par programme, mais un système à deux tableaux imbriqués. Le premier tableau possède 65536 entrées, une par espace d'adressage supporté par le processeur. C'est donc une table partagée par tous les programmes. Par contre, la seconde table est bien unique pour chaque programme, chaque programme a la sienne. Faire ainsi permet de simplifier le partage de la mémoire. La table partagée est l''''''Access Space Table Entry''''' (ASTE), alors que la seconde table est la '''liste privée''' (''access list''), sous-entendu "privée pour un programme". La liste privée mémorise une liste des espaces d'adressage auquel le programme peut accéder, auxquels le système d'exploitation lui a accordé l'accès. Pour chaque espace d'adressage, elle renvoie vers l'entrée de l'ASTE associée. En plus de mémoriser l'adresse de cette entrée, elle mémorise les droits d'accès que le programme a sur l'espace d'adressage associé. Par exemple, si deux programmes partagent un espace d'adressage, l'un pourra y avoir accès en lecture seule, l'autre pourra avoir un accès total. Pour cela, ils ont des droits différents dans la liste privée, mais l'entrée de l'ASTE sera la même. [[File:Tradiction d'adresse sur la zarchitecture d'IBM.png|centre|vignette|upright=2|Traduction d'adresse sur la zarchitecture d'IBM.]] Il y a donc une vérification des droits d'accès, suivi d'un accès à une table des super-pages globale. Si un programme veut accéder à un espace d'adressage qui lui est interdit, l'accès mémoire stoppera dans la liste privée. Le processeur utilisera l'identifiant d'espace d'adressage, mais la liste privée n'aura pas d'entrée associée. Elle lèvera alors une exception matérielle et le système d'exploitation gérera la situation, vraisemblablement en tuant le programme fautif. Mais si l'accès est autorisé, alors le programme accède à la table des super-pages et l'accès mémoire se poursuit. Je dis une liste d'accès privée, mais il y en a deux en réalité. Mais la distinction exacte demanderait de parler de ''threads'' et de processus. Pour ceux qui sont familiers avec ces concepts, chaque ''thread'' a sa propre liste privée, avec en plus une liste par processus partagée entre tous les ''threads''. Deux listes de 1024 entrées maximum, cela fait 2048 entrées au total. Les deux listes portent les noms de ''Dispatchable Unit Access List'' et de ''PASN-AL (Primary Address Space Access List''. Inutile de retenir ces deux termes, cependant. Un registre d'accès mémorise un '''''Access List Entry Token''''' ou ALET. L'ALET contient l'indice d'une entrée dans la liste privée. Elle contient aussi un bit qui indique quelle liste privée utiliser, entre la ''Dispatchable Unit Access List'' et la ''PASN-AL (Primary Address Space Access List''. Précisons cependant que deux valeurs font exceptions : 0 pointe vers l'espace d'adressage principal, 1 vers l'espace d'adressage secondaire. Les autres valeurs sont configurables. Lors d'une lecture/écriture, l'ALETs est utilisé pour sélectionner l'espace d'adressage. Les valeurs 0 et 1 sont traitées telles quelles, l'espace d'adressage est déterminé automatiquement par le processeur. Pour les autres valeurs, récupérer l'adresse mémoire demande de consulter la liste d'accès privée. L'ALET est envoyé à la liste d'accès privée, et cette dernière fournit l'adresse de l'entrée de l'ASTE à consulter. Le processeur connait l'adresse de la liste privée, car son adresse mémoire est mémorisée dans les registres de contrôle numéro 2 et 5. : Précisons que le registre d'accès numéro 0 contient obligatoirement la valeur 0, ce qui fait qu'il pointe vers l'espace d'adressage primaire. [[File:Traduction d'adresse - trois espaces d'adressage, zarchitecture d'IBM.png|centre|vignette|upright=2|Traduction d'adresse - trois espaces d'adressage, zarchitecture d'IBM.]] Une subtilité est que le système d'exploitation peut à tout moment modifier le contenu de la liste privée. Il se peut qu'un ALET qui correspondait à un espace d'adressage se mette à pointer vers un autre. Pour détecter cette situation, le processeur attribue un identifiant, à chaque espace d'adressage. L'identifiant est mémorisé dans les entrées de la liste privée, de l'ASTE et dans les registres d'accès (dans les bits de poids fort de l'ALET). L'identifiant est modifié dans l'ALET à chaque fois que le système d'exploitation modifie l'entrée. Si un programme fait un accès avec un registre d'accès, mais que l'identifiant ne correspond pas avec celui dans l'ALET/liste privée, c'est signe que l'espace d'adressage a été swappé, une exception matérielle est levée. <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> qbuhyzd66sx6marm0txu1czks4s5nwr 774397 774396 2026-10-07T20:02:45Z Mewtow 31375 /* Les registres d'accès de la z/architecture */ 774397 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. ==Un cas particulier : la z/architecture d'IBM== IBM a sa propre gamme de ''mainframes'', qui utilisent des processeurs fait maison par IBM lui-même. Les CPU IBM ont leur propre jeu d'instruction CISC, appelé '''''z/architecture'''''. Et ce jeu d'instruction a des possibilités en termes d'adressage qui sont extrêmement intéressantes, en plus d'être assez inédites. La ''z/architecture'' gère des pages de 4 kibioctets, en plus de pages larges introduites dans les dernières versions. L traduction d’adresse se fait cependant en passant des regroupements de 4 pages virtuelles, appelés des ''segments'', qui n'ont rien à voir avec les segments au sens de la segmentation. Pour éviter les confusions, je vais les appeler des ''super-pages''. Les super-pages sont des groupes de 4 pages virtuelles, ce qui veut dire que les 4 pages peuvent être très éloignées en mémoire physique. Les super-pages sont concrètement des intermédiaires utilisés pour la traduction d'adresse. La traduction d'adresse démarre à une table des super-pages, qui point vers une table des pages à plusieurs niveaux (5 maximum). Mais ce n'est pas ce qui va nous intéresser dans ce qui suit. A la place, nous allons nous concentrer sur une fonctionnalité : les programmes ont accès à plusieurs espaces d'adressage simultanément. La ''z/architecture'' supporte 65536 espaces d'adressage simultanés. Par contre, un programme ne peut en utiliser que 2048 en même temps. De plus, ils ne sont accessibles que quand le processeur est dans un mode de fonctionnement bien précis. Un ^processeur ''z/architecture'' peut en effet fonctionner dans deux modes, qu'on va appeler "standard" et "étendu". ===Le mode standard de la ''z/architecture''=== En mode standard, un programme exécuté sur la ''z/architecture'' utilise deux espaces d'adressages : l''''espace d'adressage primaire''' où se situent le programme exécuté et les données importantes, un '''espace d'adressage secondaire''' utilisé pour des tâches/données annexes. : Un troisième espace d'adressage l''''espace d'adressage ''home''''', est réservé au système d'exploitation. Il a pour particularité de ne pas avoir d'ASN. Il n'est concrètement accesible qu'en mode superviseur, donc par le système d'exploitation. C'est en quelque sorte un espace d'adressage pour le noyau, séparé des espaces d'adressage utilisateur. Nous n'en parlerons pas dans ce qui suit. Mais comment le processeur pour utiliser ces espaces d'adressages ? Par exemple, s'il veut faire une lecture, comment précise-t-il l'espace d'adressage courant ? La réponse est que le processeur peut être dans deux états : soit primaire, soit secondaire. Dans le premier état, toutes les lectures/écritures se font dans l'espace d'adressage primaire. Dans le second, elles se font toutes dans l'espace d'adressage secondaire. Il peut passer de l'un à l'autre en utilisant l'instruction ''Set Address Space Control''. Le processeur dispose aussi d'instructions ''MOVE TO PRIMARY'' et ''MOVE TO SECONDARY'' pour échanger des données dans les deux espaces d'adressage. L'espace d'adressage primaire a une table des pages dédiée, séparée de celle de l'espace d'adressage secondaire. Ou plutôt devrais-je dire une table des super-pages. La table des pages est une table des pages emboitée. Il y a peu à détailler dessus, si ce n'est pour cette histoire de segments/super-pages. L'adresse de la table des super-page est mémorisée dans le registre de contrôle CR1 pour l'espace d'adressage primaire, CR7 pour l'espace d'adressage secondaire. L'un ou l'autre est utilisé, selon que le processeur est en mode "primaire" ou "secondaire". ===Les registres d'accès de la ''z/architecture''=== Un programme peut donc passer de l'espace d'adressage primaire à secondaire (ou inversement) en utilisant l’instruction ''SET ADDRESS SPACE CONTROL''. Mais il peut en profiter pour passer dans un mode où il a accès à 2048 espaces d'adressage différents, en même temps. Le processeur n'a pas besoin d'utiliser d'instructions particulières pour copier des données d'un espace d'adressage à un autre, un simple MOV suffit. Le mode en question est appelé le '''mode ''access register'''''. Le mode ''access register'' donne accès aux '''registres d'accès''', qui lui donnent son nom. Les registres d'accès mémorisent des identifiants qui permettent de sélectionner un espace d'adressage parmi 2048. Il y a en tout 16 registres d'accès, chacun étant appairé avec un registre général. Par exemple, le registre général numéro 5 est appairé au registre d'accès de même numéro. En mode ''access register'', toute lecture/écriture lit l'adresse dans un registre général et le registre d'accès associé indique dans quel espace d'adressage se trouve cette adresse. Par exemple, une lecture utilise l'adresse dans le registre général numéro 7, l'espace d'adressage associé sera dans le registre d'accès numéro 7. Une implémentation simple numéroterait les espaces d'adressage et mettrait les numéros dans les registres d'accès. Un espace d'adressage serait associé à une table des super-pages, reste à mémoriser la correspondance ''numéro d'espace d'adressage - adresse de la table des super-pages''. Pour cela, l'idéal est de mémoriser ces correspondances dans un tableau en mémoire RAM. Le numéro d'espace d'adressage est alors un indice, qui pointe sur l'entrée adéquate dans cette table. Il devrait y avoir un tableau par programme, qui serait protégé en écriture et seulement accessible par le système d'exploitation, comme le serait une table des pages. Mais ce n'est pas exactement ce qui est fait. La différence est qu'il n'y a pas une table de correspondance par programme, mais un système à deux tableaux imbriqués. Le premier tableau possède 65536 entrées, une par espace d'adressage supporté par le processeur. C'est donc une table partagée par tous les programmes. Par contre, la seconde table est bien unique pour chaque programme, chaque programme a la sienne. Faire ainsi permet de simplifier le partage de la mémoire. La table partagée est l''''''Access Space Table Entry''''' (ASTE), alors que la seconde table est la '''liste privée''' (''access list''), sous-entendu "privée pour un programme". La liste privée mémorise une liste des espaces d'adressage auquel le programme peut accéder, auxquels le système d'exploitation lui a accordé l'accès. Pour chaque espace d'adressage, elle renvoie vers l'entrée de l'ASTE associée. En plus de mémoriser l'adresse de cette entrée, elle mémorise les droits d'accès que le programme a sur l'espace d'adressage associé. Par exemple, si deux programmes partagent un espace d'adressage, l'un pourra y avoir accès en lecture seule, l'autre pourra avoir un accès total. Pour cela, ils ont des droits différents dans la liste privée, mais l'entrée de l'ASTE sera la même. [[File:Tradiction d'adresse sur la zarchitecture d'IBM.png|centre|vignette|upright=2|Traduction d'adresse sur la zarchitecture d'IBM.]] Il y a donc une vérification des droits d'accès, suivi d'un accès à une table des super-pages globale. Si un programme veut accéder à un espace d'adressage qui lui est interdit, l'accès mémoire stoppera dans la liste privée. Le processeur utilisera l'identifiant d'espace d'adressage, mais la liste privée n'aura pas d'entrée associée. Elle lèvera alors une exception matérielle et le système d'exploitation gérera la situation, vraisemblablement en tuant le programme fautif. Mais si l'accès est autorisé, alors le programme accède à la table des super-pages et l'accès mémoire se poursuit. Je dis une liste d'accès privée, mais il y en a deux en réalité. Mais la distinction exacte demanderait de parler de ''threads'' et de processus. Pour ceux qui sont familiers avec ces concepts, chaque ''thread'' a sa propre liste privée, avec en plus une liste par processus partagée entre tous les ''threads''. Deux listes de 1024 entrées maximum, cela fait 2048 entrées au total. Les deux listes portent les noms de ''Dispatchable Unit Access List'' et de ''PASN-AL (Primary Address Space Access List''. Inutile de retenir ces deux termes, cependant. Un registre d'accès mémorise un '''''Access List Entry Token''''' ou ALET. L'ALET contient l'indice d'une entrée dans la liste privée. Elle contient aussi un bit qui indique quelle liste privée utiliser, entre la ''Dispatchable Unit Access List'' et la ''PASN-AL (Primary Address Space Access List''. Précisons cependant que deux valeurs font exceptions : 0 pointe vers l'espace d'adressage principal, 1 vers l'espace d'adressage secondaire. Les autres valeurs sont configurables. Lors d'une lecture/écriture, l'ALETs est utilisé pour sélectionner l'espace d'adressage. Les valeurs 0 et 1 sont traitées telles quelles, l'espace d'adressage est déterminé automatiquement par le processeur. Pour les autres valeurs, récupérer l'adresse mémoire demande de consulter la liste d'accès privée. L'ALET est envoyé à la liste d'accès privée, et cette dernière fournit l'adresse de l'entrée de l'ASTE à consulter. Le processeur connait l'adresse de la liste privée, car son adresse mémoire est mémorisée dans les registres de contrôle numéro 2 et 5. : Précisons que le registre d'accès numéro 0 contient obligatoirement la valeur 0, ce qui fait qu'il pointe vers l'espace d'adressage primaire. [[File:Traduction d'adresse - trois espaces d'adressage, zarchitecture d'IBM.png|centre|vignette|upright=2|Traduction d'adresse - trois espaces d'adressage, zarchitecture d'IBM.]] Une subtilité est que le système d'exploitation peut à tout moment modifier le contenu de la liste privée. Il se peut qu'un ALET qui correspondait à un espace d'adressage se mette à pointer vers un autre. Pour détecter cette situation, le processeur attribue un identifiant, à chaque espace d'adressage. L'identifiant est mémorisé dans les entrées de la liste privée, de l'ASTE et dans les registres d'accès (dans les bits de poids fort de l'ALET). L'identifiant est modifié dans l'ALET à chaque fois que le système d'exploitation modifie l'entrée. Si un programme fait un accès avec un registre d'accès, mais que l'identifiant ne correspond pas avec celui dans l'ALET/liste privée, c'est signe que l'espace d'adressage a été swappé, une exception matérielle est levée. Un tel fonctionnement vous aura sans doute fait penser à une architecture à capacité, mais la z/architecture n'en est pas une. Malgré des points de ressemblance assez importants, il y a des différences fondamentales avec les architectures à capacité. * Le z/architecture ne supporte pas la segmentation, qui est nécessaire pour les architectures à capacité. Sur la z/architecture, ce sont des espaces d'adressage qui sont sélectionnés, pas des segments. * Les registres d'accès ne sont pas des registres de capacité, car on peut les modifier avec des instructions en espace utilisateur. En comparaison, modifier les registres de capacité ne peut se faire qu'en espace noyau, sous l'égide du système d'exploitation. * Les droits d'accès sont dans une table séparée des pointeurs, pas concaténés à ceux-ci. La gestion des droits d'accès est gérée par les tables des pages. * Les identifiants ALET ne peuvent pas être passés à un autre programme. Deux ALETs identiques correspondent à deux espaces d'adressages différents dans deux programmes différents. L'identifiant peut être identique, il pointera vers l'entrée de même numéro dans les deux listes privées, mais l'adresse dans l'entrée peut être différente. <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> 5sha2cl6f23t16qtu7z5f8hvkmijmnr 774398 774397 2026-10-07T20:09:35Z Mewtow 31375 /* Les registres d'accès de la z/architecture */ 774398 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. ==Un cas particulier : la z/architecture d'IBM== IBM a sa propre gamme de ''mainframes'', qui utilisent des processeurs fait maison par IBM lui-même. Les CPU IBM ont leur propre jeu d'instruction CISC, appelé '''''z/architecture'''''. Et ce jeu d'instruction a des possibilités en termes d'adressage qui sont extrêmement intéressantes, en plus d'être assez inédites. La ''z/architecture'' gère des pages de 4 kibioctets, en plus de pages larges introduites dans les dernières versions. L traduction d’adresse se fait cependant en passant des regroupements de 4 pages virtuelles, appelés des ''segments'', qui n'ont rien à voir avec les segments au sens de la segmentation. Pour éviter les confusions, je vais les appeler des ''super-pages''. Les super-pages sont des groupes de 4 pages virtuelles, ce qui veut dire que les 4 pages peuvent être très éloignées en mémoire physique. Les super-pages sont concrètement des intermédiaires utilisés pour la traduction d'adresse. La traduction d'adresse démarre à une table des super-pages, qui point vers une table des pages à plusieurs niveaux (5 maximum). Mais ce n'est pas ce qui va nous intéresser dans ce qui suit. A la place, nous allons nous concentrer sur une fonctionnalité : les programmes ont accès à plusieurs espaces d'adressage simultanément. La ''z/architecture'' supporte 65536 espaces d'adressage simultanés. Par contre, un programme ne peut en utiliser que 2048 en même temps. De plus, ils ne sont accessibles que quand le processeur est dans un mode de fonctionnement bien précis. Un ^processeur ''z/architecture'' peut en effet fonctionner dans deux modes, qu'on va appeler "standard" et "étendu". ===Le mode standard de la ''z/architecture''=== En mode standard, un programme exécuté sur la ''z/architecture'' utilise deux espaces d'adressages : l''''espace d'adressage primaire''' où se situent le programme exécuté et les données importantes, un '''espace d'adressage secondaire''' utilisé pour des tâches/données annexes. : Un troisième espace d'adressage l''''espace d'adressage ''home''''', est réservé au système d'exploitation. Il a pour particularité de ne pas avoir d'ASN. Il n'est concrètement accesible qu'en mode superviseur, donc par le système d'exploitation. C'est en quelque sorte un espace d'adressage pour le noyau, séparé des espaces d'adressage utilisateur. Nous n'en parlerons pas dans ce qui suit. Mais comment le processeur pour utiliser ces espaces d'adressages ? Par exemple, s'il veut faire une lecture, comment précise-t-il l'espace d'adressage courant ? La réponse est que le processeur peut être dans deux états : soit primaire, soit secondaire. Dans le premier état, toutes les lectures/écritures se font dans l'espace d'adressage primaire. Dans le second, elles se font toutes dans l'espace d'adressage secondaire. Il peut passer de l'un à l'autre en utilisant l'instruction ''Set Address Space Control''. Le processeur dispose aussi d'instructions ''MOVE TO PRIMARY'' et ''MOVE TO SECONDARY'' pour échanger des données dans les deux espaces d'adressage. L'espace d'adressage primaire a une table des pages dédiée, séparée de celle de l'espace d'adressage secondaire. Ou plutôt devrais-je dire une table des super-pages. La table des pages est une table des pages emboitée. Il y a peu à détailler dessus, si ce n'est pour cette histoire de segments/super-pages. L'adresse de la table des super-page est mémorisée dans le registre de contrôle CR1 pour l'espace d'adressage primaire, CR7 pour l'espace d'adressage secondaire. L'un ou l'autre est utilisé, selon que le processeur est en mode "primaire" ou "secondaire". ===Les registres d'accès de la ''z/architecture''=== Un programme peut donc passer de l'espace d'adressage primaire à secondaire (ou inversement) en utilisant l’instruction ''SET ADDRESS SPACE CONTROL''. Mais il peut en profiter pour passer dans un mode où il a accès à 2048 espaces d'adressage différents, en même temps. Le processeur n'a pas besoin d'utiliser d'instructions particulières pour copier des données d'un espace d'adressage à un autre, un simple MOV suffit. Le mode en question est appelé le '''mode ''access register'''''. Le mode ''access register'' donne accès aux '''registres d'accès''', qui lui donnent son nom. Les registres d'accès mémorisent des identifiants qui permettent de sélectionner un espace d'adressage parmi 2048. Il y a en tout 16 registres d'accès, chacun étant appairé avec un registre général. Par exemple, le registre général numéro 5 est appairé au registre d'accès de même numéro. En mode ''access register'', toute lecture/écriture lit l'adresse dans un registre général et le registre d'accès associé indique dans quel espace d'adressage se trouve cette adresse. Par exemple, une lecture utilise l'adresse dans le registre général numéro 7, l'espace d'adressage associé sera dans le registre d'accès numéro 7. Les registres d'accès peuvent être modifiés par des instructions classiques, en espace utilisateur. Elles sont nommées COPY ACCESS, EXTRACT ACCESS, LOAD ACCESS MULTIPLE, LOAD ADDRESS EXTENDED, SET ACCESS et STORE ACCESS MULTIPLE. Elles permettent de copier un registre d'accès dans un autre, de charger le contenu d'un registre d'accès depuis la mémoire RAM, d'enregistrer les registres d'accès dans la mémoire RAM, de copier une constante dans un registre d'accès. Mais quel est le contenu de ces registres ? Une implémentation simple numéroterait les espaces d'adressage et mettrait les numéros dans les registres d'accès. Un espace d'adressage serait associé à une table des super-pages, reste à mémoriser la correspondance ''numéro d'espace d'adressage - adresse de la table des super-pages''. Pour cela, l'idéal est de mémoriser ces correspondances dans un tableau en mémoire RAM. Le numéro d'espace d'adressage est alors un indice, qui pointe sur l'entrée adéquate dans cette table. Il devrait y avoir un tableau par programme, qui serait protégé en écriture et seulement accessible par le système d'exploitation, comme le serait une table des pages. Mais ce n'est pas exactement ce qui est fait. La différence est qu'il n'y a pas une table de correspondance par programme, mais un système à deux tableaux imbriqués. Le premier tableau possède 65536 entrées, une par espace d'adressage supporté par le processeur. C'est donc une table partagée par tous les programmes. Par contre, la seconde table est bien unique pour chaque programme, chaque programme a la sienne. Faire ainsi permet de simplifier le partage de la mémoire. La table partagée est l''''''Access Space Table Entry''''' (ASTE), alors que la seconde table est la '''liste privée''' (''access list''), sous-entendu "privée pour un programme". La liste privée mémorise une liste des espaces d'adressage auquel le programme peut accéder, auxquels le système d'exploitation lui a accordé l'accès. Pour chaque espace d'adressage, elle renvoie vers l'entrée de l'ASTE associée. En plus de mémoriser l'adresse de cette entrée, elle mémorise les droits d'accès que le programme a sur l'espace d'adressage associé. Par exemple, si deux programmes partagent un espace d'adressage, l'un pourra y avoir accès en lecture seule, l'autre pourra avoir un accès total. Pour cela, ils ont des droits différents dans la liste privée, mais l'entrée de l'ASTE sera la même. [[File:Tradiction d'adresse sur la zarchitecture d'IBM.png|centre|vignette|upright=2|Traduction d'adresse sur la zarchitecture d'IBM.]] Il y a donc une vérification des droits d'accès, suivi d'un accès à une table des super-pages globale. Si un programme veut accéder à un espace d'adressage qui lui est interdit, l'accès mémoire stoppera dans la liste privée. Le processeur utilisera l'identifiant d'espace d'adressage, mais la liste privée n'aura pas d'entrée associée. Elle lèvera alors une exception matérielle et le système d'exploitation gérera la situation, vraisemblablement en tuant le programme fautif. Mais si l'accès est autorisé, alors le programme accède à la table des super-pages et l'accès mémoire se poursuit. Je dis une liste d'accès privée, mais il y en a deux en réalité. Mais la distinction exacte demanderait de parler de ''threads'' et de processus. Pour ceux qui sont familiers avec ces concepts, chaque ''thread'' a sa propre liste privée, avec en plus une liste par processus partagée entre tous les ''threads''. Deux listes de 1024 entrées maximum, cela fait 2048 entrées au total. Les deux listes portent les noms de ''Dispatchable Unit Access List'' et de ''PASN-AL (Primary Address Space Access List''. Inutile de retenir ces deux termes, cependant. Un registre d'accès mémorise un '''''Access List Entry Token''''' ou ALET. L'ALET contient l'indice d'une entrée dans la liste privée. Elle contient aussi un bit qui indique quelle liste privée utiliser, entre la ''Dispatchable Unit Access List'' et la ''PASN-AL (Primary Address Space Access List''. Précisons cependant que deux valeurs font exceptions : 0 pointe vers l'espace d'adressage principal, 1 vers l'espace d'adressage secondaire. Les autres valeurs sont configurables. {|class="wikitable" |+ Registre d'accès |- ! 0000000 !! L (choix de la liste privée !! ASLEN !! ALEN |- | 0000000 || Choix de la liste privée || Identifiant de protection || Indice dans la liste privée |} Lors d'une lecture/écriture, l'ALETs est utilisé pour sélectionner l'espace d'adressage. Les valeurs 0 et 1 sont traitées telles quelles, l'espace d'adressage est déterminé automatiquement par le processeur. Pour les autres valeurs, récupérer l'adresse mémoire demande de consulter la liste d'accès privée. L'ALET est envoyé à la liste d'accès privée, et cette dernière fournit l'adresse de l'entrée de l'ASTE à consulter. Le processeur connait l'adresse de la liste privée, car son adresse mémoire est mémorisée dans les registres de contrôle numéro 2 et 5. : Précisons que le registre d'accès numéro 0 contient obligatoirement la valeur 0, ce qui fait qu'il pointe vers l'espace d'adressage primaire. [[File:Traduction d'adresse - trois espaces d'adressage, zarchitecture d'IBM.png|centre|vignette|upright=2|Traduction d'adresse - trois espaces d'adressage, zarchitecture d'IBM.]] Une subtilité est que le système d'exploitation peut à tout moment modifier le contenu de la liste privée. Il se peut qu'un ALET qui correspondait à un espace d'adressage se mette à pointer vers un autre. Pour détecter cette situation, le processeur attribue un identifiant, à chaque espace d'adressage. L'identifiant est mémorisé dans les entrées de la liste privée, de l'ASTE et dans les registres d'accès (dans les bits de poids fort de l'ALET). L'identifiant est modifié dans l'ALET à chaque fois que le système d'exploitation modifie l'entrée. Si un programme fait un accès avec un registre d'accès, mais que l'identifiant ne correspond pas avec celui dans l'ALET/liste privée, c'est signe que l'espace d'adressage a été swappé, une exception matérielle est levée. Un tel fonctionnement vous aura sans doute fait penser à une architecture à capacité, mais la z/architecture n'en est pas une. Malgré des points de ressemblance assez importants, il y a des différences fondamentales avec les architectures à capacité. * Le z/architecture ne supporte pas la segmentation, qui est nécessaire pour les architectures à capacité. Sur la z/architecture, ce sont des espaces d'adressage qui sont sélectionnés, pas des segments. * Les registres d'accès ne sont pas des registres de capacité, car on peut les modifier avec des instructions en espace utilisateur. En comparaison, modifier les registres de capacité ne peut se faire qu'en espace noyau, sous l'égide du système d'exploitation. * Les droits d'accès sont dans une table séparée des pointeurs, pas concaténés à ceux-ci. La gestion des droits d'accès est gérée par les tables des pages. * Les identifiants ALET ne peuvent pas être passés à un autre programme. Deux ALETs identiques correspondent à deux espaces d'adressages différents dans deux programmes différents. L'identifiant peut être identique, il pointera vers l'entrée de même numéro dans les deux listes privées, mais l'adresse dans l'entrée peut être différente. <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> 7ozrpxk54n2g5ttq0x0pqgjy6d75juj 774399 774398 2026-10-07T20:11:57Z Mewtow 31375 /* Un cas particulier : la z/architecture d'IBM */ 774399 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. ==Un cas particulier : la z/architecture d'IBM== IBM a sa propre gamme de ''mainframes'', qui utilisent des processeurs fait maison par IBM lui-même. Les CPU IBM ont leur propre jeu d'instruction CISC, appelé '''''z/architecture'''''. Et ce jeu d'instruction a des possibilités en termes d'adressage qui sont extrêmement intéressantes, en plus d'être assez inédites. La ''z/architecture'' gère des pages de 4 kibioctets, en plus de pages larges introduites dans les dernières versions. L traduction d’adresse se fait cependant en passant des regroupements de 4 pages virtuelles, appelés des ''segments'', qui n'ont rien à voir avec les segments au sens de la segmentation. Pour éviter les confusions, je vais les appeler des ''super-pages''. Les super-pages sont des groupes de 4 pages virtuelles, ce qui veut dire que les 4 pages peuvent être très éloignées en mémoire physique. Les super-pages sont concrètement des intermédiaires utilisés pour la traduction d'adresse. La traduction d'adresse démarre à une table des super-pages, qui point vers une table des pages à plusieurs niveaux (5 maximum). Mais ce n'est pas ce qui va nous intéresser dans ce qui suit. À la place, nous allons nous concentrer sur une fonctionnalité : les programmes ont accès à plusieurs espaces d'adressage simultanément. La ''z/architecture'' supporte 65 536 espaces d'adressage simultanés. Par contre, un programme ne peut en utiliser que 2048 en même temps. De plus, ils ne sont accessibles que quand le processeur est dans un mode de fonctionnement bien précis. Un ^processeur ''z/architecture'' peut en effet fonctionner dans deux modes, qu'on va appeler "standard" et "étendu". ===Le mode standard de la ''z/architecture''=== En mode standard, un programme exécuté sur la ''z/architecture'' utilise deux espaces d'adressages : l''''espace d'adressage primaire''' où se situent le programme exécuté et les données importantes, un '''espace d'adressage secondaire''' utilisé pour des tâches/données annexes. : Un troisième espace d'adressage l''''espace d'adressage ''home''''', est réservé au système d'exploitation. Il a pour particularité de ne pas avoir d'ASN. Il n'est concrètement accessible qu'en mode superviseur, donc par le système d'exploitation. C'est en quelque sorte un espace d'adressage pour le noyau, séparé des espaces d'adressage utilisateur. Nous n'en parlerons pas dans ce qui suit. Mais comment le processeur pour utiliser ces espaces d'adressages ? Par exemple, s'il veut faire une lecture, comment précise-t-il l'espace d'adressage courant ? La réponse est que le processeur peut être dans deux états : soit primaire, soit secondaire. Dans le premier état, toutes les lectures/écritures se font dans l'espace d'adressage primaire. Dans le second, elles se font toutes dans l'espace d'adressage secondaire. Il peut passer de l'un à l'autre en utilisant l'instruction ''Set Address Space Control''. Le processeur dispose aussi d'instructions ''MOVE TO PRIMARY'' et ''MOVE TO SECONDARY'' pour échanger des données dans les deux espaces d'adressage. L'espace d'adressage primaire a une table des pages dédiée, séparée de celle de l'espace d'adressage secondaire. Ou plutôt devrais-je dire une table des super-pages. La table des pages est une table des pages emboitée. Il y a peu à détailler dessus, si ce n'est pour cette histoire de segments/super-pages. L'adresse de la table des super-page est mémorisée dans le registre de contrôle CR1 pour l'espace d'adressage primaire, CR7 pour l'espace d'adressage secondaire. L'un ou l'autre est utilisé, selon que le processeur est en mode "primaire" ou "secondaire". ===Les registres d'accès de la ''z/architecture''=== Un programme peut donc passer de l'espace d'adressage primaire à secondaire (ou inversement) en utilisant l’instruction ''SET ADDRESS SPACE CONTROL''. Mais il peut en profiter pour passer dans un mode où il a accès à 2048 espaces d'adressage différents, en même temps. Le processeur n'a pas besoin d'utiliser d'instructions particulières pour copier des données d'un espace d'adressage à un autre, un simple MOV suffit. Le mode en question est appelé le '''mode ''access register'''''. Le mode ''access register'' donne accès aux '''registres d'accès''', qui lui donnent son nom. Les registres d'accès mémorisent des identifiants qui permettent de sélectionner un espace d'adressage parmi 2048. Il y a en tout 16 registres d'accès, chacun étant appairé avec un registre général. Par exemple, le registre général numéro 5 est appairé au registre d'accès de même numéro. En mode ''access register'', toute lecture/écriture lit l'adresse dans un registre général et le registre d'accès associé indique dans quel espace d'adressage se trouve cette adresse. Par exemple, une lecture utilise l'adresse dans le registre général numéro 7, l'espace d'adressage associé sera dans le registre d'accès numéro 7. Les registres d'accès peuvent être modifiés par des instructions classiques, en espace utilisateur. Elles sont nommées COPY ACCESS, EXTRACT ACCESS, LOAD ACCESS MULTIPLE, LOAD ADDRESS EXTENDED, SET ACCESS et STORE ACCESS MULTIPLE. Elles permettent de copier un registre d'accès dans un autre, de charger le contenu d'un registre d'accès depuis la mémoire RAM, d'enregistrer les registres d'accès dans la mémoire RAM, de copier une constante dans un registre d'accès. Mais quel est le contenu de ces registres ? Une implémentation simple numéroterait les espaces d'adressage et mettrait les numéros dans les registres d'accès. Un espace d'adressage serait associé à une table des super-pages, reste à mémoriser la correspondance ''numéro d'espace d'adressage - adresse de la table des super-pages''. Pour cela, l'idéal est de mémoriser ces correspondances dans un tableau en mémoire RAM. Le numéro d'espace d'adressage est alors un indice, qui pointe sur l'entrée adéquate dans cette table. Il devrait y avoir un tableau par programme, qui serait protégé en écriture et seulement accessible par le système d'exploitation, comme le serait une table des pages. Mais ce n'est pas exactement ce qui est fait. La différence est qu'il n'y a pas une table de correspondance par programme, mais un système à deux tableaux imbriqués. Le premier tableau possède 65 536 entrées, une par espace d'adressage supporté par le processeur. C'est donc une table partagée par tous les programmes. Par contre, la seconde table est bien unique pour chaque programme, chaque programme a la sienne. Faire ainsi permet de simplifier le partage de la mémoire. La table partagée est l''''''Access Space Table Entry''''' (ASTE), alors que la seconde table est la '''liste privée''' (''access list''), sous-entendu "privée pour un programme". La liste privée mémorise une liste des espaces d'adressage auquel le programme peut accéder, auxquels le système d'exploitation lui a accordé l'accès. Pour chaque espace d'adressage, elle renvoie vers l'entrée de l'ASTE associée. En plus de mémoriser l'adresse de cette entrée, elle mémorise les droits d'accès que le programme a sur l'espace d'adressage associé. Par exemple, si deux programmes partagent un espace d'adressage, l'un pourra y avoir accès en lecture seule, l'autre pourra avoir un accès total. Pour cela, ils ont des droits différents dans la liste privée, mais l'entrée de l'ASTE sera la même. [[File:Tradiction d'adresse sur la zarchitecture d'IBM.png|centre|vignette|upright=2|Traduction d'adresse sur la zarchitecture d'IBM.]] Il y a donc une vérification des droits d'accès, suivi d'un accès à une table des super-pages globale. Si un programme veut accéder à un espace d'adressage qui lui est interdit, l'accès mémoire stoppera dans la liste privée. Le processeur utilisera l'identifiant d'espace d'adressage, mais la liste privée n'aura pas d'entrée associée. Elle lèvera alors une exception matérielle et le système d'exploitation gérera la situation, vraisemblablement en tuant le programme fautif. Mais si l'accès est autorisé, alors le programme accède à la table des super-pages et l'accès mémoire se poursuit. Je dis une liste d'accès privée, mais il y en a deux en réalité. Mais la distinction exacte demanderait de parler de ''threads'' et de processus. Pour ceux qui sont familiers avec ces concepts, chaque ''thread'' a sa propre liste privée, avec en plus une liste par processus, partagée entre tous les ''threads''. Deux listes de 1024 entrées maximum, cela fait 2048 entrées au total. Les deux listes portent les noms de ''Dispatchable Unit Access List'' et de ''PASN-AL (Primary Address Space Access List''. Inutile de retenir ces deux termes, cependant. Un registre d'accès mémorise un '''''Access List Entry Token''''' ou ALET. L'ALET contient l'indice d'une entrée dans la liste privée. Elle contient aussi un bit qui indique quelle liste privée utiliser, entre la ''Dispatchable Unit Access List'' et la ''PASN-AL (Primary Address Space Access List''. Précisons cependant que deux valeurs font exceptions : 0 pointe vers l'espace d'adressage principal, 1 vers l'espace d'adressage secondaire. Les autres valeurs sont configurables. {|class="wikitable" |+ Registre d'accès |- ! 0000000 !! L (choix de la liste privée !! ASLEN !! ALEN |- | 0000000 || Choix de la liste privée || Identifiant de protection || Indice dans la liste privée |} Lors d'une lecture/écriture, l'ALETs est utilisé pour sélectionner l'espace d'adressage. Les valeurs 0 et 1 sont traitées telles quelles, l'espace d'adressage est déterminé automatiquement par le processeur. Pour les autres valeurs, récupérer l'adresse mémoire demande de consulter la liste d'accès privée. L'ALET est envoyé à la liste d'accès privée, et cette dernière fournit l'adresse de l'entrée de l'ASTE à consulter. Le processeur connait l'adresse de la liste privée, car son adresse mémoire est mémorisée dans les registres de contrôle numéro 2 et 5. : Précisons que le registre d'accès numéro 0 contient obligatoirement la valeur 0, ce qui fait qu'il pointe vers l'espace d'adressage primaire. [[File:Traduction d'adresse - trois espaces d'adressage, zarchitecture d'IBM.png|centre|vignette|upright=2|Traduction d'adresse - trois espaces d'adressage, zarchitecture d'IBM.]] Une subtilité est que le système d'exploitation peut à tout moment modifier le contenu de la liste privée. Il se peut qu'un ALET qui correspondait à un espace d'adressage se mette à pointer vers un autre. Pour détecter cette situation, le processeur attribue un identifiant, à chaque espace d'adressage. L'identifiant est mémorisé dans les entrées de la liste privée, de l'ASTE et dans les registres d'accès (dans les bits de poids fort de l'ALET). L'identifiant est modifié dans l'ALET à chaque fois que le système d'exploitation modifie l'entrée. Si un programme fait un accès avec un registre d'accès, mais que l'identifiant ne correspond pas avec celui dans l'ALET/liste privée, c'est signe que l'espace d'adressage a été swappé, une exception matérielle est levée. Un tel fonctionnement vous aura sans doute fait penser à une architecture à capacité, mais la z/architecture n'en est pas une. Malgré des points de ressemblance assez importants, il y a des différences fondamentales avec les architectures à capacité. * Le z/architecture ne supporte pas la segmentation, qui est nécessaire pour les architectures à capacité. Sur la z/architecture, ce sont des espaces d'adressage qui sont sélectionnés, pas des segments. * Les registres d'accès ne sont pas des registres de capacité, car on peut les modifier avec des instructions en espace utilisateur. En comparaison, modifier les registres de capacité ne peut se faire qu'en espace noyau, sous l'égide du système d'exploitation. * Les droits d'accès sont dans une table séparée des pointeurs, pas concaténés à ceux-ci. La gestion des droits d'accès est gérée par les tables des pages. * Les identifiants ALET ne peuvent pas être passés à un autre programme. Deux ALETs identiques correspondent à deux espaces d'adressages différents dans deux programmes différents. L'identifiant peut être identique, il pointera vers l'entrée de même numéro dans les deux listes privées, mais l'adresse dans l'entrée peut être différente. <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> hrziaeecp3y9cpylh14nz1prexhvh16 774400 774399 2026-10-07T20:12:36Z Mewtow 31375 /* Un cas particulier : la z/architecture d'IBM */ 774400 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. ==Un cas particulier : la z/architecture d'IBM== IBM a sa propre gamme de ''mainframes'', qui utilisent des processeurs fait maison par IBM lui-même. Les CPU IBM ont leur propre jeu d'instruction CISC, appelé '''''z/architecture'''''. Et ce jeu d'instruction a des possibilités en termes d'adressage qui sont extrêmement intéressantes, en plus d'être assez inédites. La ''z/architecture'' gère des pages de 4 kibioctets, en plus de pages larges introduites dans les dernières versions. L traduction d’adresse se fait cependant en passant des regroupements de 4 pages virtuelles, appelés des ''segments'', qui n'ont rien à voir avec les segments au sens de la segmentation. Pour éviter les confusions, je vais les appeler des ''super-pages''. Les super-pages sont des groupes de 4 pages virtuelles, ce qui veut dire que les 4 pages peuvent être très éloignées en mémoire physique. Les super-pages sont concrètement des intermédiaires utilisés pour la traduction d'adresse. La traduction d'adresse démarre à une table des super-pages, qui point vers une table des pages à plusieurs niveaux (5 maximum). Mais ce n'est pas ce qui va nous intéresser dans ce qui suit. À la place, nous allons nous concentrer sur une fonctionnalité : les programmes ont accès à plusieurs espaces d'adressage simultanément. La ''z/architecture'' supporte 65 536 espaces d'adressage simultanés. Par contre, un programme ne peut en utiliser que 2048 en même temps. De plus, ils ne sont accessibles que quand le processeur est dans un mode de fonctionnement bien précis. Un processeur ''z/architecture'' peut en effet fonctionner dans deux modes, qu'on va appeler "standard" et "étendu". ===Le mode standard de la ''z/architecture''=== En mode standard, un programme exécuté sur la ''z/architecture'' utilise deux espaces d'adressages : l''''espace d'adressage primaire''' où se situent le programme exécuté et les données importantes, un '''espace d'adressage secondaire''' utilisé pour des tâches/données annexes. : Un troisième espace d'adressage l''''espace d'adressage ''home''''', est réservé au système d'exploitation. Il a pour particularité de ne pas avoir d'ASN. Il n'est concrètement accessible qu'en mode superviseur, donc par le système d'exploitation. C'est en quelque sorte un espace d'adressage pour le noyau, séparé des espaces d'adressage utilisateur. Nous n'en parlerons pas dans ce qui suit. Mais comment le processeur pour utiliser ces espaces d'adressages ? Par exemple, s'il veut faire une lecture, comment précise-t-il l'espace d'adressage courant ? La réponse est que le processeur peut être dans deux états : soit primaire, soit secondaire. Dans le premier état, toutes les lectures/écritures se font dans l'espace d'adressage primaire. Dans le second, elles se font toutes dans l'espace d'adressage secondaire. Il peut passer de l'un à l'autre en utilisant l'instruction ''Set Address Space Control''. Le processeur dispose aussi d'instructions ''MOVE TO PRIMARY'' et ''MOVE TO SECONDARY'' pour échanger des données dans les deux espaces d'adressage. L'espace d'adressage primaire a une table des pages dédiée, séparée de celle de l'espace d'adressage secondaire. Ou plutôt devrais-je dire une table des super-pages. La table des pages est une table des pages emboitée. Il y a peu à détailler dessus, si ce n'est pour cette histoire de segments/super-pages. L'adresse de la table des super-page est mémorisée dans le registre de contrôle CR1 pour l'espace d'adressage primaire, CR7 pour l'espace d'adressage secondaire. L'un ou l'autre est utilisé, selon que le processeur est en mode "primaire" ou "secondaire". ===Les registres d'accès de la ''z/architecture''=== Un programme peut donc passer de l'espace d'adressage primaire à secondaire (ou inversement) en utilisant l’instruction ''SET ADDRESS SPACE CONTROL''. Mais il peut en profiter pour passer dans un mode où il a accès à 2048 espaces d'adressage différents, en même temps. Le processeur n'a pas besoin d'utiliser d'instructions particulières pour copier des données d'un espace d'adressage à un autre, un simple MOV suffit. Le mode en question est appelé le '''mode ''access register'''''. Le mode ''access register'' donne accès aux '''registres d'accès''', qui lui donnent son nom. Les registres d'accès mémorisent des identifiants qui permettent de sélectionner un espace d'adressage parmi 2048. Il y a en tout 16 registres d'accès, chacun étant appairé avec un registre général. Par exemple, le registre général numéro 5 est appairé au registre d'accès de même numéro. En mode ''access register'', toute lecture/écriture lit l'adresse dans un registre général et le registre d'accès associé indique dans quel espace d'adressage se trouve cette adresse. Par exemple, une lecture utilise l'adresse dans le registre général numéro 7, l'espace d'adressage associé sera dans le registre d'accès numéro 7. Les registres d'accès peuvent être modifiés par des instructions classiques, en espace utilisateur. Elles sont nommées COPY ACCESS, EXTRACT ACCESS, LOAD ACCESS MULTIPLE, LOAD ADDRESS EXTENDED, SET ACCESS et STORE ACCESS MULTIPLE. Elles permettent de copier un registre d'accès dans un autre, de charger le contenu d'un registre d'accès depuis la mémoire RAM, d'enregistrer les registres d'accès dans la mémoire RAM, de copier une constante dans un registre d'accès. Mais quel est le contenu de ces registres ? Une implémentation simple numéroterait les espaces d'adressage et mettrait les numéros dans les registres d'accès. Un espace d'adressage serait associé à une table des super-pages, reste à mémoriser la correspondance ''numéro d'espace d'adressage - adresse de la table des super-pages''. Pour cela, l'idéal est de mémoriser ces correspondances dans un tableau en mémoire RAM. Le numéro d'espace d'adressage est alors un indice, qui pointe sur l'entrée adéquate dans cette table. Il devrait y avoir un tableau par programme, qui serait protégé en écriture et seulement accessible par le système d'exploitation, comme le serait une table des pages. Mais ce n'est pas exactement ce qui est fait. La différence est qu'il n'y a pas une table de correspondance par programme, mais un système à deux tableaux imbriqués. Le premier tableau possède 65 536 entrées, une par espace d'adressage supporté par le processeur. C'est donc une table partagée par tous les programmes. Par contre, la seconde table est bien unique pour chaque programme, chaque programme a la sienne. Faire ainsi permet de simplifier le partage de la mémoire. La table partagée est l''''''Access Space Table Entry''''' (ASTE), alors que la seconde table est la '''liste privée''' (''access list''), sous-entendu "privée pour un programme". La liste privée mémorise une liste des espaces d'adressage auquel le programme peut accéder, auxquels le système d'exploitation lui a accordé l'accès. Pour chaque espace d'adressage, elle renvoie vers l'entrée de l'ASTE associée. En plus de mémoriser l'adresse de cette entrée, elle mémorise les droits d'accès que le programme a sur l'espace d'adressage associé. Par exemple, si deux programmes partagent un espace d'adressage, l'un pourra y avoir accès en lecture seule, l'autre pourra avoir un accès total. Pour cela, ils ont des droits différents dans la liste privée, mais l'entrée de l'ASTE sera la même. [[File:Tradiction d'adresse sur la zarchitecture d'IBM.png|centre|vignette|upright=2|Traduction d'adresse sur la zarchitecture d'IBM.]] Il y a donc une vérification des droits d'accès, suivi d'un accès à une table des super-pages globale. Si un programme veut accéder à un espace d'adressage qui lui est interdit, l'accès mémoire stoppera dans la liste privée. Le processeur utilisera l'identifiant d'espace d'adressage, mais la liste privée n'aura pas d'entrée associée. Elle lèvera alors une exception matérielle et le système d'exploitation gérera la situation, vraisemblablement en tuant le programme fautif. Mais si l'accès est autorisé, alors le programme accède à la table des super-pages et l'accès mémoire se poursuit. Je dis une liste d'accès privée, mais il y en a deux en réalité. Mais la distinction exacte demanderait de parler de ''threads'' et de processus. Pour ceux qui sont familiers avec ces concepts, chaque ''thread'' a sa propre liste privée, avec en plus une liste par processus, partagée entre tous les ''threads''. Deux listes de 1024 entrées maximum, cela fait 2048 entrées au total. Les deux listes portent les noms de ''Dispatchable Unit Access List'' et de ''PASN-AL (Primary Address Space Access List''. Inutile de retenir ces deux termes, cependant. Un registre d'accès mémorise un '''''Access List Entry Token''''' ou ALET. L'ALET contient l'indice d'une entrée dans la liste privée. Elle contient aussi un bit qui indique quelle liste privée utiliser, entre la ''Dispatchable Unit Access List'' et la ''PASN-AL (Primary Address Space Access List''. Précisons cependant que deux valeurs font exceptions : 0 pointe vers l'espace d'adressage principal, 1 vers l'espace d'adressage secondaire. Les autres valeurs sont configurables. {|class="wikitable" |+ Registre d'accès |- ! 0000000 !! L (choix de la liste privée !! ASLEN !! ALEN |- | 0000000 || Choix de la liste privée || Identifiant de protection || Indice dans la liste privée |} Lors d'une lecture/écriture, l'ALETs est utilisé pour sélectionner l'espace d'adressage. Les valeurs 0 et 1 sont traitées telles quelles, l'espace d'adressage est déterminé automatiquement par le processeur. Pour les autres valeurs, récupérer l'adresse mémoire demande de consulter la liste d'accès privée. L'ALET est envoyé à la liste d'accès privée, et cette dernière fournit l'adresse de l'entrée de l'ASTE à consulter. Le processeur connait l'adresse de la liste privée, car son adresse mémoire est mémorisée dans les registres de contrôle numéro 2 et 5. : Précisons que le registre d'accès numéro 0 contient obligatoirement la valeur 0, ce qui fait qu'il pointe vers l'espace d'adressage primaire. [[File:Traduction d'adresse - trois espaces d'adressage, zarchitecture d'IBM.png|centre|vignette|upright=2|Traduction d'adresse - trois espaces d'adressage, zarchitecture d'IBM.]] Une subtilité est que le système d'exploitation peut à tout moment modifier le contenu de la liste privée. Il se peut qu'un ALET qui correspondait à un espace d'adressage se mette à pointer vers un autre. Pour détecter cette situation, le processeur attribue un identifiant, à chaque espace d'adressage. L'identifiant est mémorisé dans les entrées de la liste privée, de l'ASTE et dans les registres d'accès (dans les bits de poids fort de l'ALET). L'identifiant est modifié dans l'ALET à chaque fois que le système d'exploitation modifie l'entrée. Si un programme fait un accès avec un registre d'accès, mais que l'identifiant ne correspond pas avec celui dans l'ALET/liste privée, c'est signe que l'espace d'adressage a été swappé, une exception matérielle est levée. Un tel fonctionnement vous aura sans doute fait penser à une architecture à capacité, mais la z/architecture n'en est pas une. Malgré des points de ressemblance assez importants, il y a des différences fondamentales avec les architectures à capacité. * Le z/architecture ne supporte pas la segmentation, qui est nécessaire pour les architectures à capacité. Sur la z/architecture, ce sont des espaces d'adressage qui sont sélectionnés, pas des segments. * Les registres d'accès ne sont pas des registres de capacité, car on peut les modifier avec des instructions en espace utilisateur. En comparaison, modifier les registres de capacité ne peut se faire qu'en espace noyau, sous l'égide du système d'exploitation. * Les droits d'accès sont dans une table séparée des pointeurs, pas concaténés à ceux-ci. La gestion des droits d'accès est gérée par les tables des pages. * Les identifiants ALET ne peuvent pas être passés à un autre programme. Deux ALETs identiques correspondent à deux espaces d'adressages différents dans deux programmes différents. L'identifiant peut être identique, il pointera vers l'entrée de même numéro dans les deux listes privées, mais l'adresse dans l'entrée peut être différente. <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> kb2wrhezkn8cwrypdhysq0ri1klocsq 774401 774400 2026-10-07T20:13:40Z Mewtow 31375 /* Les registres d'accès de la z/architecture */ 774401 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. ==Un cas particulier : la z/architecture d'IBM== IBM a sa propre gamme de ''mainframes'', qui utilisent des processeurs fait maison par IBM lui-même. Les CPU IBM ont leur propre jeu d'instruction CISC, appelé '''''z/architecture'''''. Et ce jeu d'instruction a des possibilités en termes d'adressage qui sont extrêmement intéressantes, en plus d'être assez inédites. La ''z/architecture'' gère des pages de 4 kibioctets, en plus de pages larges introduites dans les dernières versions. L traduction d’adresse se fait cependant en passant des regroupements de 4 pages virtuelles, appelés des ''segments'', qui n'ont rien à voir avec les segments au sens de la segmentation. Pour éviter les confusions, je vais les appeler des ''super-pages''. Les super-pages sont des groupes de 4 pages virtuelles, ce qui veut dire que les 4 pages peuvent être très éloignées en mémoire physique. Les super-pages sont concrètement des intermédiaires utilisés pour la traduction d'adresse. La traduction d'adresse démarre à une table des super-pages, qui point vers une table des pages à plusieurs niveaux (5 maximum). Mais ce n'est pas ce qui va nous intéresser dans ce qui suit. À la place, nous allons nous concentrer sur une fonctionnalité : les programmes ont accès à plusieurs espaces d'adressage simultanément. La ''z/architecture'' supporte 65 536 espaces d'adressage simultanés. Par contre, un programme ne peut en utiliser que 2048 en même temps. De plus, ils ne sont accessibles que quand le processeur est dans un mode de fonctionnement bien précis. Un processeur ''z/architecture'' peut en effet fonctionner dans deux modes, qu'on va appeler "standard" et "étendu". ===Le mode standard de la ''z/architecture''=== En mode standard, un programme exécuté sur la ''z/architecture'' utilise deux espaces d'adressages : l''''espace d'adressage primaire''' où se situent le programme exécuté et les données importantes, un '''espace d'adressage secondaire''' utilisé pour des tâches/données annexes. : Un troisième espace d'adressage l''''espace d'adressage ''home''''', est réservé au système d'exploitation. Il a pour particularité de ne pas avoir d'ASN. Il n'est concrètement accessible qu'en mode superviseur, donc par le système d'exploitation. C'est en quelque sorte un espace d'adressage pour le noyau, séparé des espaces d'adressage utilisateur. Nous n'en parlerons pas dans ce qui suit. Mais comment le processeur pour utiliser ces espaces d'adressages ? Par exemple, s'il veut faire une lecture, comment précise-t-il l'espace d'adressage courant ? La réponse est que le processeur peut être dans deux états : soit primaire, soit secondaire. Dans le premier état, toutes les lectures/écritures se font dans l'espace d'adressage primaire. Dans le second, elles se font toutes dans l'espace d'adressage secondaire. Il peut passer de l'un à l'autre en utilisant l'instruction ''Set Address Space Control''. Le processeur dispose aussi d'instructions ''MOVE TO PRIMARY'' et ''MOVE TO SECONDARY'' pour échanger des données dans les deux espaces d'adressage. L'espace d'adressage primaire a une table des pages dédiée, séparée de celle de l'espace d'adressage secondaire. Ou plutôt devrais-je dire une table des super-pages. La table des pages est une table des pages emboitée. Il y a peu à détailler dessus, si ce n'est pour cette histoire de segments/super-pages. L'adresse de la table des super-page est mémorisée dans le registre de contrôle CR1 pour l'espace d'adressage primaire, CR7 pour l'espace d'adressage secondaire. L'un ou l'autre est utilisé, selon que le processeur est en mode "primaire" ou "secondaire". ===Les registres d'accès de la ''z/architecture''=== Un programme peut donc passer de l'espace d'adressage primaire à secondaire (ou inversement) en utilisant l’instruction ''SET ADDRESS SPACE CONTROL''. Mais il peut en profiter pour passer dans un mode où il a accès à 2048 espaces d'adressage différents, en même temps. Le processeur n'a pas besoin d'utiliser d'instructions particulières pour copier des données d'un espace d'adressage à un autre, un simple MOV suffit. Le mode en question est appelé le '''mode ''access register'''''. Le mode ''access register'' donne accès aux '''registres d'accès''', qui lui donnent son nom. Les registres d'accès mémorisent des identifiants qui permettent de sélectionner un espace d'adressage parmi 2048. Il y a en tout 16 registres d'accès, chacun étant appairé avec un registre général. Par exemple, le registre général numéro 5 est appairé au registre d'accès de même numéro. En mode ''access register'', toute lecture/écriture lit l'adresse dans un registre général et le registre d'accès associé indique dans quel espace d'adressage se trouve cette adresse. Par exemple, une lecture utilise l'adresse dans le registre général numéro 7, l'espace d'adressage associé sera dans le registre d'accès numéro 7. Les registres d'accès peuvent être modifiés par des instructions classiques, en espace utilisateur. Elles sont nommées COPY ACCESS, EXTRACT ACCESS, LOAD ACCESS MULTIPLE, LOAD ADDRESS EXTENDED, SET ACCESS et STORE ACCESS MULTIPLE. Elles permettent de copier un registre d'accès dans un autre, de charger le contenu d'un registre d'accès depuis la mémoire RAM, d'enregistrer les registres d'accès dans la mémoire RAM, de copier une constante dans un registre d'accès. Mais quel est le contenu de ces registres ? Une implémentation simple numéroterait les espaces d'adressage et mettrait les numéros dans les registres d'accès. Un espace d'adressage serait associé à une table des super-pages, reste à mémoriser la correspondance ''numéro d'espace d'adressage - adresse de la table des super-pages''. Pour cela, l'idéal est de mémoriser ces correspondances dans un tableau en mémoire RAM. Le numéro d'espace d'adressage est alors un indice, qui pointe sur l'entrée adéquate dans cette table. Il devrait y avoir un tableau par programme, qui serait protégé en écriture et seulement accessible par le système d'exploitation, comme le serait une table des pages. Mais ce n'est pas exactement ce qui est fait. ===L'utilisation des registres d'accès=== La différence est qu'il n'y a pas une table de correspondance par programme, mais un système à deux tableaux imbriqués. Le premier tableau possède 65 536 entrées, une par espace d'adressage supporté par le processeur. C'est donc une table partagée par tous les programmes. Par contre, la seconde table est bien unique pour chaque programme, chaque programme a la sienne. Faire ainsi permet de simplifier le partage de la mémoire. La table partagée est l''''''Access Space Table Entry''''' (ASTE), alors que la seconde table est la '''liste privée''' (''access list''), sous-entendu "privée pour un programme". La liste privée mémorise une liste des espaces d'adressage auquel le programme peut accéder, auxquels le système d'exploitation lui a accordé l'accès. Pour chaque espace d'adressage, elle renvoie vers l'entrée de l'ASTE associée. En plus de mémoriser l'adresse de cette entrée, elle mémorise les droits d'accès que le programme a sur l'espace d'adressage associé. Par exemple, si deux programmes partagent un espace d'adressage, l'un pourra y avoir accès en lecture seule, l'autre pourra avoir un accès total. Pour cela, ils ont des droits différents dans la liste privée, mais l'entrée de l'ASTE sera la même. [[File:Tradiction d'adresse sur la zarchitecture d'IBM.png|centre|vignette|upright=2|Traduction d'adresse sur la zarchitecture d'IBM.]] Il y a donc une vérification des droits d'accès, suivi d'un accès à une table des super-pages globale. Si un programme veut accéder à un espace d'adressage qui lui est interdit, l'accès mémoire stoppera dans la liste privée. Le processeur utilisera l'identifiant d'espace d'adressage, mais la liste privée n'aura pas d'entrée associée. Elle lèvera alors une exception matérielle et le système d'exploitation gérera la situation, vraisemblablement en tuant le programme fautif. Mais si l'accès est autorisé, alors le programme accède à la table des super-pages et l'accès mémoire se poursuit. Je dis une liste d'accès privée, mais il y en a deux en réalité. Mais la distinction exacte demanderait de parler de ''threads'' et de processus. Pour ceux qui sont familiers avec ces concepts, chaque ''thread'' a sa propre liste privée, avec en plus une liste par processus, partagée entre tous les ''threads''. Deux listes de 1024 entrées maximum, cela fait 2048 entrées au total. Les deux listes portent les noms de ''Dispatchable Unit Access List'' et de ''PASN-AL (Primary Address Space Access List''. Inutile de retenir ces deux termes, cependant. Un registre d'accès mémorise un '''''Access List Entry Token''''' ou ALET. L'ALET contient l'indice d'une entrée dans la liste privée. Elle contient aussi un bit qui indique quelle liste privée utiliser, entre la ''Dispatchable Unit Access List'' et la ''PASN-AL (Primary Address Space Access List''. Précisons cependant que deux valeurs font exceptions : 0 pointe vers l'espace d'adressage principal, 1 vers l'espace d'adressage secondaire. Les autres valeurs sont configurables. {|class="wikitable" |+ Registre d'accès |- ! 0000000 !! L (choix de la liste privée !! ASLEN !! ALEN |- | 0000000 || Choix de la liste privée || Identifiant de protection || Indice dans la liste privée |} Lors d'une lecture/écriture, l'ALETs est utilisé pour sélectionner l'espace d'adressage. Les valeurs 0 et 1 sont traitées telles quelles, l'espace d'adressage est déterminé automatiquement par le processeur. Pour les autres valeurs, récupérer l'adresse mémoire demande de consulter la liste d'accès privée. L'ALET est envoyé à la liste d'accès privée, et cette dernière fournit l'adresse de l'entrée de l'ASTE à consulter. Le processeur connait l'adresse de la liste privée, car son adresse mémoire est mémorisée dans les registres de contrôle numéro 2 et 5. : Précisons que le registre d'accès numéro 0 contient obligatoirement la valeur 0, ce qui fait qu'il pointe vers l'espace d'adressage primaire. [[File:Traduction d'adresse - trois espaces d'adressage, zarchitecture d'IBM.png|centre|vignette|upright=2|Traduction d'adresse - trois espaces d'adressage, zarchitecture d'IBM.]] Une subtilité est que le système d'exploitation peut à tout moment modifier le contenu de la liste privée. Il se peut qu'un ALET qui correspondait à un espace d'adressage se mette à pointer vers un autre. Pour détecter cette situation, le processeur attribue un identifiant, à chaque espace d'adressage. L'identifiant est mémorisé dans les entrées de la liste privée, de l'ASTE et dans les registres d'accès (dans les bits de poids fort de l'ALET). L'identifiant est modifié dans l'ALET à chaque fois que le système d'exploitation modifie l'entrée. Si un programme fait un accès avec un registre d'accès, mais que l'identifiant ne correspond pas avec celui dans l'ALET/liste privée, c'est signe que l'espace d'adressage a été swappé, une exception matérielle est levée. Un tel fonctionnement vous aura sans doute fait penser à une architecture à capacité, mais la z/architecture n'en est pas une. Malgré des points de ressemblance assez importants, il y a des différences fondamentales avec les architectures à capacité. * Le z/architecture ne supporte pas la segmentation, qui est nécessaire pour les architectures à capacité. Sur la z/architecture, ce sont des espaces d'adressage qui sont sélectionnés, pas des segments. * Les registres d'accès ne sont pas des registres de capacité, car on peut les modifier avec des instructions en espace utilisateur. En comparaison, modifier les registres de capacité ne peut se faire qu'en espace noyau, sous l'égide du système d'exploitation. * Les droits d'accès sont dans une table séparée des pointeurs, pas concaténés à ceux-ci. La gestion des droits d'accès est gérée par les tables des pages. * Les identifiants ALET ne peuvent pas être passés à un autre programme. Deux ALETs identiques correspondent à deux espaces d'adressages différents dans deux programmes différents. L'identifiant peut être identique, il pointera vers l'entrée de même numéro dans les deux listes privées, mais l'adresse dans l'entrée peut être différente. <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> mq4q2r7k3k5vhwvk9m5rkoijbctdb2b Fonctionnement d'un ordinateur/La prédiction de branchement 0 65955 774402 774194 2026-10-07T21:03:00Z Mewtow 31375 /* La prédiction des branchements des boucles */ 774402 wikitext text/x-wiki Les branchements sont pris en charge par plusieurs étages du pipeline. Exécuter un branchement demande de savoir deux choses : s'il est pris ou non, quelle est l'adresse de destination. L'adresse de destination est localisée grâce au mode d'adressage du branchement. Avec le mode d'adressage direct, elle est extraite de l'instruction par le séquenceur. Avec l'adressage indirect, elle est lue dans un registre. Avec les branchements relatifs, elle est calculée via un additionneur, qui additionne un décalage au ''program counter''. L'additionneur peut être soit dans l'étage de ''PC Update'', soit dans l'unité de calcul de l'étage d'exécution. Passons maintenant au résultat du branchement, à savoir s'il est pris ou non. Pour savoir si le branchement est pris, il faut distinguer les branchements conditionnels et inconditionnels. Pour rappel, les branchements inconditionnels regroupent les appels de fonction, les simples instructions de saut, et quelques autres. Ils sont toujours pris, ils s'exécutent toujours. À partir du moment où le décodeur a décodé un branchement inconditionnel, il sait que le branchement est pris. Pour les branchements conditionnels, ils sont pris seulement si une condition précise est respectée. On sait s'ils sont exécutés une fois que la condition est calculée par l'unité de calcul, ou par une unité de branchement spécialisée. [[File:Gestion des branchements sur un pipeline.png|centre|vignette|upright=2.0|Gestion des branchements conditionnels sur un pipeline.]] Sur le schéma précédent, les branchements inconditionnels ne sont pas montrés. Pour cela, il faudrait rajouter une connexion du décodeur vers le multiplexeur de branchement. De plus, il faudrait aussi rajouter de quoi retarder l'exécution du branchement seulement une fois que l'adresse est connue. Par exemple, elle est connue un cycle d'horloge plus si l'adresse est lue depuis les registres, comparé à un branchement direct. ==Les branchements et les exceptions imprécises/précises== Un branchement est censé altérer le ''program counter'' immédiatement, en un cycle d'horloge. Mais sur un pipeline, Le branchement fait bien reprendre le processeur au bon endroit, mais avec du retard. La raison est qu'un branchement met plusieurs cycles pour s'exécuter. Il faut plusieurs cycles pour déterminer quelle est l'adresse de destination, temps qui dépend de là où est lue l'adresse. Et on sait si le branchement est pris seulement au niveau du décodeur, voire au niveau de l'unité de calcul. Le branchement doit traverser plusieurs étages avant qu'on connaisse son résultat. Il y a bien 3 à 4 cycles avant cela. Et pendant ce temps de trajet, des instructions seront chargées dans le pipeline, potentiellement à tort. Si le branchement n'est pas pris, ce n'est pas grave : les instructions n'ont pas été chargées à tort. Mais dans le cas contraire, les instructions n'auraient pas dû rentrer dans le pipeline. Le problème est similaire à celui observé avec les exceptions précises/imprécises. La différence est que les branchements sont des instructions machines, pas les exceptions matérielles. Mais le problème des instructions chargées à tort est le même dans les grandes lignes. Une différence est que les branchements sont résolus plus ou moins au même étage, alors que les exceptions peuvent survenir dans tout le pipeline. Une autre différence, bien plus importante, est que les branchements non-pris ne chargent pas d'instructions à tort et qu'il faut faire la différence entre branchement pris et non-pris. Dans la suite, nous parlerons d'instructions fautives pour parler des instructions chargées à tort. Pour corriger ce problème, il existe une solution logicielle. Le compilateur fait suivre les branchements par des instructions qui ne font rien, des NOP (No OPeration) : c'est ce qu'on appelle un '''délai de branchement'''. L'idée est que toutes les instructions chargées à tort sont des NOPs, qui ne font rien. Mais le nombre de NOP à ajouter dépend du pipeline du processeur, ce qui peut poser des problèmes de compatibilité. Mais d'autres solutions, matérielles cette fois, sont utilisées de nos jours. ===L'annulation des instructions au décodage=== Les branchements inconditionnels sont dans un cas plus favorable : ils sont identifiés dans l'unité de décodage et sont toujours pris. D'autres branchements sont dans ce cas : les appels de fonctions, les interruptions logicielles. Avec eux, le décodeur annule les instructions fautives, il les remplace par des instructions NOP. L'étage de décodage sait que les instructions suivant un branchement inconditionnel ont été chargées à tort. Précisément N instructions, avec N le nombre d'étages entre le chargement et le décodage (inclus). Les N instructions suivantes sont tout simplement annulés, elles sont remplacées par des NOPs. L'implémentation demande juste un simple compteur et quelques circuits annexes. Cependant, la technique ne marche pas du tout pour les branchements conditionnels. En effet, on ne sait pas s'ils sont pris ou non à l'étage de décodage. Du moins pas avec un pipeline normal. Mais certains processeurs, basés sur le pipeline RISC classique, calculaient les branchements pendant l'étape de décodage, dans une ALU spécialisée qui travaille en parallèle de l'unité de décodage. Le défaut de cette technique est que l'ALU en question doit lire les registres. Pour rappel, sur ce pipeline, l'étape de décodage effectue la lecture des opérandes dans les registres, en parallèle de l'unité de décodage. Mais ce qu'on n'a pas dit dans le chapitre précédent, c'est qu'elle effectue aussi le calcul des branchements dans une mini-ALU séparée ! Ce faisant, le résultat du branchement est connu dès la fin de l'étape de décodage. ===L'usage du système d'exceptions précises pour les branchements conditionnels=== La solution gère le problème en empêchant les instructions chargées à tort d'enregistrer leurs résultats, afin qu'elles n'aient pas le moindre effet sur l'état du processeur. Le hardware pour les exceptions précises, décrit quelques paragraphes avant, est réutilisé pour cela. Les branchements conditionnels peuvent être pris ou non-pris et on doit tenir compte de ces deux cas. Si le branchement n'est pas pris, les instructions n'ont pas été chargées à tort, elles peuvent enregistrer leur résultat dans les registres. Par contre, ce n'est pas le cas pour les branchements pris, les mécanismes d'exceptions précise doivent alors d'activer. Pour cela, la meilleure solution est que l'unité de calcul génère un indicateur d'exception si le branchement n'est pas pris. L'ALU effectue la comparaison et génère ou non l'indicateur d'exception adéquat suivant le résultat de celle-ci. L'idée est d'utiliser un indicateur d'exception particulier, qui précise qu'un branchement a été pris, signe que des instructions ont été chargées à tort. L'indicateur est propagé dans le pipeline, jusqu'à atteindre l'étage d'enregistrement dans les registres. Cet étage décide s'il faut autoriser ou non les écritures qu'il recoit, pour annuler les instructions chargées à tort. Le ''program counter'' est corrigé immédiatement, simplement en exécutant le branchement. Reste à savoir qui génère cet indicateur d'exception de branchement pris. Diverses optimisations visent à éliminer les instructions chargées à tort, à faire en sorte que des instructions utiles soient chargées. Le chapitre suivant va détailler ces optimisations dans le détail, qui sont regroupées sous le terme de prédiction de branchement. Elles ne fonctionnent pas pour les exceptions précises, seulement les branchements. ===La prédiction de branchement=== Les techniques précédentes ont pour défaut que de nombreux cycles d'horloge sont gâchés à cause des instructions fautives. Pour éviter ce gâchis, les concepteurs de processeurs ont inventé l'''exécution spéculative de branchement'', qui consiste à deviner l'adresse de destination du branchement et charger les bonnes instructions dès le départ. Le but est de ne pas charger d'instruction à tort, en prédisant quelles seront les bonnes instructions à charger. Et cela demande de résoudre trois problèmes : * reconnaître les branchements ; * savoir si un branchement sera exécuté ou non : c'est la '''prédiction de branchement''' ; * dans le cas où un branchement serait exécuté, il faut aussi savoir quelle est l'adresse de destination : c'est la '''prédiction de l'adresse de destination''' d'un branchement, aussi appelée ''branch target prediction''. Pour résoudre le second problème, le processeur contient un circuit qui déterminer si le branchement est pris (on doit brancher vers l'adresse de destination) ou non pris (on poursuit l’exécution du programme immédiatement après le branchement) : c'est l''''unité de prédiction de branchement'''. La prédiction de direction de branchement est elle aussi déléguée à un circuit spécialisé : l’'''unité de prédiction de direction de branchement'''. C'est l'unité de prédiction de branchement qui autorise l'unité de prédiction de destination de branchement à modifier le ''program counter''. Dans certains processeurs, les deux unités sont regroupées dans le même circuit. [[File:Relation entre prédiction de branchement et de direction de branchement.png|centre|vignette|upright=2|Relation entre prédiction de branchement et de direction de branchement.]] ==Les erreurs de prédiction de branchement== Le processeur peut parfaitement se tromper en faisant ses prédictions, ce qui charge des instructions par erreur dans le pipeline. Dans ce cas, on parle d''''erreur de prédiction'''. Les erreurs de prédiction sont corrigées par le système d'exception précises, ou un équivalent. En clair, les instructions chargées à tort se propagent jusqu'à la sortie du pipeline, où leurs résultats sont alors annulés. N'importe quelle technique de gestion des exceptions précise fait le travail, mais les processeurs modernes utilisent un tampon de réordonnancement (''reorder buffer'') qu'on détaillera dans quelques chapitres. Détecter les erreurs de prédiction demande d'ajouter un circuit dans le processeur : l’''unité de vérification de branchement'' (''branch verification unit''). Elle génère un indicateur d'exception, qui est géré par le système d'exception précise. Pour générer l'indicateur, elle compare l'adresse de destination prédite et celle calculée en exécutant le branchement. Pour cela, l'adresse prédite doit être propagée dans le pipeline jusqu’à ce que l'adresse de destination du branchement soit calculée. [[File:Unité de vérification de branchement.png|centre|vignette|upright=2|Unité de vérification de branchement]] ===La pénalité de mauvaise prédiction=== Le temps pour vider le pipeline des instructions fautives dépend du pipeline. La dernière instruction à être chargée dans le pipeline le sera en même temps qu'on détecte l'erreur de prédiction. Il faudra attendre que cette instruction quitte le pipeline, et donc qu'elle traverse tous les étages. Il y a donc un temps d'attente pour collecter les résultats de ces instructions et la décision de ne pas enregistrer leurs résultats dans les registres Le nombre d'instructions chargées à tort dépend de où est détectée l'erreur de prédiction. Plus le résultat du branchement est connu tard, plus le nombre d'instructions chargées à tort sera grand, et plus le nombre de cycles d'horloge gâchés le sera aussi. Le nombre de cycles gâchés s'appelle la '''pénalité de mauvaise prédiction'''. Elle correspond précisément au nombre de cycles/étages entre le premier étage du pipeline, et l'étage où le branchement est résolu (on connait l'adresse à laquelle brancher). Plus celui-ci est éloigné du début du pipeline, plus la pénalité est importante. [[File:MIT 6.823 Computer System Architecture, Fall 2005 - P-3 vs. P-4 Pipelines - 4473425702.jpg|centre|vignette|upright=2.5|Comparaison de la pénalité de mauvaise prédiction entre le Pentium 3 et le Pentium 4 d'Intel.]] Elle est généralement d'autant plus longue que le pipeline lui-même est long. Les concepteurs de processeurs font donc face à un compromis : un pipeline long permet d'exécuter plus d'instructions en parallèle, mais cela augmente aussi la pénalité de mauvaise prédiction. Un pipeline de 10-15 étapes est un bon compromis. Les pipelines plus longs ont besoin d'une prédiction de branchement élaborée pour fonctionner correctement. Plus la prédiction de branchement est performante, plus le pipeline peut être long. Les pénalités de mauvaise prédiction seront plus importantes, mais les mauvaises prédictions seront plus rares, ce qui compense complétement. ===La ''minimal control dependency''=== En utilisant une technique du nom de ''minimal control dependency'', seules les instructions qui dépendent du résultat du branchement sont supprimées du pipeline en cas de mauvaise prédiction, les autres n'étant pas annulées. Sans ''minimal control dependency'', toutes les instructions qui suivent un branchement dans notre pipeline sont annulées. Pourtant, certaines d'entre elles pourraient être utiles. Prenons un exemple : supposons que l'on dispose d'un processeur de 31 étages (un Pentium 4 par exemple). Supposons que l'adresse du branchement est connue au 9éme étage. On fait face à un branchement qui envoie le processeur seulement 6 instructions plus loin. Si toutes les instructions qui suivent le branchement sont supprimées, les instructions en rouge sont celles qui sont chargées incorrectement. On remarque pourtant que certaines instructions chargées sont potentiellement correctes : celles qui suivent le point d'arrivée du branchement. Elles ne le sont pas forcément : il se peut qu'elles aient des dépendances avec les instructions supprimées. Mais si elles n'en ont pas, alors ces instructions auraient dûes être exécutées. Il serait donc plus efficace de les laisser enregistrer leurs résultats au lieu de les ré-exécuter à l’identique un peu plus tard. Ce genre de choses est possible sur les processeurs qui implémentent une technique du nom de ''Minimal Control Dependancy''. En gros, cette technique fait en sorte que seules les instructions qui dépendent du résultat du branchement soient supprimées du pipeline en cas de mauvaise prédiction. [[File:Minimal cntrol dependency.png|centre|vignette|upright=2|Minimal control dependency]] ==La reconnaissance des branchements== Pour prédire des branchements, le processeur doit faire la différence entre branchements et autres instructions, dès l'étage de chargement. Or, un processeur « normal », sans prédiction de branchement, ne peut faire cette différence qu'à l'étage de décodage, et pas avant. Les chercheurs ont donc dû trouver une solution. La première solution se base sur les techniques de prédécodage vues dans le chapitre sur le cache. Pour rappel, ce prédécodage consiste à décoder partiellement les instructions lors de leur chargement dans le cache d'instructions et à mémoriser des informations utiles dans la ligne de cache. Dans le cas des branchements, les circuits de prédécodage peuvent identifier les branchements et mémoriser cette information dans la ligne de cache. Une autre solution mémorise les branchements déjà rencontrés, dans un cache dédié. Ce cache mémorise l'adresse (le ''program counter'') des branchements déjà rencontrés : il s'appelle le '''tampon d’adresse de branchement''' (''branch adress buffer''). Il est intégré à l'unité de prédiction de branchement, mais est alimenté par le décodeur d'instruction. Reconnaitre un branchement demande l'implication du décodeur d'instruction, qui sait si l'instruction qu'il vient de décoder est un branchement ou non. Il peut même savoir si le branchement est conditionnel, inconditionnel, un appel de retour de fonction, etc. Lorsque le décodeur d'instruction reconnait un branchement, il ajoute ce branchement dans le cache Le tapon d'adresse de branchement contient une adresse mémoire par branchement, et éventuellement d'autres informations. Et pour comprendre quelle adresse est mémorisée, il faut faire attention à un détail. Les explications qui vont suivre vont faire intervenir deux adresses. * La première est l''''adresse de destination''' du branchement, à savoir l'adresse à laquelle le processeur doit reprendre son exécution si le branchement est pris. * L'autre adresse est l''''adresse du branchement''' lui-même. Elle correspond à sa position en mémoire RAM. L'adresse du branchement est la valeur du ''program counter'' quand le branchement est chargé dans le cache, alors que l'adresse de destination est celle du ''program Counter'' une fois le branchement exécuté. Il faudra faire bien attention à ne pas confondre les deux adresses dans ce qui suit. L'adresse du branchement est celle qui est utilisée pour la reconnaissance du branchement. Le tampon d'adresse de branchement mémorise, pour chaque branchement, son adresse de branchement (d'où son nom). À chaque cycle d'horloge, l'unité de chargement envoie le ''program counter'' en entrée du tampon d’adresse de branchement. Un succès dans ce cache indique qu'un branchement déjà rencontré est ré-executé, on peut utiliser la prédiction de branchement. Dans le cas contraire, la prédiction de branchement n'est pas utilisée, et l'instruction est chargée normalement. ==La prédiction de l'adresse de destination== La '''prédiction de l'adresse de destination d'un branchement''' détermine l'adresse de destination d'un branchement. Rappelons qu'il existe plusieurs modes d'adressage pour les branchements et que chacun d'entre eux précise l'adresse de destination à sa manière. Suivant le mode d'adressage, l'adresse de destination est soit dans l'instruction elle-même (adressage direct), soit calculée à l’exécution en additionnant un ''offset'' au ''program counter'' (adressage relatif), soit dans un registre du processeur (branchement indirect), soit précisée de manière implicite (retour de fonction, adresse au sommet de la pile). La prédiction des branchements directs et relatifs se fait globalement de la même manière, ce qui fait que nous ferons la confusion dans ce qui suit. Pour les branchements implicites, ils correspondent presque exclusivement aux instructions de retour de fonction, qui sont un cas un peu à part que nous verrons dans la section sur les branchements indirects. Pour résumer, nous allons faire une différence entre les branchements directs pour lequel l'adresse de destination est toujours la même, les branchements indirects où l'adresse de destination est variable durant l’exécution du programme, et les instructions de retour de fonction. Les branchements directs sont facilement prévisibles, vu que l'adresse vers laquelle il faut brancher est toujours la même. Pour les branchements indirects, vu que cette adresse change, la prédire celle-ci est particulièrement compliqué (quand c'est possible). Pour les instructions de retour de fonction, une prédiction parfaite est possible. ===La prédiction de l'adresse de destination pour les branchements directs=== Lorsqu'un branchement est exécuté, on peut se souvenir de l'adresse de destination et la réutiliser lors d'exécutions ultérieures du branchement. Cette technique marche à la perfection pour les branchements directs, pour lesquels cette adresse est toujours la même, mais pas pour les branchements indirects. Pour se souvenir de l'adresse de destination, on utilise un cache qui mémorise les correspondances entre l'adresse du branchement et l'adresse de destination. Il est appelé le '''tampon de destination de branchement''', ''branch target buffer'' en anglais, qui sera abrévié en BTB dans ce qui suit. Le BTB est une amélioration du tampon d'adresse de branchement vu plus haut, auquel on aurait ajouté les adresses de destination des branchements. Le BTB est utilisé comme suit : on envoie en entrée l'adresse du branchement lors du chargement, le BTB répond au cycle suivant en précisant : s’il reconnaît l'adresse d'entrée, et quelle est l'adresse de destination si c'est le cas. Précisons que le BTB ne mémorise pas les branchements non-pris, ce qui est inutile. Le BTB peut être un cache totalement associatif, associatif par voie, ou directement adressé, mais ces derniers sont les plus fréquents. Les BTB sont généralement des caches de type directement adressés, où seuls les bits de poids faible de l'adresse du branchement adressent le cache, le reste de l'adresse est tout simplement ignoré. Il existe aussi des BTB qui sont construits comme les caches associatifs à plusieurs voies, mais ceux-ci impliquent généralement la présence de ''tags'', ce qui fait qu'ils sont assez rares. [[File:Branch target buffer directement adressé.png|centre|vignette|upright=2|Branch target buffer directement adressé.]] Comme pour tous les caches, un accès au BTB peut entraîner un défaut de cache, c’est-à-dire qu'un branchement censé être dans le BTB n'y est pas. Comme pour les autres caches, les défauts de cache peuvent se classer en trois types : les défauts de cache à froid (''cold miss'') causés par la première exécution d'un branchement, les défauts liés à la capacité du BTB/cache, et les défauts par conflit d'accès au cache. Les défauts liés à la capacité du BTB sont les plus simples à comprendre. La capacité limitée du BTB fait que d'anciens branchements sont éliminées pour laisser la place à de nouveaux, généralement en utilisant algorithme de remplacement de type LRU. En conséquence, certains branchements peuvent donner des erreurs de prédiction si leur correspondance a été éliminée du cache. On peut en réduire le nombre en augmentant la taille du BTB. Les défauts liés aux conflits d'accès au BTB sont eux beaucoup plus intéressants, car la conception du BTB est assez spéciale dans la manière dont sont gérés ce genre de défauts. Le BTB est un cache partiellement associatif, ce qui fait que des défauts de cache par conflit d'accès peuvent avoir lieu. Pour rappel, les défauts par conflit d'accès ont lieu quand deux adresses mémoires se voient attribuer la même ligne de cache. Pour un BTB, cela correspond au cas où deux branchements se voient attribuer la même entrée dans le BTB, ce qui porte le nom d’''aliasing''. Ne pas détecter ce genre de situation sur un cache normal entraînerait des problèmes : la donnée lue/écrite ne serait pas forcément la bonne. Les caches normaux utilisent un système de tags pour éviter ce problème et on s'attendrait à ce que les BTB fassent de même. S'il existe des BTB qui utilisent des ''tags'', ils sont cependant assez rares. Pour un BTB, l'absence de ''tags'' n'est pas un problème : la seule conséquence est une augmentation du taux de mauvaises prédictions, dont les conséquences en termes de performance peuvent être facilement compensées. Les BTB se passent généralement de tags, ce qui a de nombreux avantages : le circuit est plus rapide, prend moins de portes logiques, consomme moins d'énergie, etc. Ces économies de portes logiques et de performances peuvent être utilisées pour augmenter la taille du BTB, ce qui compense, voire surcompense les mauvaises prédictions induites par l’''aliasing''. Des processeurs se passent de BTB. À la place, ils mémorisent les correspondances entre branchement et adresse de destination dans les bits de contrôle du cache d'instructions. Cela demande de mémoriser trois paramètres : l'adresse du branchement dans la ligne de cache (stockée dans le ''branch bloc index''), l'adresse de la ligne de cache de destination, la position de l'instruction de destination dans la ligne de cache de destination. Lors de l'initialisation de la ligne de cache, on considère qu'il n'y a pas de branchement dedans. Les informations de prédiction de branchement sont ensuite mises à jour progressivement, au fur et à mesure de l’exécution de branchements. Le processeur doit en même temps charger l'instruction de destination correcte dans le cache, si un défaut de cache a lieu : il faut donc utiliser un cache multi-port. L'avantage de cette technique est que l'on peut mémoriser une information par ligne de cache, comparé à une instruction par entrée dans un tampon de destination de branchement. Mais cela ralentit fortement l'accès au cache et gaspille du circuit (les bits de contrôle ajoutés ne sont pas gratuits). En pratique, on n'utilise pas cette technique, sauf sur quelques processeurs (un des processeurs Alpha utilisait cette méthode). ===La prédiction de l'adresse de destination pour les branchements indirects=== Passons maintenant aux branchements indirects. Les techniques précédentes fonctionnement quand on les applique aux branchements indirects et ne marchent pas trop mal, mais elles se trompent à chaque fois qu'un branchement indirect change d'adresse de destination. Tous les processeurs commerciaux datant d'avant le Pentium M sont dans ce cas. De nos jours, les processeurs haute performance sont capables de prédire l'adresse de destination d'un branchement indirect. Mais même malgré ces techniques avancées de prédiction, les branchements indirects et appels de sous-programmes indirects sont souvent très mal prédits. Les processeurs modernes utilisent souvent une '''unité de prédiction des branchements indirects''', séparée de l'unité de prédiction de branchement normale. Elle utilise un BTB amélioré, qui mémorise plusieurs adresses de destination pour un seul branchement, en compagnie d'informations pour prédire laquelle est la bonne. Il est possible d'utiliser un BTB unique, pour tous types de branchements, appelé un '''BTB unifié'''. Cette solution est illustrée ci-dessous, avec un tableau. Mais elle est tout sauf optimale, et n'est presque jamais utilisée en pratique. {|class="wikitable" |+ Branch Taarget Buffer unifié |- ! Adresse du branchement !! Informations de prédiction !! Adresse de destination !! Seconde adresse de destination |- | 0x54462146 || 11101011 || 0x52495674 || 0x24987461 |- | 0x54142446 || 11101101 || 0x15646956 || x0x4158746 |- | 0x25456542 || 10011101 || 0x52495674 || 0x24157461 |- | 0x15455855 || 11010101 || 0x52485754 || 0x24024511 |- | ... || ... || ... || ... |- | ... || ... || ... || ... |- | 0x54462146 || 1001101 || 0x52495674 || 0x24987461 |} Une seconde solution utilise un BTB séparé, distinct du BTB pour les branchements directs. En séparant les deux, on a un BTB simple qui ne mémorise qu'une adresse de destination par branchement, et un BTB pour branchements indirects avec plusieurs adresses de destination par branchement. [[File:BTB avec prédiction de branchements indirects.png|centre|vignette|upright=2.5|BTB avec prédiction de branchements indirects.]] L'idée est que l'on économise des circuits à nombre d'entrées égales. Par exemple, comparons ce que cela donne avec 64 entrées. D'un côté, on a un BTB à 64 entrées, capable de gérer deux adresse de destination par branchement. En pratique, la majorité des branchements sont des branchements directs, qui n'utiliseraient qu'une seule case mémoire. Les circuits du BTB seraient donc sous-utilisés. De l'autre, on a un BTB à 48 entrées pour branchements directs, et un second BTB avec 16 entrées, capable de mémoriser deux adresses par branchements. Le premier BTB utilise moins de circuits, et est utilisé à pleine capacité, il ne gaspille pas de circuits pour mémoriser des adresses de destination vides. Par contre, on perd un peu en flexibilité. L'organisation précédente marche bien si 1/4 des branchements rencontrés sont des branchements indirects. Mais si le ratio est différent, la performance est légèrement sous-optimale. Attention, cela ne veut pas dire qu'il y aura des entrées vides dans un BTB, non. Simplement, le premier BTB risque de manquer de place pour les branchements directs récents, alors que les branchements indirects seront eux bien prédits, même les anciens. À l'opposé, un BTB unique permet de mémoriser les 64 derniers branchements, peu importe qu'ils soient indirects ou non. En clair : un BTB unique est plus flexible, ce qui donne un gain de performance dans certaines situations. Cependant, cet avantage théorique ne tient pas compte de l'économie en circuits, qui peut être utilisée pour faire grossir le BTB pour branchements directs. Par exemple, à budget à transistor équivalent, on a le choix entre BTB à 64 entrées uniques, et un BTB pour branchements directs de 80 entrées avec un BTB indirect de 16 entrées. Le fait qu'un BTB soit plus gros permet des gains de performances qui compensent la perte de flexibilité. On peut ainsi avoir le meilleur des deux monde, à savoir une économie de circuits et les gains en performance qui en découlent, malgré une petite perte de flexibilité. ===La prédiction de l'adresse de retour d'une fonction=== Presque tous les processeurs conçus après les années 2000 peuvent prévoir l'adresse à laquelle il faudra reprendre lorsqu'une fonction/sous-programme a fini de s’exécuter. Ils font de la '''prédiction de retour de fonction'''. Pour cela, ils possèdent pour une unité de prédiction de branchement spécialisée, appelée le ''return stack predictor'', ou '''''pile de prédiction de retour de fonction'''''. Le terme ''return stack predictor'' étant plus court, nous l'utiliserons dans la suite du cours. Le ''return stack predictor'' est capable de reconnaitre les instructions de retour de fonction, et de prédire l'adresse de retour. Pour comprendre comment elle fait, rappelez-vous le chapitre sur la pile d'appel. Dans ce chapitre, j'avais introduit le concept de ''pile d'adresse de retour'', à savoir une pile d'appel très simplifiée, qui ne contient que les adresses de retour des fonctions en cours d'exécution. Le ''return stack predictor'' est essentiellement composé d'une telle pile d'adresse de retour, avec des circuits pour empiler et dépiler les adresses de retour. Le ''return stack predictor'' doit reconstruire une pile d'adresses de retour, à partir du programme exécuté. La pile d'adresse est mémorisée dans le ''return stack predictor'', elle est totalement séparée de la pile d'appel normale. Le programme exécuté continue d'utiliser la même pile d'appel qu'avant, ce qui fait que les adresses de retour sont dispersées dans la pile d'appel, parfois dans des registres spéciaux du processeur. Mais cela ne pose pas de problèmes pour le processeur, qui reconstruit la pile d'adresse de retour avec l'aide du décodeur. La reconstruction se fait à partir des instructions d'appel et de retour de fonction. Une instruction d'appel de fonction empile une adresse de retour, une instruction de retour de fonction la dépile. Lorsque le décodeur d'instruction reconnait une instruction d'appel de fonction, il extrait l'adresse de retour et l'envoie au ''return stack predictor''. L'adresse de retour est alors empilée, ce qui fait qu'elle se retrouve au sommet de la pile d'adresse de retour. Dépiler une adresse de retour revient à prédire un branchement. Lorsque le ''return stack predictor'' détecte une instruction de retour de fonction, il dépile l'adresse au sommet de la pile. L'adresse est alors envoyée du cache d'instruction, pour l'ire l'instruction adéquate, et le ''program counter'' est mis à jour avec cette adresse. Toute la difficulté revient à détecter les instructions de retour de fonction, sans quoi on ne sait pas quand dépiler l'adresse. La documentation là-dessus n'est pas très fournie, il est difficile de savoir ce qui est fait dans les processeurs modernes. Mais on peut faire quelques suppositions. La première possibilité est l'usage du ''prédécodage'', une technique qui décode partiellement les instructions lors de leur chargement dans le cache d'instruction. Elle sera vue dans le chapitre suivant, mais parlons-en rapidement. Le prédécodage ajoute quelques bits au début de chaque instruction pour préciser si c'est un branchement conditionnel, inconditionnel, un retour de fonction. On peut ainsi savoir rapidement quand dépiler : dès qu'une instruction est lue dans le cache d'instruction, le ''return stack predictor'' vérifie les bits ajoutés et agit en fonction. La seconde possibilité est que le BTB reconnait l'instruction de retour de fonction. Pour chaque branchement, il mémorise non seulement son adresse de retour, mais aussi son type. Par type, on veut dire : si c'est un branchement conditionnel, inconditionnel, un retour de fonction. Les retours de fonction sont donc reconnus à condition qu'ils aient été exécutés auparavant. Il est aussi possible d'avoir un BTB spécialisé dans les retours de fonction, qui est alimenté par les décodeurs. Dès que le décodeur reconnait une instruction de retour de fonction, il ajoute son ''program counter'' dans ce BTB spécialisé. Une dernière possibilité est que le ''return stack predictor'' agit une fois l'instruction partiellement décodée. Le décodeur d'instruction reconnait les instructions de retour de fonction rapidement, en un cycle, et consulte alors le ''return stack predictor''. L'adresse est dépilée, et le décodeur sait où brancher. Il n'a pas besoin de devoir lire l'adresse de retour en mémoire, il la lit depuis le ''return stack predictor'', directement. La prédiction se fait donc assez tard dans le pipeline, quelques instructions ont été chargées à tort. Mais le décodeur peut les annuler, comme il le ferait avec un branchement inconditionnel mal prédit. ==La prédiction de branchement locale== La prédiction de branchement tente de prédire si un branchement sera pris ou non-pris, et décide quelle adresse envoyer au cache en fonction du résultat. Si on prédit qu'un branchement est non pris, on laisse le ''program counter'' faire son travail. À l'inverse si le branchement est prédit comme étant pris, l'adresse de destination est envoyée au cache d'instruction et au ''program counter''. La prédiction de branchement assigne une probabilité qu'un soit branchement soit pris en fonction de ces informations : si la probabilité est de plus de 50%, le branchement est considéré comme pris. La prédiction de branchement se base avant tout sur des statistiques, c’est-à-dire qu'elle détermine la probabilité d’exécution d'un branchement en fonction d'informations assez diverses. La prédiction de branchement n'a d'intérêt que si les prédictions sont suffisamment fiables pour valoir le coup. Rappelons que la pénalité lors d'une mauvaise prédiction est importante, d'autant plus coûteuse que le pipeline est long. Il est généralement admis qu'il faut au minimum un taux de réussite proche de 90%, voire plus sur les processeurs modernes. Heureusement, la plupart des branchements sont des '''branchements biaisés''', c’est-à-dire qu'ils sont presque toujours pris ou presque toujours non-pris, sauf en de rares occasions. De tels branchements font que la prédiction des branchements est facile, bien qu'ils posent paradoxalement quelques problèmes, comme nous le verrons plus bas. Un autre point important est que les unités de prédiction de branchement doivent être très rapides. Idéalement, elles doivent fournir leur prédiction en un seul cycle d'horloge. Un seul cycle d'horloge est un temps très court, surtout sur les ordinateurs avec une fréquence élevée. Les techniques de prédiction dynamique ne peuvent donc pas utiliser des méthodes statistiques extraordinairement complexes. Cela va à l'encontre du fait que le taux de mauvaises prédictions doit être très faible, idéalement inférieur à 1% sur les processeurs modernes. Vous comprenez donc aisément que concevoir une unité de prédiction de branchement est un véritable défi d’ingénierie électronique qui requiert des prouesses technologiques sans précédent. Suivant la nature des informations utilisées, on peut distinguer plusieurs types de prédiction de branchement : locale, globale, dynamique, statique, etc. Nous allons d'abord voir les méthodes statiques avant de voir les méthodes dynamiques. La '''prédiction locale''' prédit chaque branchement indépendamment les uns des autres. Elle sépare bien les statistiques/moyennes pour chaque branchement. Et c'est autant un point fort qu'un point faible. En effet, elle n'exploite pas les corrélations entre branchements, à savoir le fait que l'exécution d'un branchement a tendance à déborder sur les suivants. Dans ce qui suit, nous allons voir les techniques de prédiction locales. ===Les ''branch hints''=== Dans son implémentation la plus explicite, la prédiction est inscrite dans le branchement lui-même. Quelques bits de l'opcode du branchement précisent si le branchement est majoritairement pris ou non pris. Ils permettent d'influencer les règles de prédiction de branchement et de passer outre les réglages par défaut. Ces bits sont appelés des '''suggestions de branchement''' (''branch hint''). Mais tous les processeurs ne gèrent pas cette fonctionnalité. Et de plus, cette solution marche assez mal. La raison est que le programmeur ou le compilateur doit déterminer si le branchement est souvent pris ou non-pris, mais qu'il n'a pas de moyen réellement fiable pour cela. Une solution possible est d’exécuter le programme sur un ensemble de données réalistes et d'analyser le comportement de chaque branchement, afin de déterminer les suggestions de branchement adéquates, mais c'est une solution lourde et peu pratique, qui a de bonnes chances de donner des résultats peu reproductibles d'une exécution à l'autre. ===La prédiction statique de branchement=== La '''prédiction statique''' prédit le résultat du branchement en fonction de caractéristiques fixes du branchement lui-même, comme son adresse, son mode d'adressage, l'adresse de destination, la position du branchement en mémoire, si le branchement est direct ou indirect, etc. Les informations en question sont connues une fois le branchement décodé, il n'y a pas besoin de mémoriser la moindre information supplémentaire. Une première implémentation, assez évidente, fait la différence entre les branchements inconditionnels toujours pris, et les branchements conditionnels au résultat variable. L'idée est de prédire les branchements inconditionnels comme toujours pris alors que les branchements conditionnels sont prédits comme non-pris (ce qui est une approximation). L'implémentation est relativement simple : on utilise un BTB qui ne mémorise que les branchements inconditionnels, et basta ! Les branchements conditionnels sont simplement ignorés, traités comme n'importe quelle autre instruction. Cette méthode est particulièrement inefficace pour les branchements de boucle, où la condition est toujours vraie, sauf en sortie de boucle ! Il a donc fallu affiner légèrement l'algorithme de prédiction statique. Une implémentation plus subtile fait la différence entre les branchements des boucles et les autres. Reste à identifier ceux-ci d'une manière ou d'une autre. Et un bon moyen est de regarder si le branchement revient en arrière ou au contraire saute une portion de code. Un programme s'exécute des adresses basses vers les adresses hautes, les instructions vont naturellement dans le sens croissant des adresses. Un branchement de boucle fait revenir en arrière, histoire de répéter un morceau de code, pas les autres branchements (du moins pas forcément). Et cela nous amène à faire la distinction entre les branchements conditionnels ascendants et descendants. Un '''branchement conditionnel ascendant''' est un branchement qui demande au processeur de reprendre plus loin dans la mémoire : l'adresse de destination est supérieure à l'adresse du branchement. Un branchement '''conditionnel descendant''' a une adresse de destination inférieure à l'adresse du branchement : le branchement demande au processeur de reprendre en arrière dans la mémoire, avant le branchement. Les branchements ascendants sont rarement pris (ils servent dans les conditions de type SI…ALORS), contrairement aux branchements descendants (qui servent à fabriquer des boucles). On peut ainsi modifier l’algorithme de prédiction statique comme suit : * les branchements inconditionnels sont toujours pris ; * les branchements descendants sont toujours pris ; * les branchements ascendants ne sont jamais pris. L'implémentation demande d'élargir le BTB. Le BTB n'a pas seulement à mémoriser l'adresse du branchement et son adresse de destination, mais aussi le type du branchement. En général, il se contente de deux bits, qui indiquent si le branchement est conditionnel, inconditionnel, ou un retour de fonction. Les retours de fonction sont traités à part, histoire de profiter du ''return stack predictor''. {|class="wikitable" |+ BTB avec prédiction de branchement statique évoluée |- ! Adresse du branchement !! Adresse de destination !! Type du branchement |- | 0x4165485 || 0x14657445541 | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Retour de fonction |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... || ... |} ===La prédiction avec des compteurs à saturation=== Les méthodes de prédiction de branchement statique sont intéressantes, mais elles montrent rapidement leurs limites. Pour plus de performances, il faut utiliser la '''prédiction dynamique''', qui extrait des statistiques sur l’exécution des branchements. Pour le reste du chapitre, toutes les techniques utilisent la prédiction dynamique, qu'elles soient locales ou globales. Sa version la plus simple se contente de mémoriser ce qui s'est passé lors de l’exécution précédente du branchement. Si le branchement a été pris, alors on suppose qu'il sera pris la prochaine fois. De même, s'il n'a pas été pris, on suppose qu'il ne le sera pas lors de sa prochaine exécution. Pour cela, chaque entrée du BTB contient un bit qui mémorise la dernière exécution du branchement : 1 s'il a été pris, 0 s'il n'a pas été pris. C'est ce qu'on appelle la '''prédiction de branchements sur 1 bit'''. Au vu de sa simplicité, il ne faut pas s'attendre à des miracles. La méthode fonctionne bien pour les branchements des boucles et les branchements inconditionnels, mais elle n'est pas assez performante. Une forme plus élaborée de prédiction utilise des '''compteurs à saturation'''. Un compteur à saturation est un compteur qui est incrémenté à chaque fois qu'un branchement est pris, décrémenté s'il est non-pris. Le compteur est initialisé à la moitié de sa valeur maximale, ce qui correspond à une probabilité proche de 50%. Les compteurs à saturation sont très utilisés et pas seulement pour de la prédiction locale, les méthodes globales en utilisent aussi. Pour faire la prédiction, l'unité de prédiction regarde le bit de poids fort du compteur. Le branchement est considéré comme pris si ce bit de poids fort est à 1, et non pris s'il vaut 0. En clair : on considère que le branchement est pris si le compteur indique une probabilité supérieure à 50%. [[File:Compteur à saturation.png|centre|vignette|upright=2|Compteur à saturation.]] : Pour la culture générale : le compteur à saturation du Pentium 1 était légèrement bogué, ce qui fait que sa prédiction de branchement était sous-optimale. La plupart des processeurs utilisent un compteur à saturation par branchement, ce qui veut dire un compteur à saturation par entrée dans le BTB. Vu chaque compteur est associé à une entrée du BTB, le phénomène d’''aliasing'' mentionné plus haut peut perturber les prédictions de branchement. Deux branchements peuvent se voir associés à la même entrée du BTB, et agir sur le même compteur à saturation, ce qui entraine l'apparition d''''interférences entre branchements'''. L'interférence peut avoir des effets positifs, neutres ou négatifs, suivant la corrélation entre les deux branchements. L’''aliasing'' n'est pas un problème si les deux branchements sont fortement corrélés, par exemple si les deux sont tout le temps pris ou tout le temps non-pris. Si les deux branchements vont dans le même sens, l'interférence est neutre, voire légèrement positive. Mais si l'un est souvent pris et l’autre souvent non-pris, l'interférence est négative. Les deux branchements vont se marcher dessus sans vergogne, chacun interférant sur les prédictions de l'autre ! Il est rare que l’''aliasing'' ait un impact significatif sur les unités de prédiction des deux paragraphes précédents. Il se manifeste surtout quand la BTB est très petite et la seule solution est d'augmenter la taille du BTB. ===La prédiction avec un historique local=== La prédiction avec des compteurs à saturation est très simple, mais il est possible de créer une implémentation alternative, très proche, avec des avantages et désavantages différents. Un compteur à saturation enregistre l'historique d'un branchement, à savoir ses dernières exécutions. L''''historique''' d'un branchement dit quel a été son résultat dans le passé, pour ses exécutions précédentes. Et il est possible d'enregistrer cet historique et l'utiliser pour faire des prédictions. Par exemple, prenons un branchement sont ses 6 dernières exécutions sont "pris, non-pris, pris, pris, pris, non-pris". Le processeur peut encoder cet historique sur 6 bits. Un branchement pris correspond à un bit à 1, un branchement non-pris correspond à un 0. Le bit de poids fort correspond à l'exécution la plus ancienne, le bit de poids faible à l'exécution la plus récente. Par exemple, si l'historique contient 010, cela veut dire que le branchement a été non pris, puis pris, puis non pris. L'historique d'un branchement est appelé son '''historique local''', mais nous parlerons simplement l'historique. Le processeur peut mémoriser l'historique de chaque branchement. Pour cela, il réserve n bits par branchements, pour mémoriser ses n exécutions précédentes. L'historique d'un branchement est mémorisé dans son entrée du BTB. Et plus précisément dans un champ séparé qui est alimentée comme un registre à décalage. Reste à exploiter cet historique local pour faire des prédictions. Une idée assez simple serait de l'utiliser pour calculer une moyenne des N exécutions précédentes. Si la probabilité d'être pris est supérieure à 50 %, le branchement est considéré comme pris. Dans le cas contraire, il est considéré comme non pris. Le calcul de la moyenne utilise une simple porte à majorité, qui prend en entrée tous les bits de l'historique. Cependant, cette méthode est équivalente à utiliser un compteur à saturation par branchement. La sortie de la porte à majorité est équivalente à ce qu'on aurait dans un compteur à saturation. La seule différence : le cout en circuit et la simplicité de conception. Prenons l'exemple des premiers processeurs PA RISC. Ils mémorisaient, pour chaque branchement, les trois dernières exécutions du branchement, avec trois bits par entrée du BTB. La prédiction était réalisée avec une porte à majorité, dont le résultat était utilisé comme prédiction. Ces processeurs ont préféré utiliser un historique de 3 bits, car il était plus simple à concevoir qu'un compteur à saturation équivalent de deux bits. Il suffisait d'alimenter un registre à décalage avec un bit, pas besoin d'incrémenter ou de décrémenter un compteur, pas besoin de signaux d'incrémentation/décrémentation séparés, etc. Mais pour des historiques plus longs, la donne s'inverse et des compteurs à saturation de deux-trois bits l’emportent. Un défaut de la méthode précédente, qui est partagée avec les compteurs à saturation, est qu'elle marche mal pour les branchements qui suivent un ''pattern''. Je veux dire par là que l'exécution d'un branchement suit un cycle, par exemple pris, pris, non-pris, pris, pris, non-pris, et ainsi de suite en boucle. Notons que l'apparition de tels motifs correspond le plus souvent à la présence de boucles dans le programme. Quand une boucle s’exécute, les branchements qui sont à l'intérieur tendent à exprimer de tels motifs. Avec de tels motifs, les compteurs à saturation feront des prédictions incorrectes, là où les prédicteurs qu'on va voir font des prédictions parfaites. Par exemple, prenons un branchement qui suit le ''pattern'' pris, pris, pris, non-pris. Si l'historique local vaut 1110, le branchement est censé refaire une boucle et être pris. Et certes, on a supposé que l'historique faisait 4 bits. Mais le''pattern'' sera visible tant que l'historique fait plus de 4 bits. Par exemple, le ''pattern'' serait 01110 pour un historique local de 5 bits, 101110 pour un historique local de 6 bits, 1101110 pour un historique local de 7 bits, etc. Un ''pattern'' correspond donc à une valeur bien précise de l'historique local. L'historique local permet de voir ces cycles, reste à les exploiter. Prédire correctement les ''pattern'' demande simplement d'associer un compteur à saturation à chaque valeur possible de l'historique local. Le compteur à saturation dira, quand tel pattern est rencontré, si le branchement sera pris ou non-pris par la suite. Au total, il faut donc un registre d'historique local de n bits couplé à 2^n compteurs à saturation. Et ce pour chaque branchement possible. Les compteurs à saturation sont regroupés dans une '''''Pattern History Table''''', que nous noterons PHT dans ce qui suit. Le résultat est une '''unité de prédiction à deux niveaux locale''', qui contient plusieurs registres d'historique et tout autant de PHT locales. En tout, il y a une PHT et un historique par branchement, donc par entrée du BTB. Quand un branchement est exécuté, son historique local indique quel compteur à saturation choisir dans la PHT locale. En général, l'historique local est mémorisé dans le BTB, reste à gérer la PHT. [[File:Unité de prédiction à deux niveaux purement locale.png|centre|vignette|upright=2.5|Unité de prédiction à deux niveaux avec des historiques et des PHT locales.]] Une unité de prédiction locale à deux niveaux contient plusieurs PHT, reste à savoir quelle PHT correspond à tel branchement. La correspondance se fait idéalement à partir de l'adresse du branchement, mais c'est là une implémentation idéale, qui demande beaucoup de circuits. Généralement, l'unité de prédiction n'utilise que les bits de poids faible de l'adresse du branchement, ce qui permet de faire un choix correct, mais avec une possibilité d'''aliasing''. Il est aussi possible de séparer l'historique local du BTB. Les historiques locaux sont alors mémorisés dans une mémoire RAM dédiée, appelé la '''''Branch History Table''''', ou encore la '''table des historiques locaux''', que nous noterons BHT dans ce qui suivra. Chaque byte de la BHT contient l'historique local associé à un branchement. Pour récupérer l'historique local voulu, l'adresse du branchement est envoyée à la BHT, sur les entrées d'adresse de la BHT, directement. Seuls les bits de poids faible de l'adresse sont utilisés. L'historique local est ensuite envoyé à la PHT adéquate, choisie en fonction des mêmes bits de poids faible du PC. Un bon moyen pour cela est d'accéder à toutes les PHT en parallèle, mais de sélectionner la bonne avec un multiplexeur, ce dernier étant commandé par les bits de poids faible du PC. [[File:Table des historiques locaux.jpg|centre|vignette|upright=2|Table des historiques locaux]] Un défaut des unités de prédiction purement locales de ce type est leur cout en termes de circuits. Utiliser un registre d'historique et une PHT par branchement a un cout en transistor élevé, avec tous les défauts que ca implique en termes de consommation électrique et thermique. Elles ont aussi des temps de calcul un peu plus important que les unités purement globales, mais pas de manière flagrante. Les gains en performance avec un historique local s'observent surtout pour les branchements qui sont dans des boucles ou pour les branchements utilisés pour concevoir des boucles, alors que l'implémentation globale marche bien pour les if..else (surtout quand ils sont imbriqués). Les deux sont donc complémentaires. Dans les faits, elles sont rarement utilisées seules du fait de leurs défauts, au profit d'unités de prédiction hybrides, qui mélangent historiques et PHT locaux et globaux. ==La prédiction de branchement globale/mixte== Utiliser un historique local avec une PHT est très puissant. Mais le cout en circuit est très important. Cependant, on peut adapter l'idée pour faire de la prédiction globale. La '''prédiction globale''' fait des prédictions holistiques, qui tiennent compte de tous les branchements, sans les traiter séparément. Elle ne sépare pas les branchements, tous sont mis dans le même panier. La prédiction globale fait donc le compromis inverse : elle exploite les corrélations entre branchements, mais perd les informations statistiques précise pour chaque branchement. L'idée est qu'au lieu d'utiliser un historique par branchement, on utilise un '''historique global''' qui mémorise les exécutions de tous les branchements conditionnels. L'historique enregistre les N exécutions de branchements conditionnels. L'historique global est mémorisé dans un registre unique, appelé le '''registre d'historique global'''. C'est un registre à décalage mis à jour à chaque fois que le processeur exécute un branchement : on fait rentrer un 1 si le branchement est pris, et un 0 sinon. Une unité de prédiction globale utilise cet historique pour faire sa prédiction. L'unité de prédiction globale utilise un compteur à saturation pour chaque valeur possible du registre d'historique. En clair, pour un registre de N bits, elle utilise 2^n compteurs à saturation. Là encore, les compteurs à saturation sont regroupés dans une ''Pattern History Table''. Nous parlerons de '''PHT globale''' pour désigner une PHT commandée par un historique global, de ''PHT locale'' si elle est adressée par un historique local. [[File:Prédiction dynamique à deux niveaux.png|centre|vignette|upright=2|Prédiction dynamique à deux niveaux.]] Il est admis que l'historique global donne de meilleures prédictions que l'historique local. Mais les deux sont complémentaires, ce qui fait qu'il existe des approches hybrides. Les approches hybrides exploitent à la fois les statistiques pour chaque branchement et les corrélations entre branchements. Les prédicteurs hybrides ont de meilleures performances que les prédicteurs purement globaux ou locaux. Avec un historique global, la prédiction tient compte d'éventuelles corrélations entre branchements consécutifs/proches pour faire les prédictions. C'est un avantage pour les applications qui enchaînent des if...else ou qui contiennent beaucoup de if...else imbriqués les uns dans les autres, qui s’exécutent plusieurs fois de suite. Le résultat de chaque if...else dépend généralement des précédents, ce qui rend l'utilisation d'un historique global intéressant. Par contre, ils sont assez mauvais pour le reste des branchements. Mais cette qualité devient un défaut quand elle détecte des corrélations fortuites ou parasites, inutiles pour la prédiction (corrélation n'est pas causalité, même pour prédire des branchements). Du fait de ce défaut, la prédiction globale a besoin de registres d'historiques globaux très larges, de plusieurs centaines de bits au mieux. [[File:2 level branch predictor.svg|centre|vignette|upright=2|Unités de prédiction de branchement à deux niveaux avec une seule PHT.]] Le circuit précédent a cependant un problème : si deux branchements différents s'exécutent et que l'historique est le même, le processeur n'y verra que du feu. Il y a alors une interférence, à savoir que les deux branchements vont agir sur le même compteur à saturation. On se retrouve alors dans une situation conceptuellement identique à l’''aliasing'' dans le BTB ou avec des compteurs à saturation. Sauf que les interférences sont alors beaucoup plus nombreuses et qu'elles sont clairement un problème pour les performances ! Mitiger les interférences devient alors bien plus important qu'avec les unités de prédiction précédentes. Heureusement, si augmenter la taille de la PHT peut mitiger le problème, d'autres solutions sont possibles sur les unités de prédiction globales. Elles sont au nombre de deux : mitiger les interférences liées aux branchements biaisés, ajouter des informations qui discriminent les branchements. Voyons ces solutions dans le détail. ===La mitigation des interférences par usage de l'adresse de branchement=== La première solution pour réduire l'impact de l’''aliasing'' est d'augmenter la taille de la PHT et de l'historique. Plus l'historique et la PHT sont grands, moins l’''aliasing'' est fréquent. Mais avoir des historiques et des PHT de très grande taille n'est pas une mince affaire et le cout en termes de performances et de portes logiques est souvent trop lourd. Une autre solution consiste à utiliser, en plus de l'historique, des informations sur le branchement lui-même pour choisir le compteur à saturation à utiliser. Typiquement, on peut utiliser l'adresse du branchement en plus de l'historique, pour choisir le compteur à saturation adéquat. Pour être précis, on n'utilise que rarement l'adresse complète du branchement, mais seulement les bits de poids faible. La raison est que cela fait moins de bits à utiliser, donc moins de circuits, mais l’''aliasing'' est juste atténué, pas éliminé. Des branchements différents ont beau avoir des adresses différentes, les bits de poids faible de leurs adresses peuvent être identiques. Des confusions sont donc possibles, mais sont assez rares pour que cela suffise en pratique. Une unité de prédiction de ce type est illustrée ci-dessous. Il y a plusieurs PHT séparées, donc plusieurs ensembles de 2^n compteurs à saturation. Le choix de la PHT est réalisé par l'adresse du branchement, alors que le choix du compteur à saturation est réalisé par l'historique. Les compteurs à saturation d'un branchement sont mis à jour seulement quand le branchement s'est chargé pendant que l'historique associé était dans le registre d'historique. Cette méthode a cependant le défaut de gâcher énormément de transistors. [[File:Unité de prédiction de branchement basée sur des compteurs à saturation sélectionnés par l'historique global.jpg|centre|vignette|upright=2|Unité de prédiction de branchement basée sur des compteurs à saturation sélectionnés par l'historique global]] D'autres unités de prédiction fonctionnent sur le même principe que l'unité précédente, sauf que les différentes PHTs sont fusionnées en une seule grosse PHT. C'est ce que font les unités de prédiction '''''gshare''''' et '''''gselect''''', ainsi que leurs dérivées. Avec les unités de prédiction '''''gselect''''', on concatène les bits de poids faible de l'adresse du branchement et l'historique pour obtenir le numéro du compteur à saturation à utiliser. [[File:Prédiction « gselect ».png|centre|vignette|upright=2|Prédiction « gselect ».]] Avec les unités de prédiction '''''gshare''''', on fait un XOR entre le registre d'historique et les bits de poids faible de l'adresse du branchement. Le résultat de ce XOR donne le numéro du compteur à utiliser. Avec cette technique, on utilise une PHT globale et un historique global, mais le registre d'historique est manipulé de manière à limiter l'''aliasing''. [[File:Prédiction « gshare ».png|centre|vignette|upright=2|Prédiction « gshare ».]] Intuitivement, on pourrait croire que les unités de prédiction gselect ont de meilleures performances. C'est vrai que les unités gshare combinent l'adresse du branchement et l'historique d'une manière qui fait perdre un peu d'information (impossible de retrouver l'adresse du branchement et l'historique à partir du résultat du XOR), alors que les unités gselect conservent ces informations. Mais il faut prendre en compte le fait que les deux unités doivent se comparer à PHT identique, de même taille. Prenons par exemple une PHT de 256 compteurs, adressée par 8 bits. Avec une unité gselect, les 8 bits sont utilisés à la fois pour l'historique et pour les bits de poids faible de l'adresse du branchement. Alors qu'avec une unité gshare, on peut utiliser un historique de 8 bits et les 8 bits de poids faible de l'adresse du branchement. L'augmentation de la taille de l'historique et du nombre de bits utilisés fait que l’''aliasing'' est réduit, et cela compense le fait que l'on fait un XOR au lieu d'une concaténation. Les unités précédentes réduisent l'''aliasing'' en segmentant la PHT : telles entrées sont allouées à un branchement, telles entrées à tel autre branchement, etc. Cependant, l'historique local n'est pas utilisé. Mais quitte à allouer des entrées à un branchement, autant en profiter pour leur associer son historique local. Il s'agit alors d'approches hybrides. Par exemple, on peut citer la '''prédiction mixte''' (''alloyed predictor''). Avec celle-ci, la table des compteurs à saturation est adressée avec la concaténation : de bits de l'adresse du branchement, du registre d'historique global et du registre d'historique local adressé par l'adresse du branchement. [[File:Prédiction mixte.jpg|centre|vignette|upright=2|Prédiction mixte.]] Une version optimisée de ce circuit permet d’accéder à la PHT et à la BHT en parallèle. Avec elle les bits de poids faible de l'adresse du branchement sont concaténés avec l'historique global. Le résultat est ensuite envoyé à plusieurs PHT locales distinctes, en parallèle. Les différences PHT envoient leurs résultats à un multiplexeur, commandé par l'historique local, qui sélectionne le résultat adéquat. L'inconvénient de cette mise en œuvre est que le circuit est assez gros, notamment en raison de la présence de plusieurs PHT séparées. [[File:Alloyed predictor optimisé.jpg|centre|vignette|upright=2|''Alloyed predictor'' optimisé]] ===La mitigation des interférences par la structuration en caches des PHTs=== Les techniques précédentes utilisent les bits de poids faible de l'adresse pour sélectionner la PHT adéquate, ou pour sélectionner le compteur dans la PHT. Mais le reste de l'adresse n'est pas utilisé. Cependant, il est théoriquement possible de conserver les bits de poids fort, afin d'identifier le branchement associé à un compteur. Pour cela, chaque compteur à saturation se voit associé à un ''tag'', qui mémorise le reste de l'adresse du branchement associé au compteur, comme pour les mémoires caches. Lorsque l'unité de prédiction démarre une prédiction, elle récupère les bits de poids fort de l'adresse du branchement et compare ceux-ci avec le ''tag''. S’il y a un ''match'', c'est signe que le compteur est bien associé au branchement à prédire. Dans le cas contraire, c'est signe qu'on fait face à une situation d’''aliasing'' et le compteur n'est pas utilisé pour la prédiction. Le résultat transforme la PHT en une sorte de cache assez particulier, qui associe un compteur à saturation à chaque couple adresse-historique. La technique du paragraphe précédent peut être encore améliorée afin de réduire l’''aliasing''. L’''aliasing'' sur une unité de prédiction de branchement est similaire aux conflits d'accès au cache, où deux données/adresses atterrissent dans la même ligne de cache. Une PHT peut formellement être vue comme un cache directement adressé. Une solution pour limiter ces conflits d'accès à de tels, est de les transformer en un cache associatif à n voies. On peut faire la même chose avec les unités de prédiction de branchement, ce qui revient à dupliquer les PHT, de manière à ce que les bits de poids faible de l'adresse se voient attribuer à plusieurs compteurs à saturation, un par branchement possible. Ce faisant, on réduit les interférences entre branchements. Mais cette technique augmente la quantité de circuits utilisés et la consommation d'énergie, ainsi que le temps d'accès, sans compter qu'elle requiert d'utilisation de ''tags'' pour fonctionner à la perfection. Pour les unités de prédiction de branchement, les mesures à ce sujet ne semblent pas montrer un impact réellement important sur les performances, comparé à une organisation de type directement adressé. Il est possible de pousser la logique encore plus loin en ajoutant des caches de victime et d'autres mécanismes courant sur les caches usuels. ===La mitigation des interférences liées aux branchements biaisés=== L’''aliasing'' a un impact négatif quand deux branchements aliasés (qui sont attribués au même compteur à saturation) vont dans des sens différents : si l'un est non-pris, l'autre a de bonnes chances d'être pris (ou inversement). Or, la plupart des branchements sont biaisés, à savoir très fortement orientés dans une direction qu'une autre (presque toujours pris ou presque toujours non-pris, à plus de 90/99% de chances). Plus un branchement a une probabilité élevée d' être pris ou non-pris, plus sa capacité à interférer avec les autres est forte. Le pire des cas est quand deux branchements sont biaisés mais dans des directions inverses : l'interférence est maximale. En conséquence, les branchements biaisés ou quasi biaisés sont les plus problématiques pour l’''aliasing''. Or, il est possible de concevoir des prédicteurs de branchements qui atténuent fortement les interférences des branchements biaisés ou quasi biaisés. C’est le cas de l’''agree predictor'' et de l'unité de prédiction bimodale, que nous allons voir dans ce qui suit. Les branchements très souvent pris vont interférer très fortement avec les branchements très souvent non-pris, et inversement. Par contre, les branchements très souvent pris n'interféreront pas entre eux, de même que les branchements non-pris iront bien ensemble. D'où l'idée de séparer ces deux types de branchements dans des PHT séparées, pour éviter le mauvais ''aliasing''. L''''unité de prédiction bimodale''' part d'une unité gshare, et sépare la PHT globale en deux : une PHT dédiée aux branchements qui sont très souvent pris et une autre pour les branchements très peu pris. Les deux PHT fournissent chacune une prédiction. La bonne prédiction est choisie par un multiplexeur commandé par une unité de sélection, qui se charge de déduire quelle unité a raison. Cette unité de sélection est une unité de prédiction basée sur des compteurs à saturation de 2 bits. On peut encore améliorer l'unité de sélection de prédiction en n'utilisant non pas des compteurs à saturation, mais une unité de prédiction dynamique à deux niveaux, ou toute autre unité vue auparavant. [[File:Prédiction bimodale.jpg|centre|vignette|upright=2|Prédiction bimodale.]] La '''prédiction par consensus''' (''agree predictor'') combine une unité de prédiction à historique global (idéalement de type gshare ou gselect, mais une unité normale marche aussi) et des compteurs à saturation. Il s'agit techniquement d'une technique de prédiction mixte, à la fois globale et locale. Le BTB intègre un compteur à saturation de un bit, qui indique la direction privilégiée du branchement. L'unité à historique global a un fonctionnement changé : elle calcule non pas la probabilité que le branchement soit pris ou non-pris, mais la probabilité que le résultat du branchement soit compatible avec le résultat de l'unité de prédiction de 1 bit. La prédiction finale vérifie que ces deux circuits sont d'accord, en faisant un NXOR entre les résultats des deux unités de prédiction de branchement. [[File:Prédiction par consensus.png|centre|vignette|upright=2|Prédiction par consensus.]] Des calculs simples de probabilités montrent que l'''agree predictor'' a des résultats assez importants. Ses résultats sont d'autant meilleurs que les branchements sont biaisés et penchent fortement vers le côté pris ou non-pris. ==La prédiction de branchement avec des perceptrons== Les méthodes précédentes utilisent au moins un compteur à saturation par historique possible. Sachant qu'il y a 2^n valeurs possibles pour un historique de n bits, le nombre de compteurs à saturation augmente exponentiellement avec la taille de l'historique. Ce qui limite grandement la taille de l'historique, alors qu'on aurait au contraire besoin d'un historique assez large pour faire des prédictions excellentes. Pour contourner le problème, des alternatives basées sur des techniques d'apprentissage artificiel (''machine learning'') existent. Un exemple est celui des unités de prédiction de branchement dites de ''neural branch prediction'', basée sur des perceptrons. Dans ce qui suit, nous les appelerons des '''unités perceptron'''. Les perceptrons sont un algorithme d'apprentissage automatique très simple, ce qui fait que les explications qui vont suivre devraient être simples. En théorie, il est possible d'utiliser des techniques d'apprentissage automatique plus élaborées, mais cela n'est pas possible en pratique. Rappelons qu'une unité de prédiction de branchement doit idéalement fournir sa prédiction en un cycle d'horloge. L'implémentation d'un perceptron est déjà problématique de ce point de vue, car elle demande d'utiliser beaucoup de circuits arithmétiques complexes (multiplication et addition). Autant dire que les autres techniques de ''machine learning'', encore plus gourmandes en calculs, ne sont pas praticables. ===Les perceptrons et leur usage en tant qu'unité de prédiction de branchements=== L'idée derrière les unités perceptron est que certains bits de l'historique sont plus importants que d'autres. En soi, ce genre de situation est réaliste. Il arrive que certains branchements soient pris uniquement si les deux branchements précédents ne le sont pas, ou que si l'avant-dernier branchement est lui aussi pris. Mais les autres résultats de branchement présents dans l'historique ne sont pas utiles. En conséquence, deux historiques semblables, qui ne sont différents que d'un ou deux bits, peuvent donner des prédictions presque identiques. Dans ce cas, on peut faire la prédiction en n'utilisant que les bits identiques entre ces historiques, ou du moins en leur donnant plus d'importance qu'aux autres. On exploite alors des corrélations avec certains bits de l'historique, plutôt qu'avec l'historique entier lui-même. Pour coder ces corrélations, on associe un coefficient à chaque bit de l'historique, qui indique à quel point ce bit est important pour déterminer le résultat final. Ce coefficient est proportionnel à la corrélation entre le bit de l'historique associé, et le résultat du branchement à prédire. Les coefficients sont des nombres entiers qui peuvent être positifs, nuls ou négatifs. Un coefficient positif signifie que si le branchement associé a été pris, alors le branchement à prédire a de bonnes chances de l'être aussi (et inversement). Un coefficient nul signifie que les deux branchements sont indépendants et que le bit de l'historique associé n'est pas à prendre en compte. Pour un coefficient négatif, cela signifie que si le branchement associé a été pris, alors le branchement à prédire a de bonnes chances d'être non-pris (et inversement). Les coefficients en question sont généralement compris entre -1 et 1. Une fois qu'on connait ces coefficients de corrélations, on peut calculer la probabilité que le branchement à prédire soit pris, avec des calculs arithmétiques simples. Dans les calculs qui vont suivre, un branchement pris est codé par un 1, alors qu'un branchement non-pris est codé par un -1 ! Ce détail permet de simplifier grandement les calculs. Dans sa version la plus simple, le perceptron calcule un nombre Z qui est positif ou nul si le branchement est pris, négatif s'il ne l'est pas. Tous les coefficients <math>p_i</math> sont alors des nombres relatifs, pouvant être positifs, nuls ou négatifs. Ils sont généralement compris entre -1 et 1. La formule devient donc celle-ci : : <math>Z = w_0 + \sum_i (h_i \times w_i)</math> [[File:ArtificialNeuronModel francais.png|centre|vignette|upright=2|Perceptron.]] : Sur le plan mathématique, le perceptron effectue un produit scalaire entre deux vecteurs : l'historique et le vecteur des poids. Choisir les coefficients adéquats est le but de l'algorithme du perceptron. L'unité de prédiction part de poids par défaut, qui sont mis à jour à chaque bonne ou mauvaise prédiction. Toute la magie de cet algorithme tient dans la manière dont sont mis à jour les poids. L'idée est de prendre le vecteur des poids, puis d'ajouter ou soustraire l'historique suivant le résultat du branchement. Les poids évoluent ainsi à chaque branchement, améliorant leurs prédictions d'une exécution à l'autre. La règle de mise à jour des poids est donc la suivante : : <math>w_i \leftarrow w_i + t \times h_i</math>, avec t = 1 pour un branchement pris, -1 pour un branchement non-pris. ===L'implémentation matérielle des perceptrons et ses optimisations=== Les calculs réalisés par un perceptrons sont simples, juste des multiplications et des additions et cela se sent dans la conception du circuit. L'unité perceptron utilise un perceptron par entrée du BTB, un par branchement pour simplifier. Pour cela, on utilise une mémoire SRAM qui mémorise les poids de chaque perceptron. La SRAM est adressée par les bits de poids faible du ''program counter'', de l'adresse du branchement. Une fois les poids récupérés, ils sont envoyés à un circuit de calcul de la prédiction, composé de circuits multiplieurs suivis par un additionneur multi-opérande. Le circuit de mise à jour des poids récupère la prédiction, les poids du perceptron, mais aussi le résultat du branchement. La mise à jour étant une simple addition, le circuit est composé d'additionneurs/soustracteurs, couplés à des registres pour mémoriser la prédiction et les poids du perceptron en attendant que le résultat du branchement soit disponible. Vu qu'il y a un délai de quelques cycles avant que le résultat du branchement soit disponible et que les prédictions doivent continuer pendant ce temps, les poids du perceptron et la prédiction sont stockés dans des FIFOs lues/écrites à chaque cycle. [[File:Unité de prédiction de branchement basée sur des perceptrons.png|centre|vignette|upright=2|Unité de prédiction de branchement basée sur des perceptrons]] Rien de bien compliqué sur le principe, mais rappelons que le perceptron doit fournir un résultat en moins d'un cycle d'horloge, et cela tient compte de l'accès au BTB. C'est un temps très court, surtout pour un circuit qui implique des multiplications. Les multiplications prennent facilement deux à trois cycles d'horloge. Autant dire que si on ajoute le temps d'accès à une petite SRAM, pour récupérer le perceptron, c'est presque impossible. Mais quelques optimisations simples permettent de rendre le calcul plus rapide. Premièrement, la taille de la SRAM est réduite grâce à quelques économies assez simples. Notamment, le perceptron utilise des poids codés sur quelques bits, généralement un octet, parfois 7 ou 5 bits, guère plus. Les poids sont encodés en complément à 1 ou en complément à 2, là où les perceptrons normaux utilisent des nombres flottants. Rien que ces deux choix simplifient les circuits et les rendent plus rapides. Le désavantage d'utiliser peu de bits par poids est une perte mineure en termes de taux de prédiction, qui est plus que compensée par l'économie en portes logiques, qui permet d'augmenter la taille de la SRAM. D'autres optimisations portent sur le circuit de calcul. Par exemple, la multiplication <math>h_i \times w_i</math> est facultative. Vu que l'historique contient les valeurs 1 et -1, il suffit d'additionner le poids associé si la valeur est 1 et de le soustraire s'il vaut -1. On peut même simplifier la soustraction et se limiter à une complémentation à un (une inversion des bits du poids). En faisant cela, les circuits multiplieurs disparaissent et le circuit de prédiction se résume à un simple additionneur multi-opérande couplé à quelques inverseurs commandables. Certains perceptrons prennent en compte à la fois l'historique global et l'historique local pour faire leur prédiction. Les deux historiques sont simplement concaténés et envoyés en entrée du perceptron. Cela marche assez bien, car les perceptrons peuvent utiliser des historiques de grande taille sans problèmes. Par contre, il y a besoin d'ajouter une ''branch history table'' au circuit, ce qui demande des portes logiques en plus, mais aussi un temps de calcul supplémentaire (l'accès à cette table n'est pas gratuit). ===Les avantages et inconvénients des perceptrons pour la prédiction de branchements=== L'avantage des unités perceptron est qu'elles utilisent un nombre de portes logiques qui est proportionnel à la taille de l'historique, et non pas exponentiel. Cela permet d'utiliser un historique de grande taille, et donc d'obtenir un taux de bonnes prédictions très élevé. Leur désavantage est que leurs performances de prédiction sont moins bonnes à historique équivalent. Les unités perceptrons ont des taux de prédiction élevés, mais seulement quand certaines conditions mathématiques sont respectées par les historiques. La condition en question est que les historiques correspondants à un branchement pris et les historiques où ce même branchement est non-pris doivent être linéairement séparables. Heureusement, les branchements des programmes informatiques sont une majorité à respecter la condition de séparation linéaire, ce qui rend les perceptrons parfaitement adaptés à la prédiction de branchements. La notion de linéairement séparable est un peu difficile à comprendre. Pour l'expliquer, imaginez que les N bits de l'historique forme un espace à N-dimensions discrets. Chaque historique est un point dans cet espace N-dimensionnel et chaque bit sert de coordonnée pour se repérer dans cet espace. Une fonction est linéairement séparable si l'espace N-dimensionnel peut être coupé en deux par un hyperplan : d'un côté de ce plan se trouvent les historiques des branchements pris, de l'autre se trouvent les historiques des branchements non-pris. Cet hyperplan est défini par l'ensemble des points qui respecte l'équation suivante : : <math>w_0 + \sum_i (h_i \times w_i) = 0</math> Un exemple où cette condition est respectée est quand le résultat d'une prédiction se calcule avec un ET entre plusieurs branchements de l'historique. Si un branchement est pris quand plusieurs branchements de l’historique sont tous pris ou tous non-pris, la condition est respectée et le perceptron donnera des prédictions correctes. Un exemple où cette condition n'est pas respectée est un banal XOR. Prenons l'exemple d'un historique global de 2 bits. Imaginons que le branchement à prédire soit pris : soit quand le premier branchement de l'historique est pris et le second non-pris, soit quand le premier est non-pris et le second pris. Concrètement, la prédiction exacte se calcule en faisant un XOR entre les deux bits de l'historique. Dans ce cas, la condition "linéairement séparable" n'est pas respectée et le perceptron n'aura pas des performances optimales. Les unités perceptron utilisent idéalement un perceptron par branchement. Cela demande d'associer, l'adresse du branchement pour chaque perceptron, généralement en donnant un perceptron par entrée du BTB. Mais une telle organisation n'est pas possible en pratique, car elle demanderait d'ajouter un ''tag'' à chaque perceptron/entrée du BTB, ce qui demanderait beaucoup de circuits et de ressources matérielles. Dans les faits l'association entre un branchement et un perceptron se fait en utilisant les bits de poids faible de l'adresse de branchement. Mais faire cela entrainera l'apparition de phénomènes d'''aliasing'', comme pour les unités de prédiction à deux niveaux. Les techniques vues précédemment peuvent en théorie être adaptées sur les unités à perceptrons, dans une certaine mesure. Notons que les perceptrons sont des algorithmes ou des circuits qui permettent de classer des données d'entrée en deux types. Ici, la donnée d'entrée est l'historique global et la sortie du perceptron indique si le branchement prédit est pris ou non-pris. Ils ne sont donc pas adaptés à d'autres tâches de prédiction, comme pour le préchargement des lignes de cache ou la prédiction de l'adresse de destination d'un branchement, ou toute autre tâche d'exécution spéculative un tant soit peu complexe. Leur utilisation reste cantonnée à la prédiction de branchement proprement dit, guère plus. Par contre, les autres techniques vues plus haut peuvent être utilisées pour le préchargement des lignes de cache, ou d'autres mécanismes de prédiction plus complexes, que nous aborderons dans la suite du cours. ==La prédiction avec plusieurs unités de branchement distinctes== Il est possible de combiner plusieurs unités de prédiction de branchement différentes et de combiner leurs résultats en une seule prédiction. On peut par exemple combiner une unité de prédiction spécialisée dans les boucles, une unité de prédiction 2 bits basée sur des compteurs à saturation, une unité de prédiction statique et une unité ''gshare''. Tout le problème est de décider quelle unité a fait la bonne prédiction, comment combiner les résultats des différentes unités de prédiction. Soit on choisit un résultat qui parait plus fiable que les autres, soit on combine les résultats pour faire une sorte de moyenne des prédictions. La méthode la plus simple de choisir le résultat d'une unité de prédiction parmi toutes les autres. Pour implémenter le tout, il suffit d'un multiplexeur qui effectue le choix de la prédiction. Reste à commander le multiplexeur, ce qui demande un '''circuit méta-prédicteur''' qui sélectionne la bonne unité de prédiction. Il arrive que le circuit méta-prédicteur soit conçu avec les mêmes techniques que les unités de prédiction de branchement elle-mêmes. Dans le cas le plus simple, il s'agit d'un simple compteur à saturation, mis à jour suivant la concordance des prédictions avec le résultat effectif du branchement. Mais c'est loin d'être systématique. Une autre solution combine le résultat des différentes unités de prédiction avec une fonction mathématique adéquate. Avec un nombre impair d'unités de prédiction, une méthode assez efficace est de simplement prendre le résultat majoritaire. Si une majorité d'unités pense que le branchement est pris, alors on le considère comme pris, et comme non pris dans le cas contraire. Par contre, avec un nombre pair d'unités, cette méthode simple ne marche pas, il y a un risque d'égalité. L'unité de prédiction de branchement « '''''e-gskew''''' » utilise trois unités gshare et combine leurs résultats avec un vote à majorité. Les trois unités gshare utilisent des XOR légèrement différents, dans le sens où les bits de l'adresse du branchement choisi ne sont pas les mêmes dans les trois unités, sans compter que le résultat du XOR peut subir une modification qui dépend de l'unité de prédiction choisie. Autrement dit, la fonction de hachage qui associe une entrée à un branchement dépend de l'unité. [[File:Unité de prédiction « e-gskew ».png|centre|vignette|upright=2|Unité de prédiction « e-gskew ».]] Enfin, une dernière solution est que certaines unités de prédiction ont la priorité sur toutes les autres. Par exemple, prenons un processeur avec un ''return stack predictor'', une unité qui détecte les boucles et une unité de prédiction avec un historique. Si le ''return stack predictor'' détecte une instruction de retour de fonction, sa prédiction passera avant toutes les autres unités de prédiction. Et dans ce qui va suivre, c'est cette solution qui sera utilisé pour les unités de prédiction qu'on va étudier. ===La prédiction des branchements des boucles=== Les boucles sont à part, pour ce qui est de la prédiction de branchement. Rappelons qu'une boucle FOR/WHILE répète une série d'instruction N fois, le nombre N étant appelé le '''compteur de boucle'''. Ce qui fait que les branchements d'une boucle FOR sont pris n − 1 fois, la dernière exécution étant non prise. La conséquence est que les unités de prédiction usuelles sont assez mauvaises pour prédire les branchements des boucles. Par contre, la prédiction statique marche particulièrement bien comparé au reste. La prédiction statique prédit que les branchements des boucles sont toujours pris, ce qui donne des prédictions qui sont d'autant meilleures que la boucle est répétée un grand nombre de fois. Reste à gérer les branchements des boucles au mieux, en sachant cela. L'idée est d'utiliser plusieurs unités de prédiction séparées : une spécialisée dans les boucles et d'autres unités de prédiction. Uen unité de méta-prédiction se charge d'activer l'unité de prédiction des boucles dès qu'une boucle est détéctée. Les unités de prédiction modernes essayent de détecter les branchements des boucles afin de les mettre à part. En soi, ce n'est pas compliqué les branchements des boucles sont presque toujours des branchements descendants et réciproquement. De plus, ces branchements sont pris à chaque répétition de la boucle, et ne sont non-pris que quand on quitte la boucle. Certaines unités de prédiction de branchement sont capables de prédire spécifiquement les branchements de boucles FOR imbriquées, à savoir où une boucle FOR est dans une autre boucle FOR. Concrètement, cela permet de répéter la première boucle FOR plusieurs fois de suite, souvent un grand nombre de fois. Avec les boucles imbriquées, le compteur de boucle peut changer d'une répétition à l'autre, mais ce n'est que rarement le cas. Dans de nombreux cas, le compteur de boucle reste le même d'une répétition à l'autre. Ce qui fait qu'une fois la boucle exécutée une première fois, on peut prédire à la perfection les exécutions suivantes. Pour cela, il suffit de déterminer le compteur de boucle après la première exécution de la boucle, puis de le mémoriser dans un compteur. Lors des exécutions suivantes de la boucle, on compte le nombre de fois que le branchement de la boucle s'exécute : il est prédit comme pris tant que le compteur est différent du compteur de boucle, mais non-pris en cas d'égalité. Le compteur de boucle analyse l'exécution des branchements, pour détecter les branchements des boucles. Un branchement est considéré comme un branchement de boucle si : il est pris N fois, avant de ne pas être pris une fois. Au moment où le branchement n'est plus pris, le ''Loop Predictor'' lui attribue une entrée dédié. L'entrée mémorise mémorise le nombre de fois que ce branchement a été pris, à savoir N, et est associée à un compteur. Le compteur est incrémenté/décrémenté à chaque exécution du branchement, et sa valeur est comparée à la valeur N. Quand le compteur atteint N+1, il est réinitialisé. Il s'agit d'une implémentation simple, mais est visiblement proche de celle utilisée sur le Pentium M, si j'en crois l'article "The IntelÆ PentiumÆ M Processor : Microarchitecture and Performance". ===Le prédicteur L-TAGE=== Il est possible d'utiliser plusieurs unités de prédiction, chacune utilisant des historiques de taille différente. Pour donner un exemple simple, on pourrait avoir une unité avec un historique de 2 bits, une autre avec un historique de 3 bits, une autre de 4 bits, etc. Une méta-unité de branchement prédit alors quelle taille d'historique donne les meilleurs résultats. Il est fréquent que les tailles d'historiques utilisées suivent une formule mathématique précise, appelée suite géométrique, ce qui est un mot bien barbare pour dire que l'on passe d'un historique au suivant en multipliant sa taille par une constante bien précise. Par exemple, on pourrait avoir des historiques de taille 2, 4, 8, 16, 32. C'est d'ailleurs le principe derrière les '''unités de branchement TAGE (''TAgged GEometric length predictor'')'''. Elles utilisent plusieurs registres d'historiques, chacun avec sa propre PHT, dont la taille suit une suite géométrique. Avec cette méthode, les branchements qui ont un motif répétitif sont alors parfaitement détectés, peu importe sa taille. Par exemple, un branchement avec un historique dont le cycle est pris, non-pris, non-pris, sera parfaitement prédit avec un historique de 3 bits, 6 bits, ou de 3*n bits, mais pas forcément avec un historique de 4 ou 8 bits. De ce fait, utiliser plusieurs unités de branchement avec plusieurs tailles d'historique fonctionne plutôt bien. Le prédicteur TAGE a été raffiné avec le temps, sur plusieurs points, que je ne vais pas détailler dans ce qui suit. Précisons cependant que les PHT sont structurées en caches, afin d'éliminer les phénomènes d'''aliasing''. L'ajout d'une unité de prédiction pour les boucles, a donné le '''prédicteur L-TAGE''', qui a lui-même été amélioré par la suite. Les unités de branchement TAGE sont les plus utilisées au moment où j'écris ces lignes (2026), et la majorité des processeurs x86 commerciaux utilisent une variante de ce prédicteur, à quelques exceptions près. ===La prédiction de branchement à deux niveaux=== Les processeurs modernes utilisent une prédiction de branchement à deux niveaux. Une première unité de branchement fournit des résultats rapides et peu précis, la seconde a des résultats plus précis mais fournit ses résultats après quelques cycles d'horloge. La première unité envoie les adresses prédites au cache d'instruction, pour charger les instructions prédites et met à jour le ''program counter'' en conséquence. La seconde envoie les adresses prédites à l'étage de décodage d'instruction, histoire d'annuler des branchements considérés à tort comme pris/non-pris. Il y a donc une unité de prédiction rapide, dont le résultat est corrigé par une seconde unité. La seconde unité a le dernier mot, elle corrige ou annule les prédictions de la première. Ce qui lui vaut le nom, en anglais, d'''overiding predictor''. La correction se fait simplement en corrigeant le ''program counter'' et en forçant le décodeur à émettre des NOPs pendant quelques cycles, le temps d'éliminer les instructions chargées à tort. [[File:Double BTB sur les CPU modernes.png|centre|vignette|upright=2|Double BTB sur les CPU modernes.]] Les deux unités de prédiction n'utilisent pas le même algorithme. Par exemple, la microarchitecture Silvermont d'Intel utilisait deux unités de prédiction : une unité rapide basée sur des compteurs à saturation, et un prédicteur ''gshare'' lent. Les deux unités de prédiction utilisent des BTB séparés, de taille différente, au moins deux. Vu que les BTB sont des caches, on les appelle des '''BTB de niveau L1 et L2'''. Par exemple, les processeurs Bulldozer d'AMD disposent de deux BTBs : un BTB L1 de 512 entrées, et un BTB L2 de 5120 entrées. Le BTB de niveau L2 est souvent un cache de victime, qui mémorise les branchements retirés de la BTB de niveau L1. La BTB pour les branchements indirects vient en plus de ces deux BTBs, elle complète souvent la BTB de niveau L2. Le ''return stack predictor'' vient en plus des BTB L1 et L2, mais sa petite taille et sa rapidité font qu'il complète l'unité/BTB de niveau L1. Quelques processeurs ont trois niveaux de BTB, avec un niveau L0, L1 et L2. La BTB L0 aliment le cache d'instruction, alors que la L1 et la L2 préviennent les décodeurs d'instruction. La différence entre la L1 et la L2 est que la L2 met plus de temps à prévenir les décodeurs. Les branchements peuvent donc être annulés/forcés à deux étages de décodage différents. Pour donner quelques exemples, les processeurs Zen d'AMD disposent de plusieurs BTBs, avec des niveaux L0, L1 et L2. Le tableau ci-dessous vous donne la taille et la latence de chaque niveau de BTB. La BTB de niveau L0 alimente le cache d'instruction, la L1 permet d'annuler les instructions au premier cycle de décodage, la BTB L2 annule les instructions vers le milieu/fin du décodage. {|class="wikitable" |- ! rowspan="3" | Zen 2 | 16 || 0 |- | 512 || 1 |- | 7168 || 4 |- ! rowspan="3" | Zen 3 | 16 || 0 |- | 1024 || 1 |- | 6656 + 1536 (branchements indirects)|| 4 |} ==Les optimisations de la prédiction de branchements== La prédiction de branchement est complexe et toute économie est bonne à prendre. Pour améliorer les performances, ou simplement améliorer l'utilisation du BTB et des PHT, diverses techniques ont été inventées. Ces techniques marchent quel que soit l'unité de prédiction prise en compte. Elles peuvent s'appliquer aussi bien aux unités basées sur des perceptrons, que sur des unités à deux niveaux ou des unités de prédiction dynamique en général. Ces techniques sont assez variés : certaines profitent du fait que de nombreux branchements sont biaisés, d'autres tentent de réduire les interférences entre branchements sans utiliser l'historique global/local, etc. ===Le filtrage de branchements=== Une première optimisation permet d'économiser l'usage des différentes ressources matérielles, comme la BTB ou les PHT, en les réservant aux branchements non-biaisés. Idéalement, les branchements biaisés peuvent être prédits en utilisant des techniques très simples. D'où l'idée d'utiliser deux unités de prédiction spécialisées : une pour les branchements biaisés et une autre plus complexe. L'unité de prédiction pour les branchements biaisés est une unité de prédiction à 1 bit qui indique si le branchement est pris ou non, ce qui suffit largement pour de tels branchements. Cette technique s'appelle le '''filtrage de branchement''' et son nom est assez parlant. On filtre les branchements biaisés pour éviter qu'ils utilisent des PHT et d'autres ressources qui gagneraient à être utilisées pour des branchements plus difficiles à prédire. Appliquer cette idée demande de reconnaître les branchements fortement biaisés d'une manière ou d'une autre, ce qui est plus facile à dire qu'à faire. Une solution similaire enregistre quels branchements sont biaisés dans le BTB, et utilise cette information pour faire des prédictions. ===L'usage de fonctions de hashage pour indexer les diverses SRAM/tables=== Plus haut, nous avons vu que les unités de prédiction de branchement contiennent des structures qui sont adressées par l'adresse de branchement. Tel est le cas du ''Branch Target Buffer'', de certaines PHT globales ou locales, de la ''Branch History Table'' qui stocke les historiques locaux, ou encore de la SRAM des poids des unités à base de perceptrons. En pratique, ces structures sont adressées non pas par l'adresse de branchement complète, mais pas les bits de poids faible de cette adresse. Cela a pour conséquence l'apparition d'un ''aliasing'' lié au fait que ces structures vont confondre deux branchements pour lesquels les bits de poids faible de l'adresse sont identiques. Il est possible d'utiliser autre chose que les bits de poids faible de l'adresse du branchement, afin de limiter les interférences. Et les possibilités sont multiples. Les possibilités en question s'inspirent des traitements effectués sur les adresses des banques dans les mémoires évoluées. Une première solution serait de faire un XOR entre les bits de poids faible de l'adresse du branchement, et d'autres bits de cette même adresse. Ainsi, deux branchements éloignés en mémoire donneraient des résultats différents, même si leurs bits de poids faible sont identiques. Une autre possibilité serait de diviser l'adresse du branchement par un nombre et de garder le reste. Ce calcul de modulo n'est en soi pas très différent du fait de conserver seulement les bits de poids faible. Conserver les N bits de poids faible consiste en effet à prendre le modulo par 2^N de l'adresse. Ici, l'idée serait de faire un modulo par un nombre P, qui serait un nombre premier proche de 2^N. Le fait que le nombre soit premier limite les cas où deux adresses différentes donneraient le même reste, ce qui réduit l'''aliasing''. Le fait de prendre un nombre proche de 2^N pour une entrée de N bits est que le résultat reste assez proche de ce qu'on obtiendrait en gardant les bits de poids faible, cette dernière étant une solution pas trop mauvaise. D'autres possibilités similaires se basent sur des réductions polynomiales ou d'autres astuces impliquant des nombres premiers. L'efficacité de ces méthodes dépend grandement de la taille de la SRAM/table considérée, des adresses des branchements et de beaucoup d'autres paramètres. La réduction des interférences par telle ou telle méthode dépend aussi de l'unité de prédiction considérée. Les résultats ne sont pas les mêmes selon que l'on parle d'une unité à perceptrons ou d'une unité à deux niveaux ou d'unités à base de compteurs à saturation, ou que l'on parle du BTB. Par contre, toutes les méthodes ne sont pas équivalentes en termes de temps de calcul ou de portes logiques. Autant la première solution avec un XOR ajoute un temps de calcul négligeable et quelques portes logiques, autant les autres méthodes requièrent l'ajout d'un diviseur très lent et gourmand en portes logiques pour calculer des modulos. Elles ne sont généralement pas praticables pour ces raisons, le temps de calcul serait trop élevé. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Le pipeline | prevText=Le pipeline | next=Les optimisations du chargement des instructions | nextText=Les optimisations du chargement des instructions }} </noinclude> lydxt84gzwrtmvdbo4t2094qgovs6aq 774403 774402 2026-10-07T21:07:20Z Mewtow 31375 /* La prédiction des branchements des boucles */ 774403 wikitext text/x-wiki Les branchements sont pris en charge par plusieurs étages du pipeline. Exécuter un branchement demande de savoir deux choses : s'il est pris ou non, quelle est l'adresse de destination. L'adresse de destination est localisée grâce au mode d'adressage du branchement. Avec le mode d'adressage direct, elle est extraite de l'instruction par le séquenceur. Avec l'adressage indirect, elle est lue dans un registre. Avec les branchements relatifs, elle est calculée via un additionneur, qui additionne un décalage au ''program counter''. L'additionneur peut être soit dans l'étage de ''PC Update'', soit dans l'unité de calcul de l'étage d'exécution. Passons maintenant au résultat du branchement, à savoir s'il est pris ou non. Pour savoir si le branchement est pris, il faut distinguer les branchements conditionnels et inconditionnels. Pour rappel, les branchements inconditionnels regroupent les appels de fonction, les simples instructions de saut, et quelques autres. Ils sont toujours pris, ils s'exécutent toujours. À partir du moment où le décodeur a décodé un branchement inconditionnel, il sait que le branchement est pris. Pour les branchements conditionnels, ils sont pris seulement si une condition précise est respectée. On sait s'ils sont exécutés une fois que la condition est calculée par l'unité de calcul, ou par une unité de branchement spécialisée. [[File:Gestion des branchements sur un pipeline.png|centre|vignette|upright=2.0|Gestion des branchements conditionnels sur un pipeline.]] Sur le schéma précédent, les branchements inconditionnels ne sont pas montrés. Pour cela, il faudrait rajouter une connexion du décodeur vers le multiplexeur de branchement. De plus, il faudrait aussi rajouter de quoi retarder l'exécution du branchement seulement une fois que l'adresse est connue. Par exemple, elle est connue un cycle d'horloge plus si l'adresse est lue depuis les registres, comparé à un branchement direct. ==Les branchements et les exceptions imprécises/précises== Un branchement est censé altérer le ''program counter'' immédiatement, en un cycle d'horloge. Mais sur un pipeline, Le branchement fait bien reprendre le processeur au bon endroit, mais avec du retard. La raison est qu'un branchement met plusieurs cycles pour s'exécuter. Il faut plusieurs cycles pour déterminer quelle est l'adresse de destination, temps qui dépend de là où est lue l'adresse. Et on sait si le branchement est pris seulement au niveau du décodeur, voire au niveau de l'unité de calcul. Le branchement doit traverser plusieurs étages avant qu'on connaisse son résultat. Il y a bien 3 à 4 cycles avant cela. Et pendant ce temps de trajet, des instructions seront chargées dans le pipeline, potentiellement à tort. Si le branchement n'est pas pris, ce n'est pas grave : les instructions n'ont pas été chargées à tort. Mais dans le cas contraire, les instructions n'auraient pas dû rentrer dans le pipeline. Le problème est similaire à celui observé avec les exceptions précises/imprécises. La différence est que les branchements sont des instructions machines, pas les exceptions matérielles. Mais le problème des instructions chargées à tort est le même dans les grandes lignes. Une différence est que les branchements sont résolus plus ou moins au même étage, alors que les exceptions peuvent survenir dans tout le pipeline. Une autre différence, bien plus importante, est que les branchements non-pris ne chargent pas d'instructions à tort et qu'il faut faire la différence entre branchement pris et non-pris. Dans la suite, nous parlerons d'instructions fautives pour parler des instructions chargées à tort. Pour corriger ce problème, il existe une solution logicielle. Le compilateur fait suivre les branchements par des instructions qui ne font rien, des NOP (No OPeration) : c'est ce qu'on appelle un '''délai de branchement'''. L'idée est que toutes les instructions chargées à tort sont des NOPs, qui ne font rien. Mais le nombre de NOP à ajouter dépend du pipeline du processeur, ce qui peut poser des problèmes de compatibilité. Mais d'autres solutions, matérielles cette fois, sont utilisées de nos jours. ===L'annulation des instructions au décodage=== Les branchements inconditionnels sont dans un cas plus favorable : ils sont identifiés dans l'unité de décodage et sont toujours pris. D'autres branchements sont dans ce cas : les appels de fonctions, les interruptions logicielles. Avec eux, le décodeur annule les instructions fautives, il les remplace par des instructions NOP. L'étage de décodage sait que les instructions suivant un branchement inconditionnel ont été chargées à tort. Précisément N instructions, avec N le nombre d'étages entre le chargement et le décodage (inclus). Les N instructions suivantes sont tout simplement annulés, elles sont remplacées par des NOPs. L'implémentation demande juste un simple compteur et quelques circuits annexes. Cependant, la technique ne marche pas du tout pour les branchements conditionnels. En effet, on ne sait pas s'ils sont pris ou non à l'étage de décodage. Du moins pas avec un pipeline normal. Mais certains processeurs, basés sur le pipeline RISC classique, calculaient les branchements pendant l'étape de décodage, dans une ALU spécialisée qui travaille en parallèle de l'unité de décodage. Le défaut de cette technique est que l'ALU en question doit lire les registres. Pour rappel, sur ce pipeline, l'étape de décodage effectue la lecture des opérandes dans les registres, en parallèle de l'unité de décodage. Mais ce qu'on n'a pas dit dans le chapitre précédent, c'est qu'elle effectue aussi le calcul des branchements dans une mini-ALU séparée ! Ce faisant, le résultat du branchement est connu dès la fin de l'étape de décodage. ===L'usage du système d'exceptions précises pour les branchements conditionnels=== La solution gère le problème en empêchant les instructions chargées à tort d'enregistrer leurs résultats, afin qu'elles n'aient pas le moindre effet sur l'état du processeur. Le hardware pour les exceptions précises, décrit quelques paragraphes avant, est réutilisé pour cela. Les branchements conditionnels peuvent être pris ou non-pris et on doit tenir compte de ces deux cas. Si le branchement n'est pas pris, les instructions n'ont pas été chargées à tort, elles peuvent enregistrer leur résultat dans les registres. Par contre, ce n'est pas le cas pour les branchements pris, les mécanismes d'exceptions précise doivent alors d'activer. Pour cela, la meilleure solution est que l'unité de calcul génère un indicateur d'exception si le branchement n'est pas pris. L'ALU effectue la comparaison et génère ou non l'indicateur d'exception adéquat suivant le résultat de celle-ci. L'idée est d'utiliser un indicateur d'exception particulier, qui précise qu'un branchement a été pris, signe que des instructions ont été chargées à tort. L'indicateur est propagé dans le pipeline, jusqu'à atteindre l'étage d'enregistrement dans les registres. Cet étage décide s'il faut autoriser ou non les écritures qu'il recoit, pour annuler les instructions chargées à tort. Le ''program counter'' est corrigé immédiatement, simplement en exécutant le branchement. Reste à savoir qui génère cet indicateur d'exception de branchement pris. Diverses optimisations visent à éliminer les instructions chargées à tort, à faire en sorte que des instructions utiles soient chargées. Le chapitre suivant va détailler ces optimisations dans le détail, qui sont regroupées sous le terme de prédiction de branchement. Elles ne fonctionnent pas pour les exceptions précises, seulement les branchements. ===La prédiction de branchement=== Les techniques précédentes ont pour défaut que de nombreux cycles d'horloge sont gâchés à cause des instructions fautives. Pour éviter ce gâchis, les concepteurs de processeurs ont inventé l'''exécution spéculative de branchement'', qui consiste à deviner l'adresse de destination du branchement et charger les bonnes instructions dès le départ. Le but est de ne pas charger d'instruction à tort, en prédisant quelles seront les bonnes instructions à charger. Et cela demande de résoudre trois problèmes : * reconnaître les branchements ; * savoir si un branchement sera exécuté ou non : c'est la '''prédiction de branchement''' ; * dans le cas où un branchement serait exécuté, il faut aussi savoir quelle est l'adresse de destination : c'est la '''prédiction de l'adresse de destination''' d'un branchement, aussi appelée ''branch target prediction''. Pour résoudre le second problème, le processeur contient un circuit qui déterminer si le branchement est pris (on doit brancher vers l'adresse de destination) ou non pris (on poursuit l’exécution du programme immédiatement après le branchement) : c'est l''''unité de prédiction de branchement'''. La prédiction de direction de branchement est elle aussi déléguée à un circuit spécialisé : l’'''unité de prédiction de direction de branchement'''. C'est l'unité de prédiction de branchement qui autorise l'unité de prédiction de destination de branchement à modifier le ''program counter''. Dans certains processeurs, les deux unités sont regroupées dans le même circuit. [[File:Relation entre prédiction de branchement et de direction de branchement.png|centre|vignette|upright=2|Relation entre prédiction de branchement et de direction de branchement.]] ==Les erreurs de prédiction de branchement== Le processeur peut parfaitement se tromper en faisant ses prédictions, ce qui charge des instructions par erreur dans le pipeline. Dans ce cas, on parle d''''erreur de prédiction'''. Les erreurs de prédiction sont corrigées par le système d'exception précises, ou un équivalent. En clair, les instructions chargées à tort se propagent jusqu'à la sortie du pipeline, où leurs résultats sont alors annulés. N'importe quelle technique de gestion des exceptions précise fait le travail, mais les processeurs modernes utilisent un tampon de réordonnancement (''reorder buffer'') qu'on détaillera dans quelques chapitres. Détecter les erreurs de prédiction demande d'ajouter un circuit dans le processeur : l’''unité de vérification de branchement'' (''branch verification unit''). Elle génère un indicateur d'exception, qui est géré par le système d'exception précise. Pour générer l'indicateur, elle compare l'adresse de destination prédite et celle calculée en exécutant le branchement. Pour cela, l'adresse prédite doit être propagée dans le pipeline jusqu’à ce que l'adresse de destination du branchement soit calculée. [[File:Unité de vérification de branchement.png|centre|vignette|upright=2|Unité de vérification de branchement]] ===La pénalité de mauvaise prédiction=== Le temps pour vider le pipeline des instructions fautives dépend du pipeline. La dernière instruction à être chargée dans le pipeline le sera en même temps qu'on détecte l'erreur de prédiction. Il faudra attendre que cette instruction quitte le pipeline, et donc qu'elle traverse tous les étages. Il y a donc un temps d'attente pour collecter les résultats de ces instructions et la décision de ne pas enregistrer leurs résultats dans les registres Le nombre d'instructions chargées à tort dépend de où est détectée l'erreur de prédiction. Plus le résultat du branchement est connu tard, plus le nombre d'instructions chargées à tort sera grand, et plus le nombre de cycles d'horloge gâchés le sera aussi. Le nombre de cycles gâchés s'appelle la '''pénalité de mauvaise prédiction'''. Elle correspond précisément au nombre de cycles/étages entre le premier étage du pipeline, et l'étage où le branchement est résolu (on connait l'adresse à laquelle brancher). Plus celui-ci est éloigné du début du pipeline, plus la pénalité est importante. [[File:MIT 6.823 Computer System Architecture, Fall 2005 - P-3 vs. P-4 Pipelines - 4473425702.jpg|centre|vignette|upright=2.5|Comparaison de la pénalité de mauvaise prédiction entre le Pentium 3 et le Pentium 4 d'Intel.]] Elle est généralement d'autant plus longue que le pipeline lui-même est long. Les concepteurs de processeurs font donc face à un compromis : un pipeline long permet d'exécuter plus d'instructions en parallèle, mais cela augmente aussi la pénalité de mauvaise prédiction. Un pipeline de 10-15 étapes est un bon compromis. Les pipelines plus longs ont besoin d'une prédiction de branchement élaborée pour fonctionner correctement. Plus la prédiction de branchement est performante, plus le pipeline peut être long. Les pénalités de mauvaise prédiction seront plus importantes, mais les mauvaises prédictions seront plus rares, ce qui compense complétement. ===La ''minimal control dependency''=== En utilisant une technique du nom de ''minimal control dependency'', seules les instructions qui dépendent du résultat du branchement sont supprimées du pipeline en cas de mauvaise prédiction, les autres n'étant pas annulées. Sans ''minimal control dependency'', toutes les instructions qui suivent un branchement dans notre pipeline sont annulées. Pourtant, certaines d'entre elles pourraient être utiles. Prenons un exemple : supposons que l'on dispose d'un processeur de 31 étages (un Pentium 4 par exemple). Supposons que l'adresse du branchement est connue au 9éme étage. On fait face à un branchement qui envoie le processeur seulement 6 instructions plus loin. Si toutes les instructions qui suivent le branchement sont supprimées, les instructions en rouge sont celles qui sont chargées incorrectement. On remarque pourtant que certaines instructions chargées sont potentiellement correctes : celles qui suivent le point d'arrivée du branchement. Elles ne le sont pas forcément : il se peut qu'elles aient des dépendances avec les instructions supprimées. Mais si elles n'en ont pas, alors ces instructions auraient dûes être exécutées. Il serait donc plus efficace de les laisser enregistrer leurs résultats au lieu de les ré-exécuter à l’identique un peu plus tard. Ce genre de choses est possible sur les processeurs qui implémentent une technique du nom de ''Minimal Control Dependancy''. En gros, cette technique fait en sorte que seules les instructions qui dépendent du résultat du branchement soient supprimées du pipeline en cas de mauvaise prédiction. [[File:Minimal cntrol dependency.png|centre|vignette|upright=2|Minimal control dependency]] ==La reconnaissance des branchements== Pour prédire des branchements, le processeur doit faire la différence entre branchements et autres instructions, dès l'étage de chargement. Or, un processeur « normal », sans prédiction de branchement, ne peut faire cette différence qu'à l'étage de décodage, et pas avant. Les chercheurs ont donc dû trouver une solution. La première solution se base sur les techniques de prédécodage vues dans le chapitre sur le cache. Pour rappel, ce prédécodage consiste à décoder partiellement les instructions lors de leur chargement dans le cache d'instructions et à mémoriser des informations utiles dans la ligne de cache. Dans le cas des branchements, les circuits de prédécodage peuvent identifier les branchements et mémoriser cette information dans la ligne de cache. Une autre solution mémorise les branchements déjà rencontrés, dans un cache dédié. Ce cache mémorise l'adresse (le ''program counter'') des branchements déjà rencontrés : il s'appelle le '''tampon d’adresse de branchement''' (''branch adress buffer''). Il est intégré à l'unité de prédiction de branchement, mais est alimenté par le décodeur d'instruction. Reconnaitre un branchement demande l'implication du décodeur d'instruction, qui sait si l'instruction qu'il vient de décoder est un branchement ou non. Il peut même savoir si le branchement est conditionnel, inconditionnel, un appel de retour de fonction, etc. Lorsque le décodeur d'instruction reconnait un branchement, il ajoute ce branchement dans le cache Le tapon d'adresse de branchement contient une adresse mémoire par branchement, et éventuellement d'autres informations. Et pour comprendre quelle adresse est mémorisée, il faut faire attention à un détail. Les explications qui vont suivre vont faire intervenir deux adresses. * La première est l''''adresse de destination''' du branchement, à savoir l'adresse à laquelle le processeur doit reprendre son exécution si le branchement est pris. * L'autre adresse est l''''adresse du branchement''' lui-même. Elle correspond à sa position en mémoire RAM. L'adresse du branchement est la valeur du ''program counter'' quand le branchement est chargé dans le cache, alors que l'adresse de destination est celle du ''program Counter'' une fois le branchement exécuté. Il faudra faire bien attention à ne pas confondre les deux adresses dans ce qui suit. L'adresse du branchement est celle qui est utilisée pour la reconnaissance du branchement. Le tampon d'adresse de branchement mémorise, pour chaque branchement, son adresse de branchement (d'où son nom). À chaque cycle d'horloge, l'unité de chargement envoie le ''program counter'' en entrée du tampon d’adresse de branchement. Un succès dans ce cache indique qu'un branchement déjà rencontré est ré-executé, on peut utiliser la prédiction de branchement. Dans le cas contraire, la prédiction de branchement n'est pas utilisée, et l'instruction est chargée normalement. ==La prédiction de l'adresse de destination== La '''prédiction de l'adresse de destination d'un branchement''' détermine l'adresse de destination d'un branchement. Rappelons qu'il existe plusieurs modes d'adressage pour les branchements et que chacun d'entre eux précise l'adresse de destination à sa manière. Suivant le mode d'adressage, l'adresse de destination est soit dans l'instruction elle-même (adressage direct), soit calculée à l’exécution en additionnant un ''offset'' au ''program counter'' (adressage relatif), soit dans un registre du processeur (branchement indirect), soit précisée de manière implicite (retour de fonction, adresse au sommet de la pile). La prédiction des branchements directs et relatifs se fait globalement de la même manière, ce qui fait que nous ferons la confusion dans ce qui suit. Pour les branchements implicites, ils correspondent presque exclusivement aux instructions de retour de fonction, qui sont un cas un peu à part que nous verrons dans la section sur les branchements indirects. Pour résumer, nous allons faire une différence entre les branchements directs pour lequel l'adresse de destination est toujours la même, les branchements indirects où l'adresse de destination est variable durant l’exécution du programme, et les instructions de retour de fonction. Les branchements directs sont facilement prévisibles, vu que l'adresse vers laquelle il faut brancher est toujours la même. Pour les branchements indirects, vu que cette adresse change, la prédire celle-ci est particulièrement compliqué (quand c'est possible). Pour les instructions de retour de fonction, une prédiction parfaite est possible. ===La prédiction de l'adresse de destination pour les branchements directs=== Lorsqu'un branchement est exécuté, on peut se souvenir de l'adresse de destination et la réutiliser lors d'exécutions ultérieures du branchement. Cette technique marche à la perfection pour les branchements directs, pour lesquels cette adresse est toujours la même, mais pas pour les branchements indirects. Pour se souvenir de l'adresse de destination, on utilise un cache qui mémorise les correspondances entre l'adresse du branchement et l'adresse de destination. Il est appelé le '''tampon de destination de branchement''', ''branch target buffer'' en anglais, qui sera abrévié en BTB dans ce qui suit. Le BTB est une amélioration du tampon d'adresse de branchement vu plus haut, auquel on aurait ajouté les adresses de destination des branchements. Le BTB est utilisé comme suit : on envoie en entrée l'adresse du branchement lors du chargement, le BTB répond au cycle suivant en précisant : s’il reconnaît l'adresse d'entrée, et quelle est l'adresse de destination si c'est le cas. Précisons que le BTB ne mémorise pas les branchements non-pris, ce qui est inutile. Le BTB peut être un cache totalement associatif, associatif par voie, ou directement adressé, mais ces derniers sont les plus fréquents. Les BTB sont généralement des caches de type directement adressés, où seuls les bits de poids faible de l'adresse du branchement adressent le cache, le reste de l'adresse est tout simplement ignoré. Il existe aussi des BTB qui sont construits comme les caches associatifs à plusieurs voies, mais ceux-ci impliquent généralement la présence de ''tags'', ce qui fait qu'ils sont assez rares. [[File:Branch target buffer directement adressé.png|centre|vignette|upright=2|Branch target buffer directement adressé.]] Comme pour tous les caches, un accès au BTB peut entraîner un défaut de cache, c’est-à-dire qu'un branchement censé être dans le BTB n'y est pas. Comme pour les autres caches, les défauts de cache peuvent se classer en trois types : les défauts de cache à froid (''cold miss'') causés par la première exécution d'un branchement, les défauts liés à la capacité du BTB/cache, et les défauts par conflit d'accès au cache. Les défauts liés à la capacité du BTB sont les plus simples à comprendre. La capacité limitée du BTB fait que d'anciens branchements sont éliminées pour laisser la place à de nouveaux, généralement en utilisant algorithme de remplacement de type LRU. En conséquence, certains branchements peuvent donner des erreurs de prédiction si leur correspondance a été éliminée du cache. On peut en réduire le nombre en augmentant la taille du BTB. Les défauts liés aux conflits d'accès au BTB sont eux beaucoup plus intéressants, car la conception du BTB est assez spéciale dans la manière dont sont gérés ce genre de défauts. Le BTB est un cache partiellement associatif, ce qui fait que des défauts de cache par conflit d'accès peuvent avoir lieu. Pour rappel, les défauts par conflit d'accès ont lieu quand deux adresses mémoires se voient attribuer la même ligne de cache. Pour un BTB, cela correspond au cas où deux branchements se voient attribuer la même entrée dans le BTB, ce qui porte le nom d’''aliasing''. Ne pas détecter ce genre de situation sur un cache normal entraînerait des problèmes : la donnée lue/écrite ne serait pas forcément la bonne. Les caches normaux utilisent un système de tags pour éviter ce problème et on s'attendrait à ce que les BTB fassent de même. S'il existe des BTB qui utilisent des ''tags'', ils sont cependant assez rares. Pour un BTB, l'absence de ''tags'' n'est pas un problème : la seule conséquence est une augmentation du taux de mauvaises prédictions, dont les conséquences en termes de performance peuvent être facilement compensées. Les BTB se passent généralement de tags, ce qui a de nombreux avantages : le circuit est plus rapide, prend moins de portes logiques, consomme moins d'énergie, etc. Ces économies de portes logiques et de performances peuvent être utilisées pour augmenter la taille du BTB, ce qui compense, voire surcompense les mauvaises prédictions induites par l’''aliasing''. Des processeurs se passent de BTB. À la place, ils mémorisent les correspondances entre branchement et adresse de destination dans les bits de contrôle du cache d'instructions. Cela demande de mémoriser trois paramètres : l'adresse du branchement dans la ligne de cache (stockée dans le ''branch bloc index''), l'adresse de la ligne de cache de destination, la position de l'instruction de destination dans la ligne de cache de destination. Lors de l'initialisation de la ligne de cache, on considère qu'il n'y a pas de branchement dedans. Les informations de prédiction de branchement sont ensuite mises à jour progressivement, au fur et à mesure de l’exécution de branchements. Le processeur doit en même temps charger l'instruction de destination correcte dans le cache, si un défaut de cache a lieu : il faut donc utiliser un cache multi-port. L'avantage de cette technique est que l'on peut mémoriser une information par ligne de cache, comparé à une instruction par entrée dans un tampon de destination de branchement. Mais cela ralentit fortement l'accès au cache et gaspille du circuit (les bits de contrôle ajoutés ne sont pas gratuits). En pratique, on n'utilise pas cette technique, sauf sur quelques processeurs (un des processeurs Alpha utilisait cette méthode). ===La prédiction de l'adresse de destination pour les branchements indirects=== Passons maintenant aux branchements indirects. Les techniques précédentes fonctionnement quand on les applique aux branchements indirects et ne marchent pas trop mal, mais elles se trompent à chaque fois qu'un branchement indirect change d'adresse de destination. Tous les processeurs commerciaux datant d'avant le Pentium M sont dans ce cas. De nos jours, les processeurs haute performance sont capables de prédire l'adresse de destination d'un branchement indirect. Mais même malgré ces techniques avancées de prédiction, les branchements indirects et appels de sous-programmes indirects sont souvent très mal prédits. Les processeurs modernes utilisent souvent une '''unité de prédiction des branchements indirects''', séparée de l'unité de prédiction de branchement normale. Elle utilise un BTB amélioré, qui mémorise plusieurs adresses de destination pour un seul branchement, en compagnie d'informations pour prédire laquelle est la bonne. Il est possible d'utiliser un BTB unique, pour tous types de branchements, appelé un '''BTB unifié'''. Cette solution est illustrée ci-dessous, avec un tableau. Mais elle est tout sauf optimale, et n'est presque jamais utilisée en pratique. {|class="wikitable" |+ Branch Taarget Buffer unifié |- ! Adresse du branchement !! Informations de prédiction !! Adresse de destination !! Seconde adresse de destination |- | 0x54462146 || 11101011 || 0x52495674 || 0x24987461 |- | 0x54142446 || 11101101 || 0x15646956 || x0x4158746 |- | 0x25456542 || 10011101 || 0x52495674 || 0x24157461 |- | 0x15455855 || 11010101 || 0x52485754 || 0x24024511 |- | ... || ... || ... || ... |- | ... || ... || ... || ... |- | 0x54462146 || 1001101 || 0x52495674 || 0x24987461 |} Une seconde solution utilise un BTB séparé, distinct du BTB pour les branchements directs. En séparant les deux, on a un BTB simple qui ne mémorise qu'une adresse de destination par branchement, et un BTB pour branchements indirects avec plusieurs adresses de destination par branchement. [[File:BTB avec prédiction de branchements indirects.png|centre|vignette|upright=2.5|BTB avec prédiction de branchements indirects.]] L'idée est que l'on économise des circuits à nombre d'entrées égales. Par exemple, comparons ce que cela donne avec 64 entrées. D'un côté, on a un BTB à 64 entrées, capable de gérer deux adresse de destination par branchement. En pratique, la majorité des branchements sont des branchements directs, qui n'utiliseraient qu'une seule case mémoire. Les circuits du BTB seraient donc sous-utilisés. De l'autre, on a un BTB à 48 entrées pour branchements directs, et un second BTB avec 16 entrées, capable de mémoriser deux adresses par branchements. Le premier BTB utilise moins de circuits, et est utilisé à pleine capacité, il ne gaspille pas de circuits pour mémoriser des adresses de destination vides. Par contre, on perd un peu en flexibilité. L'organisation précédente marche bien si 1/4 des branchements rencontrés sont des branchements indirects. Mais si le ratio est différent, la performance est légèrement sous-optimale. Attention, cela ne veut pas dire qu'il y aura des entrées vides dans un BTB, non. Simplement, le premier BTB risque de manquer de place pour les branchements directs récents, alors que les branchements indirects seront eux bien prédits, même les anciens. À l'opposé, un BTB unique permet de mémoriser les 64 derniers branchements, peu importe qu'ils soient indirects ou non. En clair : un BTB unique est plus flexible, ce qui donne un gain de performance dans certaines situations. Cependant, cet avantage théorique ne tient pas compte de l'économie en circuits, qui peut être utilisée pour faire grossir le BTB pour branchements directs. Par exemple, à budget à transistor équivalent, on a le choix entre BTB à 64 entrées uniques, et un BTB pour branchements directs de 80 entrées avec un BTB indirect de 16 entrées. Le fait qu'un BTB soit plus gros permet des gains de performances qui compensent la perte de flexibilité. On peut ainsi avoir le meilleur des deux monde, à savoir une économie de circuits et les gains en performance qui en découlent, malgré une petite perte de flexibilité. ===La prédiction de l'adresse de retour d'une fonction=== Presque tous les processeurs conçus après les années 2000 peuvent prévoir l'adresse à laquelle il faudra reprendre lorsqu'une fonction/sous-programme a fini de s’exécuter. Ils font de la '''prédiction de retour de fonction'''. Pour cela, ils possèdent pour une unité de prédiction de branchement spécialisée, appelée le ''return stack predictor'', ou '''''pile de prédiction de retour de fonction'''''. Le terme ''return stack predictor'' étant plus court, nous l'utiliserons dans la suite du cours. Le ''return stack predictor'' est capable de reconnaitre les instructions de retour de fonction, et de prédire l'adresse de retour. Pour comprendre comment elle fait, rappelez-vous le chapitre sur la pile d'appel. Dans ce chapitre, j'avais introduit le concept de ''pile d'adresse de retour'', à savoir une pile d'appel très simplifiée, qui ne contient que les adresses de retour des fonctions en cours d'exécution. Le ''return stack predictor'' est essentiellement composé d'une telle pile d'adresse de retour, avec des circuits pour empiler et dépiler les adresses de retour. Le ''return stack predictor'' doit reconstruire une pile d'adresses de retour, à partir du programme exécuté. La pile d'adresse est mémorisée dans le ''return stack predictor'', elle est totalement séparée de la pile d'appel normale. Le programme exécuté continue d'utiliser la même pile d'appel qu'avant, ce qui fait que les adresses de retour sont dispersées dans la pile d'appel, parfois dans des registres spéciaux du processeur. Mais cela ne pose pas de problèmes pour le processeur, qui reconstruit la pile d'adresse de retour avec l'aide du décodeur. La reconstruction se fait à partir des instructions d'appel et de retour de fonction. Une instruction d'appel de fonction empile une adresse de retour, une instruction de retour de fonction la dépile. Lorsque le décodeur d'instruction reconnait une instruction d'appel de fonction, il extrait l'adresse de retour et l'envoie au ''return stack predictor''. L'adresse de retour est alors empilée, ce qui fait qu'elle se retrouve au sommet de la pile d'adresse de retour. Dépiler une adresse de retour revient à prédire un branchement. Lorsque le ''return stack predictor'' détecte une instruction de retour de fonction, il dépile l'adresse au sommet de la pile. L'adresse est alors envoyée du cache d'instruction, pour l'ire l'instruction adéquate, et le ''program counter'' est mis à jour avec cette adresse. Toute la difficulté revient à détecter les instructions de retour de fonction, sans quoi on ne sait pas quand dépiler l'adresse. La documentation là-dessus n'est pas très fournie, il est difficile de savoir ce qui est fait dans les processeurs modernes. Mais on peut faire quelques suppositions. La première possibilité est l'usage du ''prédécodage'', une technique qui décode partiellement les instructions lors de leur chargement dans le cache d'instruction. Elle sera vue dans le chapitre suivant, mais parlons-en rapidement. Le prédécodage ajoute quelques bits au début de chaque instruction pour préciser si c'est un branchement conditionnel, inconditionnel, un retour de fonction. On peut ainsi savoir rapidement quand dépiler : dès qu'une instruction est lue dans le cache d'instruction, le ''return stack predictor'' vérifie les bits ajoutés et agit en fonction. La seconde possibilité est que le BTB reconnait l'instruction de retour de fonction. Pour chaque branchement, il mémorise non seulement son adresse de retour, mais aussi son type. Par type, on veut dire : si c'est un branchement conditionnel, inconditionnel, un retour de fonction. Les retours de fonction sont donc reconnus à condition qu'ils aient été exécutés auparavant. Il est aussi possible d'avoir un BTB spécialisé dans les retours de fonction, qui est alimenté par les décodeurs. Dès que le décodeur reconnait une instruction de retour de fonction, il ajoute son ''program counter'' dans ce BTB spécialisé. Une dernière possibilité est que le ''return stack predictor'' agit une fois l'instruction partiellement décodée. Le décodeur d'instruction reconnait les instructions de retour de fonction rapidement, en un cycle, et consulte alors le ''return stack predictor''. L'adresse est dépilée, et le décodeur sait où brancher. Il n'a pas besoin de devoir lire l'adresse de retour en mémoire, il la lit depuis le ''return stack predictor'', directement. La prédiction se fait donc assez tard dans le pipeline, quelques instructions ont été chargées à tort. Mais le décodeur peut les annuler, comme il le ferait avec un branchement inconditionnel mal prédit. ==La prédiction de branchement locale== La prédiction de branchement tente de prédire si un branchement sera pris ou non-pris, et décide quelle adresse envoyer au cache en fonction du résultat. Si on prédit qu'un branchement est non pris, on laisse le ''program counter'' faire son travail. À l'inverse si le branchement est prédit comme étant pris, l'adresse de destination est envoyée au cache d'instruction et au ''program counter''. La prédiction de branchement assigne une probabilité qu'un soit branchement soit pris en fonction de ces informations : si la probabilité est de plus de 50%, le branchement est considéré comme pris. La prédiction de branchement se base avant tout sur des statistiques, c’est-à-dire qu'elle détermine la probabilité d’exécution d'un branchement en fonction d'informations assez diverses. La prédiction de branchement n'a d'intérêt que si les prédictions sont suffisamment fiables pour valoir le coup. Rappelons que la pénalité lors d'une mauvaise prédiction est importante, d'autant plus coûteuse que le pipeline est long. Il est généralement admis qu'il faut au minimum un taux de réussite proche de 90%, voire plus sur les processeurs modernes. Heureusement, la plupart des branchements sont des '''branchements biaisés''', c’est-à-dire qu'ils sont presque toujours pris ou presque toujours non-pris, sauf en de rares occasions. De tels branchements font que la prédiction des branchements est facile, bien qu'ils posent paradoxalement quelques problèmes, comme nous le verrons plus bas. Un autre point important est que les unités de prédiction de branchement doivent être très rapides. Idéalement, elles doivent fournir leur prédiction en un seul cycle d'horloge. Un seul cycle d'horloge est un temps très court, surtout sur les ordinateurs avec une fréquence élevée. Les techniques de prédiction dynamique ne peuvent donc pas utiliser des méthodes statistiques extraordinairement complexes. Cela va à l'encontre du fait que le taux de mauvaises prédictions doit être très faible, idéalement inférieur à 1% sur les processeurs modernes. Vous comprenez donc aisément que concevoir une unité de prédiction de branchement est un véritable défi d’ingénierie électronique qui requiert des prouesses technologiques sans précédent. Suivant la nature des informations utilisées, on peut distinguer plusieurs types de prédiction de branchement : locale, globale, dynamique, statique, etc. Nous allons d'abord voir les méthodes statiques avant de voir les méthodes dynamiques. La '''prédiction locale''' prédit chaque branchement indépendamment les uns des autres. Elle sépare bien les statistiques/moyennes pour chaque branchement. Et c'est autant un point fort qu'un point faible. En effet, elle n'exploite pas les corrélations entre branchements, à savoir le fait que l'exécution d'un branchement a tendance à déborder sur les suivants. Dans ce qui suit, nous allons voir les techniques de prédiction locales. ===Les ''branch hints''=== Dans son implémentation la plus explicite, la prédiction est inscrite dans le branchement lui-même. Quelques bits de l'opcode du branchement précisent si le branchement est majoritairement pris ou non pris. Ils permettent d'influencer les règles de prédiction de branchement et de passer outre les réglages par défaut. Ces bits sont appelés des '''suggestions de branchement''' (''branch hint''). Mais tous les processeurs ne gèrent pas cette fonctionnalité. Et de plus, cette solution marche assez mal. La raison est que le programmeur ou le compilateur doit déterminer si le branchement est souvent pris ou non-pris, mais qu'il n'a pas de moyen réellement fiable pour cela. Une solution possible est d’exécuter le programme sur un ensemble de données réalistes et d'analyser le comportement de chaque branchement, afin de déterminer les suggestions de branchement adéquates, mais c'est une solution lourde et peu pratique, qui a de bonnes chances de donner des résultats peu reproductibles d'une exécution à l'autre. ===La prédiction statique de branchement=== La '''prédiction statique''' prédit le résultat du branchement en fonction de caractéristiques fixes du branchement lui-même, comme son adresse, son mode d'adressage, l'adresse de destination, la position du branchement en mémoire, si le branchement est direct ou indirect, etc. Les informations en question sont connues une fois le branchement décodé, il n'y a pas besoin de mémoriser la moindre information supplémentaire. Une première implémentation, assez évidente, fait la différence entre les branchements inconditionnels toujours pris, et les branchements conditionnels au résultat variable. L'idée est de prédire les branchements inconditionnels comme toujours pris alors que les branchements conditionnels sont prédits comme non-pris (ce qui est une approximation). L'implémentation est relativement simple : on utilise un BTB qui ne mémorise que les branchements inconditionnels, et basta ! Les branchements conditionnels sont simplement ignorés, traités comme n'importe quelle autre instruction. Cette méthode est particulièrement inefficace pour les branchements de boucle, où la condition est toujours vraie, sauf en sortie de boucle ! Il a donc fallu affiner légèrement l'algorithme de prédiction statique. Une implémentation plus subtile fait la différence entre les branchements des boucles et les autres. Reste à identifier ceux-ci d'une manière ou d'une autre. Et un bon moyen est de regarder si le branchement revient en arrière ou au contraire saute une portion de code. Un programme s'exécute des adresses basses vers les adresses hautes, les instructions vont naturellement dans le sens croissant des adresses. Un branchement de boucle fait revenir en arrière, histoire de répéter un morceau de code, pas les autres branchements (du moins pas forcément). Et cela nous amène à faire la distinction entre les branchements conditionnels ascendants et descendants. Un '''branchement conditionnel ascendant''' est un branchement qui demande au processeur de reprendre plus loin dans la mémoire : l'adresse de destination est supérieure à l'adresse du branchement. Un branchement '''conditionnel descendant''' a une adresse de destination inférieure à l'adresse du branchement : le branchement demande au processeur de reprendre en arrière dans la mémoire, avant le branchement. Les branchements ascendants sont rarement pris (ils servent dans les conditions de type SI…ALORS), contrairement aux branchements descendants (qui servent à fabriquer des boucles). On peut ainsi modifier l’algorithme de prédiction statique comme suit : * les branchements inconditionnels sont toujours pris ; * les branchements descendants sont toujours pris ; * les branchements ascendants ne sont jamais pris. L'implémentation demande d'élargir le BTB. Le BTB n'a pas seulement à mémoriser l'adresse du branchement et son adresse de destination, mais aussi le type du branchement. En général, il se contente de deux bits, qui indiquent si le branchement est conditionnel, inconditionnel, ou un retour de fonction. Les retours de fonction sont traités à part, histoire de profiter du ''return stack predictor''. {|class="wikitable" |+ BTB avec prédiction de branchement statique évoluée |- ! Adresse du branchement !! Adresse de destination !! Type du branchement |- | 0x4165485 || 0x14657445541 | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Retour de fonction |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... || ... |} ===La prédiction avec des compteurs à saturation=== Les méthodes de prédiction de branchement statique sont intéressantes, mais elles montrent rapidement leurs limites. Pour plus de performances, il faut utiliser la '''prédiction dynamique''', qui extrait des statistiques sur l’exécution des branchements. Pour le reste du chapitre, toutes les techniques utilisent la prédiction dynamique, qu'elles soient locales ou globales. Sa version la plus simple se contente de mémoriser ce qui s'est passé lors de l’exécution précédente du branchement. Si le branchement a été pris, alors on suppose qu'il sera pris la prochaine fois. De même, s'il n'a pas été pris, on suppose qu'il ne le sera pas lors de sa prochaine exécution. Pour cela, chaque entrée du BTB contient un bit qui mémorise la dernière exécution du branchement : 1 s'il a été pris, 0 s'il n'a pas été pris. C'est ce qu'on appelle la '''prédiction de branchements sur 1 bit'''. Au vu de sa simplicité, il ne faut pas s'attendre à des miracles. La méthode fonctionne bien pour les branchements des boucles et les branchements inconditionnels, mais elle n'est pas assez performante. Une forme plus élaborée de prédiction utilise des '''compteurs à saturation'''. Un compteur à saturation est un compteur qui est incrémenté à chaque fois qu'un branchement est pris, décrémenté s'il est non-pris. Le compteur est initialisé à la moitié de sa valeur maximale, ce qui correspond à une probabilité proche de 50%. Les compteurs à saturation sont très utilisés et pas seulement pour de la prédiction locale, les méthodes globales en utilisent aussi. Pour faire la prédiction, l'unité de prédiction regarde le bit de poids fort du compteur. Le branchement est considéré comme pris si ce bit de poids fort est à 1, et non pris s'il vaut 0. En clair : on considère que le branchement est pris si le compteur indique une probabilité supérieure à 50%. [[File:Compteur à saturation.png|centre|vignette|upright=2|Compteur à saturation.]] : Pour la culture générale : le compteur à saturation du Pentium 1 était légèrement bogué, ce qui fait que sa prédiction de branchement était sous-optimale. La plupart des processeurs utilisent un compteur à saturation par branchement, ce qui veut dire un compteur à saturation par entrée dans le BTB. Vu chaque compteur est associé à une entrée du BTB, le phénomène d’''aliasing'' mentionné plus haut peut perturber les prédictions de branchement. Deux branchements peuvent se voir associés à la même entrée du BTB, et agir sur le même compteur à saturation, ce qui entraine l'apparition d''''interférences entre branchements'''. L'interférence peut avoir des effets positifs, neutres ou négatifs, suivant la corrélation entre les deux branchements. L’''aliasing'' n'est pas un problème si les deux branchements sont fortement corrélés, par exemple si les deux sont tout le temps pris ou tout le temps non-pris. Si les deux branchements vont dans le même sens, l'interférence est neutre, voire légèrement positive. Mais si l'un est souvent pris et l’autre souvent non-pris, l'interférence est négative. Les deux branchements vont se marcher dessus sans vergogne, chacun interférant sur les prédictions de l'autre ! Il est rare que l’''aliasing'' ait un impact significatif sur les unités de prédiction des deux paragraphes précédents. Il se manifeste surtout quand la BTB est très petite et la seule solution est d'augmenter la taille du BTB. ===La prédiction avec un historique local=== La prédiction avec des compteurs à saturation est très simple, mais il est possible de créer une implémentation alternative, très proche, avec des avantages et désavantages différents. Un compteur à saturation enregistre l'historique d'un branchement, à savoir ses dernières exécutions. L''''historique''' d'un branchement dit quel a été son résultat dans le passé, pour ses exécutions précédentes. Et il est possible d'enregistrer cet historique et l'utiliser pour faire des prédictions. Par exemple, prenons un branchement sont ses 6 dernières exécutions sont "pris, non-pris, pris, pris, pris, non-pris". Le processeur peut encoder cet historique sur 6 bits. Un branchement pris correspond à un bit à 1, un branchement non-pris correspond à un 0. Le bit de poids fort correspond à l'exécution la plus ancienne, le bit de poids faible à l'exécution la plus récente. Par exemple, si l'historique contient 010, cela veut dire que le branchement a été non pris, puis pris, puis non pris. L'historique d'un branchement est appelé son '''historique local''', mais nous parlerons simplement l'historique. Le processeur peut mémoriser l'historique de chaque branchement. Pour cela, il réserve n bits par branchements, pour mémoriser ses n exécutions précédentes. L'historique d'un branchement est mémorisé dans son entrée du BTB. Et plus précisément dans un champ séparé qui est alimentée comme un registre à décalage. Reste à exploiter cet historique local pour faire des prédictions. Une idée assez simple serait de l'utiliser pour calculer une moyenne des N exécutions précédentes. Si la probabilité d'être pris est supérieure à 50 %, le branchement est considéré comme pris. Dans le cas contraire, il est considéré comme non pris. Le calcul de la moyenne utilise une simple porte à majorité, qui prend en entrée tous les bits de l'historique. Cependant, cette méthode est équivalente à utiliser un compteur à saturation par branchement. La sortie de la porte à majorité est équivalente à ce qu'on aurait dans un compteur à saturation. La seule différence : le cout en circuit et la simplicité de conception. Prenons l'exemple des premiers processeurs PA RISC. Ils mémorisaient, pour chaque branchement, les trois dernières exécutions du branchement, avec trois bits par entrée du BTB. La prédiction était réalisée avec une porte à majorité, dont le résultat était utilisé comme prédiction. Ces processeurs ont préféré utiliser un historique de 3 bits, car il était plus simple à concevoir qu'un compteur à saturation équivalent de deux bits. Il suffisait d'alimenter un registre à décalage avec un bit, pas besoin d'incrémenter ou de décrémenter un compteur, pas besoin de signaux d'incrémentation/décrémentation séparés, etc. Mais pour des historiques plus longs, la donne s'inverse et des compteurs à saturation de deux-trois bits l’emportent. Un défaut de la méthode précédente, qui est partagée avec les compteurs à saturation, est qu'elle marche mal pour les branchements qui suivent un ''pattern''. Je veux dire par là que l'exécution d'un branchement suit un cycle, par exemple pris, pris, non-pris, pris, pris, non-pris, et ainsi de suite en boucle. Notons que l'apparition de tels motifs correspond le plus souvent à la présence de boucles dans le programme. Quand une boucle s’exécute, les branchements qui sont à l'intérieur tendent à exprimer de tels motifs. Avec de tels motifs, les compteurs à saturation feront des prédictions incorrectes, là où les prédicteurs qu'on va voir font des prédictions parfaites. Par exemple, prenons un branchement qui suit le ''pattern'' pris, pris, pris, non-pris. Si l'historique local vaut 1110, le branchement est censé refaire une boucle et être pris. Et certes, on a supposé que l'historique faisait 4 bits. Mais le''pattern'' sera visible tant que l'historique fait plus de 4 bits. Par exemple, le ''pattern'' serait 01110 pour un historique local de 5 bits, 101110 pour un historique local de 6 bits, 1101110 pour un historique local de 7 bits, etc. Un ''pattern'' correspond donc à une valeur bien précise de l'historique local. L'historique local permet de voir ces cycles, reste à les exploiter. Prédire correctement les ''pattern'' demande simplement d'associer un compteur à saturation à chaque valeur possible de l'historique local. Le compteur à saturation dira, quand tel pattern est rencontré, si le branchement sera pris ou non-pris par la suite. Au total, il faut donc un registre d'historique local de n bits couplé à 2^n compteurs à saturation. Et ce pour chaque branchement possible. Les compteurs à saturation sont regroupés dans une '''''Pattern History Table''''', que nous noterons PHT dans ce qui suit. Le résultat est une '''unité de prédiction à deux niveaux locale''', qui contient plusieurs registres d'historique et tout autant de PHT locales. En tout, il y a une PHT et un historique par branchement, donc par entrée du BTB. Quand un branchement est exécuté, son historique local indique quel compteur à saturation choisir dans la PHT locale. En général, l'historique local est mémorisé dans le BTB, reste à gérer la PHT. [[File:Unité de prédiction à deux niveaux purement locale.png|centre|vignette|upright=2.5|Unité de prédiction à deux niveaux avec des historiques et des PHT locales.]] Une unité de prédiction locale à deux niveaux contient plusieurs PHT, reste à savoir quelle PHT correspond à tel branchement. La correspondance se fait idéalement à partir de l'adresse du branchement, mais c'est là une implémentation idéale, qui demande beaucoup de circuits. Généralement, l'unité de prédiction n'utilise que les bits de poids faible de l'adresse du branchement, ce qui permet de faire un choix correct, mais avec une possibilité d'''aliasing''. Il est aussi possible de séparer l'historique local du BTB. Les historiques locaux sont alors mémorisés dans une mémoire RAM dédiée, appelé la '''''Branch History Table''''', ou encore la '''table des historiques locaux''', que nous noterons BHT dans ce qui suivra. Chaque byte de la BHT contient l'historique local associé à un branchement. Pour récupérer l'historique local voulu, l'adresse du branchement est envoyée à la BHT, sur les entrées d'adresse de la BHT, directement. Seuls les bits de poids faible de l'adresse sont utilisés. L'historique local est ensuite envoyé à la PHT adéquate, choisie en fonction des mêmes bits de poids faible du PC. Un bon moyen pour cela est d'accéder à toutes les PHT en parallèle, mais de sélectionner la bonne avec un multiplexeur, ce dernier étant commandé par les bits de poids faible du PC. [[File:Table des historiques locaux.jpg|centre|vignette|upright=2|Table des historiques locaux]] Un défaut des unités de prédiction purement locales de ce type est leur cout en termes de circuits. Utiliser un registre d'historique et une PHT par branchement a un cout en transistor élevé, avec tous les défauts que ca implique en termes de consommation électrique et thermique. Elles ont aussi des temps de calcul un peu plus important que les unités purement globales, mais pas de manière flagrante. Les gains en performance avec un historique local s'observent surtout pour les branchements qui sont dans des boucles ou pour les branchements utilisés pour concevoir des boucles, alors que l'implémentation globale marche bien pour les if..else (surtout quand ils sont imbriqués). Les deux sont donc complémentaires. Dans les faits, elles sont rarement utilisées seules du fait de leurs défauts, au profit d'unités de prédiction hybrides, qui mélangent historiques et PHT locaux et globaux. ==La prédiction de branchement globale/mixte== Utiliser un historique local avec une PHT est très puissant. Mais le cout en circuit est très important. Cependant, on peut adapter l'idée pour faire de la prédiction globale. La '''prédiction globale''' fait des prédictions holistiques, qui tiennent compte de tous les branchements, sans les traiter séparément. Elle ne sépare pas les branchements, tous sont mis dans le même panier. La prédiction globale fait donc le compromis inverse : elle exploite les corrélations entre branchements, mais perd les informations statistiques précise pour chaque branchement. L'idée est qu'au lieu d'utiliser un historique par branchement, on utilise un '''historique global''' qui mémorise les exécutions de tous les branchements conditionnels. L'historique enregistre les N exécutions de branchements conditionnels. L'historique global est mémorisé dans un registre unique, appelé le '''registre d'historique global'''. C'est un registre à décalage mis à jour à chaque fois que le processeur exécute un branchement : on fait rentrer un 1 si le branchement est pris, et un 0 sinon. Une unité de prédiction globale utilise cet historique pour faire sa prédiction. L'unité de prédiction globale utilise un compteur à saturation pour chaque valeur possible du registre d'historique. En clair, pour un registre de N bits, elle utilise 2^n compteurs à saturation. Là encore, les compteurs à saturation sont regroupés dans une ''Pattern History Table''. Nous parlerons de '''PHT globale''' pour désigner une PHT commandée par un historique global, de ''PHT locale'' si elle est adressée par un historique local. [[File:Prédiction dynamique à deux niveaux.png|centre|vignette|upright=2|Prédiction dynamique à deux niveaux.]] Il est admis que l'historique global donne de meilleures prédictions que l'historique local. Mais les deux sont complémentaires, ce qui fait qu'il existe des approches hybrides. Les approches hybrides exploitent à la fois les statistiques pour chaque branchement et les corrélations entre branchements. Les prédicteurs hybrides ont de meilleures performances que les prédicteurs purement globaux ou locaux. Avec un historique global, la prédiction tient compte d'éventuelles corrélations entre branchements consécutifs/proches pour faire les prédictions. C'est un avantage pour les applications qui enchaînent des if...else ou qui contiennent beaucoup de if...else imbriqués les uns dans les autres, qui s’exécutent plusieurs fois de suite. Le résultat de chaque if...else dépend généralement des précédents, ce qui rend l'utilisation d'un historique global intéressant. Par contre, ils sont assez mauvais pour le reste des branchements. Mais cette qualité devient un défaut quand elle détecte des corrélations fortuites ou parasites, inutiles pour la prédiction (corrélation n'est pas causalité, même pour prédire des branchements). Du fait de ce défaut, la prédiction globale a besoin de registres d'historiques globaux très larges, de plusieurs centaines de bits au mieux. [[File:2 level branch predictor.svg|centre|vignette|upright=2|Unités de prédiction de branchement à deux niveaux avec une seule PHT.]] Le circuit précédent a cependant un problème : si deux branchements différents s'exécutent et que l'historique est le même, le processeur n'y verra que du feu. Il y a alors une interférence, à savoir que les deux branchements vont agir sur le même compteur à saturation. On se retrouve alors dans une situation conceptuellement identique à l’''aliasing'' dans le BTB ou avec des compteurs à saturation. Sauf que les interférences sont alors beaucoup plus nombreuses et qu'elles sont clairement un problème pour les performances ! Mitiger les interférences devient alors bien plus important qu'avec les unités de prédiction précédentes. Heureusement, si augmenter la taille de la PHT peut mitiger le problème, d'autres solutions sont possibles sur les unités de prédiction globales. Elles sont au nombre de deux : mitiger les interférences liées aux branchements biaisés, ajouter des informations qui discriminent les branchements. Voyons ces solutions dans le détail. ===La mitigation des interférences par usage de l'adresse de branchement=== La première solution pour réduire l'impact de l’''aliasing'' est d'augmenter la taille de la PHT et de l'historique. Plus l'historique et la PHT sont grands, moins l’''aliasing'' est fréquent. Mais avoir des historiques et des PHT de très grande taille n'est pas une mince affaire et le cout en termes de performances et de portes logiques est souvent trop lourd. Une autre solution consiste à utiliser, en plus de l'historique, des informations sur le branchement lui-même pour choisir le compteur à saturation à utiliser. Typiquement, on peut utiliser l'adresse du branchement en plus de l'historique, pour choisir le compteur à saturation adéquat. Pour être précis, on n'utilise que rarement l'adresse complète du branchement, mais seulement les bits de poids faible. La raison est que cela fait moins de bits à utiliser, donc moins de circuits, mais l’''aliasing'' est juste atténué, pas éliminé. Des branchements différents ont beau avoir des adresses différentes, les bits de poids faible de leurs adresses peuvent être identiques. Des confusions sont donc possibles, mais sont assez rares pour que cela suffise en pratique. Une unité de prédiction de ce type est illustrée ci-dessous. Il y a plusieurs PHT séparées, donc plusieurs ensembles de 2^n compteurs à saturation. Le choix de la PHT est réalisé par l'adresse du branchement, alors que le choix du compteur à saturation est réalisé par l'historique. Les compteurs à saturation d'un branchement sont mis à jour seulement quand le branchement s'est chargé pendant que l'historique associé était dans le registre d'historique. Cette méthode a cependant le défaut de gâcher énormément de transistors. [[File:Unité de prédiction de branchement basée sur des compteurs à saturation sélectionnés par l'historique global.jpg|centre|vignette|upright=2|Unité de prédiction de branchement basée sur des compteurs à saturation sélectionnés par l'historique global]] D'autres unités de prédiction fonctionnent sur le même principe que l'unité précédente, sauf que les différentes PHTs sont fusionnées en une seule grosse PHT. C'est ce que font les unités de prédiction '''''gshare''''' et '''''gselect''''', ainsi que leurs dérivées. Avec les unités de prédiction '''''gselect''''', on concatène les bits de poids faible de l'adresse du branchement et l'historique pour obtenir le numéro du compteur à saturation à utiliser. [[File:Prédiction « gselect ».png|centre|vignette|upright=2|Prédiction « gselect ».]] Avec les unités de prédiction '''''gshare''''', on fait un XOR entre le registre d'historique et les bits de poids faible de l'adresse du branchement. Le résultat de ce XOR donne le numéro du compteur à utiliser. Avec cette technique, on utilise une PHT globale et un historique global, mais le registre d'historique est manipulé de manière à limiter l'''aliasing''. [[File:Prédiction « gshare ».png|centre|vignette|upright=2|Prédiction « gshare ».]] Intuitivement, on pourrait croire que les unités de prédiction gselect ont de meilleures performances. C'est vrai que les unités gshare combinent l'adresse du branchement et l'historique d'une manière qui fait perdre un peu d'information (impossible de retrouver l'adresse du branchement et l'historique à partir du résultat du XOR), alors que les unités gselect conservent ces informations. Mais il faut prendre en compte le fait que les deux unités doivent se comparer à PHT identique, de même taille. Prenons par exemple une PHT de 256 compteurs, adressée par 8 bits. Avec une unité gselect, les 8 bits sont utilisés à la fois pour l'historique et pour les bits de poids faible de l'adresse du branchement. Alors qu'avec une unité gshare, on peut utiliser un historique de 8 bits et les 8 bits de poids faible de l'adresse du branchement. L'augmentation de la taille de l'historique et du nombre de bits utilisés fait que l’''aliasing'' est réduit, et cela compense le fait que l'on fait un XOR au lieu d'une concaténation. Les unités précédentes réduisent l'''aliasing'' en segmentant la PHT : telles entrées sont allouées à un branchement, telles entrées à tel autre branchement, etc. Cependant, l'historique local n'est pas utilisé. Mais quitte à allouer des entrées à un branchement, autant en profiter pour leur associer son historique local. Il s'agit alors d'approches hybrides. Par exemple, on peut citer la '''prédiction mixte''' (''alloyed predictor''). Avec celle-ci, la table des compteurs à saturation est adressée avec la concaténation : de bits de l'adresse du branchement, du registre d'historique global et du registre d'historique local adressé par l'adresse du branchement. [[File:Prédiction mixte.jpg|centre|vignette|upright=2|Prédiction mixte.]] Une version optimisée de ce circuit permet d’accéder à la PHT et à la BHT en parallèle. Avec elle les bits de poids faible de l'adresse du branchement sont concaténés avec l'historique global. Le résultat est ensuite envoyé à plusieurs PHT locales distinctes, en parallèle. Les différences PHT envoient leurs résultats à un multiplexeur, commandé par l'historique local, qui sélectionne le résultat adéquat. L'inconvénient de cette mise en œuvre est que le circuit est assez gros, notamment en raison de la présence de plusieurs PHT séparées. [[File:Alloyed predictor optimisé.jpg|centre|vignette|upright=2|''Alloyed predictor'' optimisé]] ===La mitigation des interférences par la structuration en caches des PHTs=== Les techniques précédentes utilisent les bits de poids faible de l'adresse pour sélectionner la PHT adéquate, ou pour sélectionner le compteur dans la PHT. Mais le reste de l'adresse n'est pas utilisé. Cependant, il est théoriquement possible de conserver les bits de poids fort, afin d'identifier le branchement associé à un compteur. Pour cela, chaque compteur à saturation se voit associé à un ''tag'', qui mémorise le reste de l'adresse du branchement associé au compteur, comme pour les mémoires caches. Lorsque l'unité de prédiction démarre une prédiction, elle récupère les bits de poids fort de l'adresse du branchement et compare ceux-ci avec le ''tag''. S’il y a un ''match'', c'est signe que le compteur est bien associé au branchement à prédire. Dans le cas contraire, c'est signe qu'on fait face à une situation d’''aliasing'' et le compteur n'est pas utilisé pour la prédiction. Le résultat transforme la PHT en une sorte de cache assez particulier, qui associe un compteur à saturation à chaque couple adresse-historique. La technique du paragraphe précédent peut être encore améliorée afin de réduire l’''aliasing''. L’''aliasing'' sur une unité de prédiction de branchement est similaire aux conflits d'accès au cache, où deux données/adresses atterrissent dans la même ligne de cache. Une PHT peut formellement être vue comme un cache directement adressé. Une solution pour limiter ces conflits d'accès à de tels, est de les transformer en un cache associatif à n voies. On peut faire la même chose avec les unités de prédiction de branchement, ce qui revient à dupliquer les PHT, de manière à ce que les bits de poids faible de l'adresse se voient attribuer à plusieurs compteurs à saturation, un par branchement possible. Ce faisant, on réduit les interférences entre branchements. Mais cette technique augmente la quantité de circuits utilisés et la consommation d'énergie, ainsi que le temps d'accès, sans compter qu'elle requiert d'utilisation de ''tags'' pour fonctionner à la perfection. Pour les unités de prédiction de branchement, les mesures à ce sujet ne semblent pas montrer un impact réellement important sur les performances, comparé à une organisation de type directement adressé. Il est possible de pousser la logique encore plus loin en ajoutant des caches de victime et d'autres mécanismes courant sur les caches usuels. ===La mitigation des interférences liées aux branchements biaisés=== L’''aliasing'' a un impact négatif quand deux branchements aliasés (qui sont attribués au même compteur à saturation) vont dans des sens différents : si l'un est non-pris, l'autre a de bonnes chances d'être pris (ou inversement). Or, la plupart des branchements sont biaisés, à savoir très fortement orientés dans une direction qu'une autre (presque toujours pris ou presque toujours non-pris, à plus de 90/99% de chances). Plus un branchement a une probabilité élevée d' être pris ou non-pris, plus sa capacité à interférer avec les autres est forte. Le pire des cas est quand deux branchements sont biaisés mais dans des directions inverses : l'interférence est maximale. En conséquence, les branchements biaisés ou quasi biaisés sont les plus problématiques pour l’''aliasing''. Or, il est possible de concevoir des prédicteurs de branchements qui atténuent fortement les interférences des branchements biaisés ou quasi biaisés. C’est le cas de l’''agree predictor'' et de l'unité de prédiction bimodale, que nous allons voir dans ce qui suit. Les branchements très souvent pris vont interférer très fortement avec les branchements très souvent non-pris, et inversement. Par contre, les branchements très souvent pris n'interféreront pas entre eux, de même que les branchements non-pris iront bien ensemble. D'où l'idée de séparer ces deux types de branchements dans des PHT séparées, pour éviter le mauvais ''aliasing''. L''''unité de prédiction bimodale''' part d'une unité gshare, et sépare la PHT globale en deux : une PHT dédiée aux branchements qui sont très souvent pris et une autre pour les branchements très peu pris. Les deux PHT fournissent chacune une prédiction. La bonne prédiction est choisie par un multiplexeur commandé par une unité de sélection, qui se charge de déduire quelle unité a raison. Cette unité de sélection est une unité de prédiction basée sur des compteurs à saturation de 2 bits. On peut encore améliorer l'unité de sélection de prédiction en n'utilisant non pas des compteurs à saturation, mais une unité de prédiction dynamique à deux niveaux, ou toute autre unité vue auparavant. [[File:Prédiction bimodale.jpg|centre|vignette|upright=2|Prédiction bimodale.]] La '''prédiction par consensus''' (''agree predictor'') combine une unité de prédiction à historique global (idéalement de type gshare ou gselect, mais une unité normale marche aussi) et des compteurs à saturation. Il s'agit techniquement d'une technique de prédiction mixte, à la fois globale et locale. Le BTB intègre un compteur à saturation de un bit, qui indique la direction privilégiée du branchement. L'unité à historique global a un fonctionnement changé : elle calcule non pas la probabilité que le branchement soit pris ou non-pris, mais la probabilité que le résultat du branchement soit compatible avec le résultat de l'unité de prédiction de 1 bit. La prédiction finale vérifie que ces deux circuits sont d'accord, en faisant un NXOR entre les résultats des deux unités de prédiction de branchement. [[File:Prédiction par consensus.png|centre|vignette|upright=2|Prédiction par consensus.]] Des calculs simples de probabilités montrent que l'''agree predictor'' a des résultats assez importants. Ses résultats sont d'autant meilleurs que les branchements sont biaisés et penchent fortement vers le côté pris ou non-pris. ==La prédiction de branchement avec des perceptrons== Les méthodes précédentes utilisent au moins un compteur à saturation par historique possible. Sachant qu'il y a 2^n valeurs possibles pour un historique de n bits, le nombre de compteurs à saturation augmente exponentiellement avec la taille de l'historique. Ce qui limite grandement la taille de l'historique, alors qu'on aurait au contraire besoin d'un historique assez large pour faire des prédictions excellentes. Pour contourner le problème, des alternatives basées sur des techniques d'apprentissage artificiel (''machine learning'') existent. Un exemple est celui des unités de prédiction de branchement dites de ''neural branch prediction'', basée sur des perceptrons. Dans ce qui suit, nous les appelerons des '''unités perceptron'''. Les perceptrons sont un algorithme d'apprentissage automatique très simple, ce qui fait que les explications qui vont suivre devraient être simples. En théorie, il est possible d'utiliser des techniques d'apprentissage automatique plus élaborées, mais cela n'est pas possible en pratique. Rappelons qu'une unité de prédiction de branchement doit idéalement fournir sa prédiction en un cycle d'horloge. L'implémentation d'un perceptron est déjà problématique de ce point de vue, car elle demande d'utiliser beaucoup de circuits arithmétiques complexes (multiplication et addition). Autant dire que les autres techniques de ''machine learning'', encore plus gourmandes en calculs, ne sont pas praticables. ===Les perceptrons et leur usage en tant qu'unité de prédiction de branchements=== L'idée derrière les unités perceptron est que certains bits de l'historique sont plus importants que d'autres. En soi, ce genre de situation est réaliste. Il arrive que certains branchements soient pris uniquement si les deux branchements précédents ne le sont pas, ou que si l'avant-dernier branchement est lui aussi pris. Mais les autres résultats de branchement présents dans l'historique ne sont pas utiles. En conséquence, deux historiques semblables, qui ne sont différents que d'un ou deux bits, peuvent donner des prédictions presque identiques. Dans ce cas, on peut faire la prédiction en n'utilisant que les bits identiques entre ces historiques, ou du moins en leur donnant plus d'importance qu'aux autres. On exploite alors des corrélations avec certains bits de l'historique, plutôt qu'avec l'historique entier lui-même. Pour coder ces corrélations, on associe un coefficient à chaque bit de l'historique, qui indique à quel point ce bit est important pour déterminer le résultat final. Ce coefficient est proportionnel à la corrélation entre le bit de l'historique associé, et le résultat du branchement à prédire. Les coefficients sont des nombres entiers qui peuvent être positifs, nuls ou négatifs. Un coefficient positif signifie que si le branchement associé a été pris, alors le branchement à prédire a de bonnes chances de l'être aussi (et inversement). Un coefficient nul signifie que les deux branchements sont indépendants et que le bit de l'historique associé n'est pas à prendre en compte. Pour un coefficient négatif, cela signifie que si le branchement associé a été pris, alors le branchement à prédire a de bonnes chances d'être non-pris (et inversement). Les coefficients en question sont généralement compris entre -1 et 1. Une fois qu'on connait ces coefficients de corrélations, on peut calculer la probabilité que le branchement à prédire soit pris, avec des calculs arithmétiques simples. Dans les calculs qui vont suivre, un branchement pris est codé par un 1, alors qu'un branchement non-pris est codé par un -1 ! Ce détail permet de simplifier grandement les calculs. Dans sa version la plus simple, le perceptron calcule un nombre Z qui est positif ou nul si le branchement est pris, négatif s'il ne l'est pas. Tous les coefficients <math>p_i</math> sont alors des nombres relatifs, pouvant être positifs, nuls ou négatifs. Ils sont généralement compris entre -1 et 1. La formule devient donc celle-ci : : <math>Z = w_0 + \sum_i (h_i \times w_i)</math> [[File:ArtificialNeuronModel francais.png|centre|vignette|upright=2|Perceptron.]] : Sur le plan mathématique, le perceptron effectue un produit scalaire entre deux vecteurs : l'historique et le vecteur des poids. Choisir les coefficients adéquats est le but de l'algorithme du perceptron. L'unité de prédiction part de poids par défaut, qui sont mis à jour à chaque bonne ou mauvaise prédiction. Toute la magie de cet algorithme tient dans la manière dont sont mis à jour les poids. L'idée est de prendre le vecteur des poids, puis d'ajouter ou soustraire l'historique suivant le résultat du branchement. Les poids évoluent ainsi à chaque branchement, améliorant leurs prédictions d'une exécution à l'autre. La règle de mise à jour des poids est donc la suivante : : <math>w_i \leftarrow w_i + t \times h_i</math>, avec t = 1 pour un branchement pris, -1 pour un branchement non-pris. ===L'implémentation matérielle des perceptrons et ses optimisations=== Les calculs réalisés par un perceptrons sont simples, juste des multiplications et des additions et cela se sent dans la conception du circuit. L'unité perceptron utilise un perceptron par entrée du BTB, un par branchement pour simplifier. Pour cela, on utilise une mémoire SRAM qui mémorise les poids de chaque perceptron. La SRAM est adressée par les bits de poids faible du ''program counter'', de l'adresse du branchement. Une fois les poids récupérés, ils sont envoyés à un circuit de calcul de la prédiction, composé de circuits multiplieurs suivis par un additionneur multi-opérande. Le circuit de mise à jour des poids récupère la prédiction, les poids du perceptron, mais aussi le résultat du branchement. La mise à jour étant une simple addition, le circuit est composé d'additionneurs/soustracteurs, couplés à des registres pour mémoriser la prédiction et les poids du perceptron en attendant que le résultat du branchement soit disponible. Vu qu'il y a un délai de quelques cycles avant que le résultat du branchement soit disponible et que les prédictions doivent continuer pendant ce temps, les poids du perceptron et la prédiction sont stockés dans des FIFOs lues/écrites à chaque cycle. [[File:Unité de prédiction de branchement basée sur des perceptrons.png|centre|vignette|upright=2|Unité de prédiction de branchement basée sur des perceptrons]] Rien de bien compliqué sur le principe, mais rappelons que le perceptron doit fournir un résultat en moins d'un cycle d'horloge, et cela tient compte de l'accès au BTB. C'est un temps très court, surtout pour un circuit qui implique des multiplications. Les multiplications prennent facilement deux à trois cycles d'horloge. Autant dire que si on ajoute le temps d'accès à une petite SRAM, pour récupérer le perceptron, c'est presque impossible. Mais quelques optimisations simples permettent de rendre le calcul plus rapide. Premièrement, la taille de la SRAM est réduite grâce à quelques économies assez simples. Notamment, le perceptron utilise des poids codés sur quelques bits, généralement un octet, parfois 7 ou 5 bits, guère plus. Les poids sont encodés en complément à 1 ou en complément à 2, là où les perceptrons normaux utilisent des nombres flottants. Rien que ces deux choix simplifient les circuits et les rendent plus rapides. Le désavantage d'utiliser peu de bits par poids est une perte mineure en termes de taux de prédiction, qui est plus que compensée par l'économie en portes logiques, qui permet d'augmenter la taille de la SRAM. D'autres optimisations portent sur le circuit de calcul. Par exemple, la multiplication <math>h_i \times w_i</math> est facultative. Vu que l'historique contient les valeurs 1 et -1, il suffit d'additionner le poids associé si la valeur est 1 et de le soustraire s'il vaut -1. On peut même simplifier la soustraction et se limiter à une complémentation à un (une inversion des bits du poids). En faisant cela, les circuits multiplieurs disparaissent et le circuit de prédiction se résume à un simple additionneur multi-opérande couplé à quelques inverseurs commandables. Certains perceptrons prennent en compte à la fois l'historique global et l'historique local pour faire leur prédiction. Les deux historiques sont simplement concaténés et envoyés en entrée du perceptron. Cela marche assez bien, car les perceptrons peuvent utiliser des historiques de grande taille sans problèmes. Par contre, il y a besoin d'ajouter une ''branch history table'' au circuit, ce qui demande des portes logiques en plus, mais aussi un temps de calcul supplémentaire (l'accès à cette table n'est pas gratuit). ===Les avantages et inconvénients des perceptrons pour la prédiction de branchements=== L'avantage des unités perceptron est qu'elles utilisent un nombre de portes logiques qui est proportionnel à la taille de l'historique, et non pas exponentiel. Cela permet d'utiliser un historique de grande taille, et donc d'obtenir un taux de bonnes prédictions très élevé. Leur désavantage est que leurs performances de prédiction sont moins bonnes à historique équivalent. Les unités perceptrons ont des taux de prédiction élevés, mais seulement quand certaines conditions mathématiques sont respectées par les historiques. La condition en question est que les historiques correspondants à un branchement pris et les historiques où ce même branchement est non-pris doivent être linéairement séparables. Heureusement, les branchements des programmes informatiques sont une majorité à respecter la condition de séparation linéaire, ce qui rend les perceptrons parfaitement adaptés à la prédiction de branchements. La notion de linéairement séparable est un peu difficile à comprendre. Pour l'expliquer, imaginez que les N bits de l'historique forme un espace à N-dimensions discrets. Chaque historique est un point dans cet espace N-dimensionnel et chaque bit sert de coordonnée pour se repérer dans cet espace. Une fonction est linéairement séparable si l'espace N-dimensionnel peut être coupé en deux par un hyperplan : d'un côté de ce plan se trouvent les historiques des branchements pris, de l'autre se trouvent les historiques des branchements non-pris. Cet hyperplan est défini par l'ensemble des points qui respecte l'équation suivante : : <math>w_0 + \sum_i (h_i \times w_i) = 0</math> Un exemple où cette condition est respectée est quand le résultat d'une prédiction se calcule avec un ET entre plusieurs branchements de l'historique. Si un branchement est pris quand plusieurs branchements de l’historique sont tous pris ou tous non-pris, la condition est respectée et le perceptron donnera des prédictions correctes. Un exemple où cette condition n'est pas respectée est un banal XOR. Prenons l'exemple d'un historique global de 2 bits. Imaginons que le branchement à prédire soit pris : soit quand le premier branchement de l'historique est pris et le second non-pris, soit quand le premier est non-pris et le second pris. Concrètement, la prédiction exacte se calcule en faisant un XOR entre les deux bits de l'historique. Dans ce cas, la condition "linéairement séparable" n'est pas respectée et le perceptron n'aura pas des performances optimales. Les unités perceptron utilisent idéalement un perceptron par branchement. Cela demande d'associer, l'adresse du branchement pour chaque perceptron, généralement en donnant un perceptron par entrée du BTB. Mais une telle organisation n'est pas possible en pratique, car elle demanderait d'ajouter un ''tag'' à chaque perceptron/entrée du BTB, ce qui demanderait beaucoup de circuits et de ressources matérielles. Dans les faits l'association entre un branchement et un perceptron se fait en utilisant les bits de poids faible de l'adresse de branchement. Mais faire cela entrainera l'apparition de phénomènes d'''aliasing'', comme pour les unités de prédiction à deux niveaux. Les techniques vues précédemment peuvent en théorie être adaptées sur les unités à perceptrons, dans une certaine mesure. Notons que les perceptrons sont des algorithmes ou des circuits qui permettent de classer des données d'entrée en deux types. Ici, la donnée d'entrée est l'historique global et la sortie du perceptron indique si le branchement prédit est pris ou non-pris. Ils ne sont donc pas adaptés à d'autres tâches de prédiction, comme pour le préchargement des lignes de cache ou la prédiction de l'adresse de destination d'un branchement, ou toute autre tâche d'exécution spéculative un tant soit peu complexe. Leur utilisation reste cantonnée à la prédiction de branchement proprement dit, guère plus. Par contre, les autres techniques vues plus haut peuvent être utilisées pour le préchargement des lignes de cache, ou d'autres mécanismes de prédiction plus complexes, que nous aborderons dans la suite du cours. ==La prédiction avec plusieurs unités de branchement distinctes== Il est possible de combiner plusieurs unités de prédiction de branchement différentes et de combiner leurs résultats en une seule prédiction. On peut par exemple combiner une unité de prédiction spécialisée dans les boucles, une unité de prédiction 2 bits basée sur des compteurs à saturation, une unité de prédiction statique et une unité ''gshare''. Tout le problème est de décider quelle unité a fait la bonne prédiction, comment combiner les résultats des différentes unités de prédiction. Soit on choisit un résultat qui parait plus fiable que les autres, soit on combine les résultats pour faire une sorte de moyenne des prédictions. La méthode la plus simple de choisir le résultat d'une unité de prédiction parmi toutes les autres. Pour implémenter le tout, il suffit d'un multiplexeur qui effectue le choix de la prédiction. Reste à commander le multiplexeur, ce qui demande un '''circuit méta-prédicteur''' qui sélectionne la bonne unité de prédiction. Il arrive que le circuit méta-prédicteur soit conçu avec les mêmes techniques que les unités de prédiction de branchement elle-mêmes. Dans le cas le plus simple, il s'agit d'un simple compteur à saturation, mis à jour suivant la concordance des prédictions avec le résultat effectif du branchement. Mais c'est loin d'être systématique. Une autre solution combine le résultat des différentes unités de prédiction avec une fonction mathématique adéquate. Avec un nombre impair d'unités de prédiction, une méthode assez efficace est de simplement prendre le résultat majoritaire. Si une majorité d'unités pense que le branchement est pris, alors on le considère comme pris, et comme non pris dans le cas contraire. Par contre, avec un nombre pair d'unités, cette méthode simple ne marche pas, il y a un risque d'égalité. L'unité de prédiction de branchement « '''''e-gskew''''' » utilise trois unités gshare et combine leurs résultats avec un vote à majorité. Les trois unités gshare utilisent des XOR légèrement différents, dans le sens où les bits de l'adresse du branchement choisi ne sont pas les mêmes dans les trois unités, sans compter que le résultat du XOR peut subir une modification qui dépend de l'unité de prédiction choisie. Autrement dit, la fonction de hachage qui associe une entrée à un branchement dépend de l'unité. [[File:Unité de prédiction « e-gskew ».png|centre|vignette|upright=2|Unité de prédiction « e-gskew ».]] Enfin, une dernière solution est que certaines unités de prédiction ont la priorité sur toutes les autres. Par exemple, prenons un processeur avec un ''return stack predictor'', une unité qui détecte les boucles et une unité de prédiction avec un historique. Si le ''return stack predictor'' détecte une instruction de retour de fonction, sa prédiction passera avant toutes les autres unités de prédiction. Et dans ce qui va suivre, c'est cette solution qui sera utilisé pour les unités de prédiction qu'on va étudier. ===La prédiction des branchements des boucles=== Les boucles sont à part, pour ce qui est de la prédiction de branchement. Rappelons qu'une boucle FOR/WHILE répète une série d'instruction N fois, le nombre N étant appelé le '''compteur de boucle'''. Ce qui fait que les branchements d'une boucle FOR sont pris n − 1 fois, la dernière exécution étant non prise. La conséquence est que les unités de prédiction usuelles sont assez mauvaises pour prédire les branchements des boucles. Par contre, la prédiction statique marche particulièrement bien comparé au reste. La prédiction statique prédit que les branchements des boucles sont toujours pris, ce qui donne des prédictions qui sont d'autant meilleures que la boucle est répétée un grand nombre de fois. Reste à gérer les branchements des boucles au mieux, en sachant cela. L'idée est d'utiliser plusieurs unités de prédiction séparées : une spécialisée dans les boucles et d'autres unités de prédiction. Uen unité de méta-prédiction se charge d'activer l'unité de prédiction des boucles dès qu'une boucle est détéctée. Les unités de prédiction modernes essayent de détecter les branchements des boucles afin de les mettre à part. En soi, ce n'est pas compliqué les branchements des boucles sont presque toujours des branchements descendants et réciproquement. De plus, ces branchements sont pris à chaque répétition de la boucle, et ne sont non-pris que quand on quitte la boucle. Le compteur de boucle analyse l'exécution des branchements, pour détecter les branchements des boucles. Un branchement est considéré comme un branchement de boucle si : il est pris N fois, avant de ne pas être pris une fois. Au moment où le branchement n'est plus pris, le ''Loop Predictor'' lui attribue une entrée dédié. L'entrée mémorise mémorise le nombre de fois que ce branchement a été pris, à savoir N, et est associée à un compteur. Le compteur est incrémenté/décrémenté à chaque exécution du branchement, et sa valeur est comparée à la valeur N. Quand le compteur atteint N+1, il est réinitialisé. Il s'agit d'une implémentation simple, mais est visiblement proche de celle utilisée sur le Pentium M, si j'en crois l'article "The Intel Pentium M Processor : Microarchitecture and Performance". Il faut noter que la boucle doit s'exécuter au moins une fois pour être détectée, et seules les exécutions suivantes sont prédites correctement. La boucle doit donc s'exécuter plusieurs fois, ce qui implique qu'elle est elle-même dans une boucle plus grande qu'elle. Une telle unité est donc capable de prédire spécifiquement les branchements de boucles FOR imbriquées, à savoir où une boucle FOR est dans une autre boucle FOR. Concrètement, cela permet de répéter la première boucle FOR plusieurs fois de suite, souvent un grand nombre de fois. Avec les boucles imbriquées, le compteur de boucle peut changer d'une répétition à l'autre, mais ce n'est que rarement le cas. Dans de nombreux cas, le compteur de boucle reste le même d'une répétition à l'autre. Ce qui fait qu'une fois la boucle exécutée une première fois, on peut prédire à la perfection les exécutions suivantes. Pour cela, il suffit de déterminer le compteur de boucle après la première exécution de la boucle, puis de le mémoriser dans un compteur. Lors des exécutions suivantes de la boucle, on compte le nombre de fois que le branchement de la boucle s'exécute : il est prédit comme pris tant que le compteur est différent du compteur de boucle, mais non-pris en cas d'égalité. ===Le prédicteur L-TAGE=== Il est possible d'utiliser plusieurs unités de prédiction, chacune utilisant des historiques de taille différente. Pour donner un exemple simple, on pourrait avoir une unité avec un historique de 2 bits, une autre avec un historique de 3 bits, une autre de 4 bits, etc. Une méta-unité de branchement prédit alors quelle taille d'historique donne les meilleurs résultats. Il est fréquent que les tailles d'historiques utilisées suivent une formule mathématique précise, appelée suite géométrique, ce qui est un mot bien barbare pour dire que l'on passe d'un historique au suivant en multipliant sa taille par une constante bien précise. Par exemple, on pourrait avoir des historiques de taille 2, 4, 8, 16, 32. C'est d'ailleurs le principe derrière les '''unités de branchement TAGE (''TAgged GEometric length predictor'')'''. Elles utilisent plusieurs registres d'historiques, chacun avec sa propre PHT, dont la taille suit une suite géométrique. Avec cette méthode, les branchements qui ont un motif répétitif sont alors parfaitement détectés, peu importe sa taille. Par exemple, un branchement avec un historique dont le cycle est pris, non-pris, non-pris, sera parfaitement prédit avec un historique de 3 bits, 6 bits, ou de 3*n bits, mais pas forcément avec un historique de 4 ou 8 bits. De ce fait, utiliser plusieurs unités de branchement avec plusieurs tailles d'historique fonctionne plutôt bien. Le prédicteur TAGE a été raffiné avec le temps, sur plusieurs points, que je ne vais pas détailler dans ce qui suit. Précisons cependant que les PHT sont structurées en caches, afin d'éliminer les phénomènes d'''aliasing''. L'ajout d'une unité de prédiction pour les boucles, a donné le '''prédicteur L-TAGE''', qui a lui-même été amélioré par la suite. Les unités de branchement TAGE sont les plus utilisées au moment où j'écris ces lignes (2026), et la majorité des processeurs x86 commerciaux utilisent une variante de ce prédicteur, à quelques exceptions près. ===La prédiction de branchement à deux niveaux=== Les processeurs modernes utilisent une prédiction de branchement à deux niveaux. Une première unité de branchement fournit des résultats rapides et peu précis, la seconde a des résultats plus précis mais fournit ses résultats après quelques cycles d'horloge. La première unité envoie les adresses prédites au cache d'instruction, pour charger les instructions prédites et met à jour le ''program counter'' en conséquence. La seconde envoie les adresses prédites à l'étage de décodage d'instruction, histoire d'annuler des branchements considérés à tort comme pris/non-pris. Il y a donc une unité de prédiction rapide, dont le résultat est corrigé par une seconde unité. La seconde unité a le dernier mot, elle corrige ou annule les prédictions de la première. Ce qui lui vaut le nom, en anglais, d'''overiding predictor''. La correction se fait simplement en corrigeant le ''program counter'' et en forçant le décodeur à émettre des NOPs pendant quelques cycles, le temps d'éliminer les instructions chargées à tort. [[File:Double BTB sur les CPU modernes.png|centre|vignette|upright=2|Double BTB sur les CPU modernes.]] Les deux unités de prédiction n'utilisent pas le même algorithme. Par exemple, la microarchitecture Silvermont d'Intel utilisait deux unités de prédiction : une unité rapide basée sur des compteurs à saturation, et un prédicteur ''gshare'' lent. Les deux unités de prédiction utilisent des BTB séparés, de taille différente, au moins deux. Vu que les BTB sont des caches, on les appelle des '''BTB de niveau L1 et L2'''. Par exemple, les processeurs Bulldozer d'AMD disposent de deux BTBs : un BTB L1 de 512 entrées, et un BTB L2 de 5120 entrées. Le BTB de niveau L2 est souvent un cache de victime, qui mémorise les branchements retirés de la BTB de niveau L1. La BTB pour les branchements indirects vient en plus de ces deux BTBs, elle complète souvent la BTB de niveau L2. Le ''return stack predictor'' vient en plus des BTB L1 et L2, mais sa petite taille et sa rapidité font qu'il complète l'unité/BTB de niveau L1. Quelques processeurs ont trois niveaux de BTB, avec un niveau L0, L1 et L2. La BTB L0 aliment le cache d'instruction, alors que la L1 et la L2 préviennent les décodeurs d'instruction. La différence entre la L1 et la L2 est que la L2 met plus de temps à prévenir les décodeurs. Les branchements peuvent donc être annulés/forcés à deux étages de décodage différents. Pour donner quelques exemples, les processeurs Zen d'AMD disposent de plusieurs BTBs, avec des niveaux L0, L1 et L2. Le tableau ci-dessous vous donne la taille et la latence de chaque niveau de BTB. La BTB de niveau L0 alimente le cache d'instruction, la L1 permet d'annuler les instructions au premier cycle de décodage, la BTB L2 annule les instructions vers le milieu/fin du décodage. {|class="wikitable" |- ! rowspan="3" | Zen 2 | 16 || 0 |- | 512 || 1 |- | 7168 || 4 |- ! rowspan="3" | Zen 3 | 16 || 0 |- | 1024 || 1 |- | 6656 + 1536 (branchements indirects)|| 4 |} ==Les optimisations de la prédiction de branchements== La prédiction de branchement est complexe et toute économie est bonne à prendre. Pour améliorer les performances, ou simplement améliorer l'utilisation du BTB et des PHT, diverses techniques ont été inventées. Ces techniques marchent quel que soit l'unité de prédiction prise en compte. Elles peuvent s'appliquer aussi bien aux unités basées sur des perceptrons, que sur des unités à deux niveaux ou des unités de prédiction dynamique en général. Ces techniques sont assez variés : certaines profitent du fait que de nombreux branchements sont biaisés, d'autres tentent de réduire les interférences entre branchements sans utiliser l'historique global/local, etc. ===Le filtrage de branchements=== Une première optimisation permet d'économiser l'usage des différentes ressources matérielles, comme la BTB ou les PHT, en les réservant aux branchements non-biaisés. Idéalement, les branchements biaisés peuvent être prédits en utilisant des techniques très simples. D'où l'idée d'utiliser deux unités de prédiction spécialisées : une pour les branchements biaisés et une autre plus complexe. L'unité de prédiction pour les branchements biaisés est une unité de prédiction à 1 bit qui indique si le branchement est pris ou non, ce qui suffit largement pour de tels branchements. Cette technique s'appelle le '''filtrage de branchement''' et son nom est assez parlant. On filtre les branchements biaisés pour éviter qu'ils utilisent des PHT et d'autres ressources qui gagneraient à être utilisées pour des branchements plus difficiles à prédire. Appliquer cette idée demande de reconnaître les branchements fortement biaisés d'une manière ou d'une autre, ce qui est plus facile à dire qu'à faire. Une solution similaire enregistre quels branchements sont biaisés dans le BTB, et utilise cette information pour faire des prédictions. ===L'usage de fonctions de hashage pour indexer les diverses SRAM/tables=== Plus haut, nous avons vu que les unités de prédiction de branchement contiennent des structures qui sont adressées par l'adresse de branchement. Tel est le cas du ''Branch Target Buffer'', de certaines PHT globales ou locales, de la ''Branch History Table'' qui stocke les historiques locaux, ou encore de la SRAM des poids des unités à base de perceptrons. En pratique, ces structures sont adressées non pas par l'adresse de branchement complète, mais pas les bits de poids faible de cette adresse. Cela a pour conséquence l'apparition d'un ''aliasing'' lié au fait que ces structures vont confondre deux branchements pour lesquels les bits de poids faible de l'adresse sont identiques. Il est possible d'utiliser autre chose que les bits de poids faible de l'adresse du branchement, afin de limiter les interférences. Et les possibilités sont multiples. Les possibilités en question s'inspirent des traitements effectués sur les adresses des banques dans les mémoires évoluées. Une première solution serait de faire un XOR entre les bits de poids faible de l'adresse du branchement, et d'autres bits de cette même adresse. Ainsi, deux branchements éloignés en mémoire donneraient des résultats différents, même si leurs bits de poids faible sont identiques. Une autre possibilité serait de diviser l'adresse du branchement par un nombre et de garder le reste. Ce calcul de modulo n'est en soi pas très différent du fait de conserver seulement les bits de poids faible. Conserver les N bits de poids faible consiste en effet à prendre le modulo par 2^N de l'adresse. Ici, l'idée serait de faire un modulo par un nombre P, qui serait un nombre premier proche de 2^N. Le fait que le nombre soit premier limite les cas où deux adresses différentes donneraient le même reste, ce qui réduit l'''aliasing''. Le fait de prendre un nombre proche de 2^N pour une entrée de N bits est que le résultat reste assez proche de ce qu'on obtiendrait en gardant les bits de poids faible, cette dernière étant une solution pas trop mauvaise. D'autres possibilités similaires se basent sur des réductions polynomiales ou d'autres astuces impliquant des nombres premiers. L'efficacité de ces méthodes dépend grandement de la taille de la SRAM/table considérée, des adresses des branchements et de beaucoup d'autres paramètres. La réduction des interférences par telle ou telle méthode dépend aussi de l'unité de prédiction considérée. Les résultats ne sont pas les mêmes selon que l'on parle d'une unité à perceptrons ou d'une unité à deux niveaux ou d'unités à base de compteurs à saturation, ou que l'on parle du BTB. Par contre, toutes les méthodes ne sont pas équivalentes en termes de temps de calcul ou de portes logiques. Autant la première solution avec un XOR ajoute un temps de calcul négligeable et quelques portes logiques, autant les autres méthodes requièrent l'ajout d'un diviseur très lent et gourmand en portes logiques pour calculer des modulos. Elles ne sont généralement pas praticables pour ces raisons, le temps de calcul serait trop élevé. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Le pipeline | prevText=Le pipeline | next=Les optimisations du chargement des instructions | nextText=Les optimisations du chargement des instructions }} </noinclude> 1jxwdjq4v7ro70tdpe7fybcg09gg5l9 774404 774403 2026-10-07T21:31:50Z Mewtow 31375 /* La prédiction de l'adresse de destination */ 774404 wikitext text/x-wiki Les branchements sont pris en charge par plusieurs étages du pipeline. Exécuter un branchement demande de savoir deux choses : s'il est pris ou non, quelle est l'adresse de destination. L'adresse de destination est localisée grâce au mode d'adressage du branchement. Avec le mode d'adressage direct, elle est extraite de l'instruction par le séquenceur. Avec l'adressage indirect, elle est lue dans un registre. Avec les branchements relatifs, elle est calculée via un additionneur, qui additionne un décalage au ''program counter''. L'additionneur peut être soit dans l'étage de ''PC Update'', soit dans l'unité de calcul de l'étage d'exécution. Passons maintenant au résultat du branchement, à savoir s'il est pris ou non. Pour savoir si le branchement est pris, il faut distinguer les branchements conditionnels et inconditionnels. Pour rappel, les branchements inconditionnels regroupent les appels de fonction, les simples instructions de saut, et quelques autres. Ils sont toujours pris, ils s'exécutent toujours. À partir du moment où le décodeur a décodé un branchement inconditionnel, il sait que le branchement est pris. Pour les branchements conditionnels, ils sont pris seulement si une condition précise est respectée. On sait s'ils sont exécutés une fois que la condition est calculée par l'unité de calcul, ou par une unité de branchement spécialisée. [[File:Gestion des branchements sur un pipeline.png|centre|vignette|upright=2.0|Gestion des branchements conditionnels sur un pipeline.]] Sur le schéma précédent, les branchements inconditionnels ne sont pas montrés. Pour cela, il faudrait rajouter une connexion du décodeur vers le multiplexeur de branchement. De plus, il faudrait aussi rajouter de quoi retarder l'exécution du branchement seulement une fois que l'adresse est connue. Par exemple, elle est connue un cycle d'horloge plus si l'adresse est lue depuis les registres, comparé à un branchement direct. ==Les branchements et les exceptions imprécises/précises== Un branchement est censé altérer le ''program counter'' immédiatement, en un cycle d'horloge. Mais sur un pipeline, Le branchement fait bien reprendre le processeur au bon endroit, mais avec du retard. La raison est qu'un branchement met plusieurs cycles pour s'exécuter. Il faut plusieurs cycles pour déterminer quelle est l'adresse de destination, temps qui dépend de là où est lue l'adresse. Et on sait si le branchement est pris seulement au niveau du décodeur, voire au niveau de l'unité de calcul. Le branchement doit traverser plusieurs étages avant qu'on connaisse son résultat. Il y a bien 3 à 4 cycles avant cela. Et pendant ce temps de trajet, des instructions seront chargées dans le pipeline, potentiellement à tort. Si le branchement n'est pas pris, ce n'est pas grave : les instructions n'ont pas été chargées à tort. Mais dans le cas contraire, les instructions n'auraient pas dû rentrer dans le pipeline. Le problème est similaire à celui observé avec les exceptions précises/imprécises. La différence est que les branchements sont des instructions machines, pas les exceptions matérielles. Mais le problème des instructions chargées à tort est le même dans les grandes lignes. Une différence est que les branchements sont résolus plus ou moins au même étage, alors que les exceptions peuvent survenir dans tout le pipeline. Une autre différence, bien plus importante, est que les branchements non-pris ne chargent pas d'instructions à tort et qu'il faut faire la différence entre branchement pris et non-pris. Dans la suite, nous parlerons d'instructions fautives pour parler des instructions chargées à tort. Pour corriger ce problème, il existe une solution logicielle. Le compilateur fait suivre les branchements par des instructions qui ne font rien, des NOP (No OPeration) : c'est ce qu'on appelle un '''délai de branchement'''. L'idée est que toutes les instructions chargées à tort sont des NOPs, qui ne font rien. Mais le nombre de NOP à ajouter dépend du pipeline du processeur, ce qui peut poser des problèmes de compatibilité. Mais d'autres solutions, matérielles cette fois, sont utilisées de nos jours. ===L'annulation des instructions au décodage=== Les branchements inconditionnels sont dans un cas plus favorable : ils sont identifiés dans l'unité de décodage et sont toujours pris. D'autres branchements sont dans ce cas : les appels de fonctions, les interruptions logicielles. Avec eux, le décodeur annule les instructions fautives, il les remplace par des instructions NOP. L'étage de décodage sait que les instructions suivant un branchement inconditionnel ont été chargées à tort. Précisément N instructions, avec N le nombre d'étages entre le chargement et le décodage (inclus). Les N instructions suivantes sont tout simplement annulés, elles sont remplacées par des NOPs. L'implémentation demande juste un simple compteur et quelques circuits annexes. Cependant, la technique ne marche pas du tout pour les branchements conditionnels. En effet, on ne sait pas s'ils sont pris ou non à l'étage de décodage. Du moins pas avec un pipeline normal. Mais certains processeurs, basés sur le pipeline RISC classique, calculaient les branchements pendant l'étape de décodage, dans une ALU spécialisée qui travaille en parallèle de l'unité de décodage. Le défaut de cette technique est que l'ALU en question doit lire les registres. Pour rappel, sur ce pipeline, l'étape de décodage effectue la lecture des opérandes dans les registres, en parallèle de l'unité de décodage. Mais ce qu'on n'a pas dit dans le chapitre précédent, c'est qu'elle effectue aussi le calcul des branchements dans une mini-ALU séparée ! Ce faisant, le résultat du branchement est connu dès la fin de l'étape de décodage. ===L'usage du système d'exceptions précises pour les branchements conditionnels=== La solution gère le problème en empêchant les instructions chargées à tort d'enregistrer leurs résultats, afin qu'elles n'aient pas le moindre effet sur l'état du processeur. Le hardware pour les exceptions précises, décrit quelques paragraphes avant, est réutilisé pour cela. Les branchements conditionnels peuvent être pris ou non-pris et on doit tenir compte de ces deux cas. Si le branchement n'est pas pris, les instructions n'ont pas été chargées à tort, elles peuvent enregistrer leur résultat dans les registres. Par contre, ce n'est pas le cas pour les branchements pris, les mécanismes d'exceptions précise doivent alors d'activer. Pour cela, la meilleure solution est que l'unité de calcul génère un indicateur d'exception si le branchement n'est pas pris. L'ALU effectue la comparaison et génère ou non l'indicateur d'exception adéquat suivant le résultat de celle-ci. L'idée est d'utiliser un indicateur d'exception particulier, qui précise qu'un branchement a été pris, signe que des instructions ont été chargées à tort. L'indicateur est propagé dans le pipeline, jusqu'à atteindre l'étage d'enregistrement dans les registres. Cet étage décide s'il faut autoriser ou non les écritures qu'il recoit, pour annuler les instructions chargées à tort. Le ''program counter'' est corrigé immédiatement, simplement en exécutant le branchement. Reste à savoir qui génère cet indicateur d'exception de branchement pris. Diverses optimisations visent à éliminer les instructions chargées à tort, à faire en sorte que des instructions utiles soient chargées. Le chapitre suivant va détailler ces optimisations dans le détail, qui sont regroupées sous le terme de prédiction de branchement. Elles ne fonctionnent pas pour les exceptions précises, seulement les branchements. ===La prédiction de branchement=== Les techniques précédentes ont pour défaut que de nombreux cycles d'horloge sont gâchés à cause des instructions fautives. Pour éviter ce gâchis, les concepteurs de processeurs ont inventé l'''exécution spéculative de branchement'', qui consiste à deviner l'adresse de destination du branchement et charger les bonnes instructions dès le départ. Le but est de ne pas charger d'instruction à tort, en prédisant quelles seront les bonnes instructions à charger. Et cela demande de résoudre trois problèmes : * reconnaître les branchements ; * savoir si un branchement sera exécuté ou non : c'est la '''prédiction de branchement''' ; * dans le cas où un branchement serait exécuté, il faut aussi savoir quelle est l'adresse de destination : c'est la '''prédiction de l'adresse de destination''' d'un branchement, aussi appelée ''branch target prediction''. Pour résoudre le second problème, le processeur contient un circuit qui déterminer si le branchement est pris (on doit brancher vers l'adresse de destination) ou non pris (on poursuit l’exécution du programme immédiatement après le branchement) : c'est l''''unité de prédiction de branchement'''. La prédiction de direction de branchement est elle aussi déléguée à un circuit spécialisé : l’'''unité de prédiction de direction de branchement'''. C'est l'unité de prédiction de branchement qui autorise l'unité de prédiction de destination de branchement à modifier le ''program counter''. Dans certains processeurs, les deux unités sont regroupées dans le même circuit. [[File:Relation entre prédiction de branchement et de direction de branchement.png|centre|vignette|upright=2|Relation entre prédiction de branchement et de direction de branchement.]] ==Les erreurs de prédiction de branchement== Le processeur peut parfaitement se tromper en faisant ses prédictions, ce qui charge des instructions par erreur dans le pipeline. Dans ce cas, on parle d''''erreur de prédiction'''. Les erreurs de prédiction sont corrigées par le système d'exception précises, ou un équivalent. En clair, les instructions chargées à tort se propagent jusqu'à la sortie du pipeline, où leurs résultats sont alors annulés. N'importe quelle technique de gestion des exceptions précise fait le travail, mais les processeurs modernes utilisent un tampon de réordonnancement (''reorder buffer'') qu'on détaillera dans quelques chapitres. Détecter les erreurs de prédiction demande d'ajouter un circuit dans le processeur : l’''unité de vérification de branchement'' (''branch verification unit''). Elle génère un indicateur d'exception, qui est géré par le système d'exception précise. Pour générer l'indicateur, elle compare l'adresse de destination prédite et celle calculée en exécutant le branchement. Pour cela, l'adresse prédite doit être propagée dans le pipeline jusqu’à ce que l'adresse de destination du branchement soit calculée. [[File:Unité de vérification de branchement.png|centre|vignette|upright=2|Unité de vérification de branchement]] ===La pénalité de mauvaise prédiction=== Le temps pour vider le pipeline des instructions fautives dépend du pipeline. La dernière instruction à être chargée dans le pipeline le sera en même temps qu'on détecte l'erreur de prédiction. Il faudra attendre que cette instruction quitte le pipeline, et donc qu'elle traverse tous les étages. Il y a donc un temps d'attente pour collecter les résultats de ces instructions et la décision de ne pas enregistrer leurs résultats dans les registres Le nombre d'instructions chargées à tort dépend de où est détectée l'erreur de prédiction. Plus le résultat du branchement est connu tard, plus le nombre d'instructions chargées à tort sera grand, et plus le nombre de cycles d'horloge gâchés le sera aussi. Le nombre de cycles gâchés s'appelle la '''pénalité de mauvaise prédiction'''. Elle correspond précisément au nombre de cycles/étages entre le premier étage du pipeline, et l'étage où le branchement est résolu (on connait l'adresse à laquelle brancher). Plus celui-ci est éloigné du début du pipeline, plus la pénalité est importante. [[File:MIT 6.823 Computer System Architecture, Fall 2005 - P-3 vs. P-4 Pipelines - 4473425702.jpg|centre|vignette|upright=2.5|Comparaison de la pénalité de mauvaise prédiction entre le Pentium 3 et le Pentium 4 d'Intel.]] Elle est généralement d'autant plus longue que le pipeline lui-même est long. Les concepteurs de processeurs font donc face à un compromis : un pipeline long permet d'exécuter plus d'instructions en parallèle, mais cela augmente aussi la pénalité de mauvaise prédiction. Un pipeline de 10-15 étapes est un bon compromis. Les pipelines plus longs ont besoin d'une prédiction de branchement élaborée pour fonctionner correctement. Plus la prédiction de branchement est performante, plus le pipeline peut être long. Les pénalités de mauvaise prédiction seront plus importantes, mais les mauvaises prédictions seront plus rares, ce qui compense complétement. ===La ''minimal control dependency''=== En utilisant une technique du nom de ''minimal control dependency'', seules les instructions qui dépendent du résultat du branchement sont supprimées du pipeline en cas de mauvaise prédiction, les autres n'étant pas annulées. Sans ''minimal control dependency'', toutes les instructions qui suivent un branchement dans notre pipeline sont annulées. Pourtant, certaines d'entre elles pourraient être utiles. Prenons un exemple : supposons que l'on dispose d'un processeur de 31 étages (un Pentium 4 par exemple). Supposons que l'adresse du branchement est connue au 9éme étage. On fait face à un branchement qui envoie le processeur seulement 6 instructions plus loin. Si toutes les instructions qui suivent le branchement sont supprimées, les instructions en rouge sont celles qui sont chargées incorrectement. On remarque pourtant que certaines instructions chargées sont potentiellement correctes : celles qui suivent le point d'arrivée du branchement. Elles ne le sont pas forcément : il se peut qu'elles aient des dépendances avec les instructions supprimées. Mais si elles n'en ont pas, alors ces instructions auraient dûes être exécutées. Il serait donc plus efficace de les laisser enregistrer leurs résultats au lieu de les ré-exécuter à l’identique un peu plus tard. Ce genre de choses est possible sur les processeurs qui implémentent une technique du nom de ''Minimal Control Dependancy''. En gros, cette technique fait en sorte que seules les instructions qui dépendent du résultat du branchement soient supprimées du pipeline en cas de mauvaise prédiction. [[File:Minimal cntrol dependency.png|centre|vignette|upright=2|Minimal control dependency]] ==La reconnaissance des branchements== Pour prédire des branchements, le processeur doit faire la différence entre branchements et autres instructions, dès l'étage de chargement. Or, un processeur « normal », sans prédiction de branchement, ne peut faire cette différence qu'à l'étage de décodage, et pas avant. Les chercheurs ont donc dû trouver une solution. La première solution se base sur les techniques de prédécodage vues dans le chapitre sur le cache. Pour rappel, ce prédécodage consiste à décoder partiellement les instructions lors de leur chargement dans le cache d'instructions et à mémoriser des informations utiles dans la ligne de cache. Dans le cas des branchements, les circuits de prédécodage peuvent identifier les branchements et mémoriser cette information dans la ligne de cache. Une autre solution mémorise les branchements déjà rencontrés, dans un cache dédié. Ce cache mémorise l'adresse (le ''program counter'') des branchements déjà rencontrés : il s'appelle le '''tampon d’adresse de branchement''' (''branch adress buffer''). Il est intégré à l'unité de prédiction de branchement, mais est alimenté par le décodeur d'instruction. Reconnaitre un branchement demande l'implication du décodeur d'instruction, qui sait si l'instruction qu'il vient de décoder est un branchement ou non. Il peut même savoir si le branchement est conditionnel, inconditionnel, un appel de retour de fonction, etc. Lorsque le décodeur d'instruction reconnait un branchement, il ajoute ce branchement dans le cache Le tapon d'adresse de branchement contient une adresse mémoire par branchement, et éventuellement d'autres informations. Et pour comprendre quelle adresse est mémorisée, il faut faire attention à un détail. Les explications qui vont suivre vont faire intervenir deux adresses. * La première est l''''adresse de destination''' du branchement, à savoir l'adresse à laquelle le processeur doit reprendre son exécution si le branchement est pris. * L'autre adresse est l''''adresse du branchement''' lui-même. Elle correspond à sa position en mémoire RAM. L'adresse du branchement est la valeur du ''program counter'' quand le branchement est chargé dans le cache, alors que l'adresse de destination est celle du ''program Counter'' une fois le branchement exécuté. Il faudra faire bien attention à ne pas confondre les deux adresses dans ce qui suit. L'adresse du branchement est celle qui est utilisée pour la reconnaissance du branchement. Le tampon d'adresse de branchement mémorise, pour chaque branchement, son adresse de branchement (d'où son nom). À chaque cycle d'horloge, l'unité de chargement envoie le ''program counter'' en entrée du tampon d’adresse de branchement. Un succès dans ce cache indique qu'un branchement déjà rencontré est ré-executé, on peut utiliser la prédiction de branchement. Dans le cas contraire, la prédiction de branchement n'est pas utilisée, et l'instruction est chargée normalement. ==La prédiction de l'adresse de destination== La '''prédiction de l'adresse de destination d'un branchement''' détermine l'adresse de destination d'un branchement. Rappelons qu'il existe plusieurs modes d'adressage pour les branchements et que chacun d'entre eux précise l'adresse de destination à sa manière. Suivant le mode d'adressage, l'adresse de destination est soit dans l'instruction elle-même (adressage direct), soit calculée à l’exécution en additionnant un ''offset'' au ''program counter'' (adressage relatif), soit dans un registre du processeur (branchement indirect), soit précisée de manière implicite (retour de fonction, adresse au sommet de la pile). : Histoire de simplifier la terminologie, je parlerais parfois de '''prédiction de destination''' pour parler de la ''prédiction de l'adresse de destination d'un branchement''. Pour rappel, les branchements se classent en trois types : * les branchements directs pour lequel l'adresse de destination est toujours la même ; * les branchements indirects où l'adresse de destination est dans un registre et peut changer ; * les instructions de retour de fonction. Les branchements directs sont facilement prévisibles, vu que l'adresse vers laquelle il faut brancher est toujours la même. La prédiction des branchements directs et relatifs se fait globalement de la même manière, ce qui fait que nous ferons la confusion dans ce qui suit. Pour les instructions de retour de fonction, une prédiction parfaite est possible. Et c'est ce qui explique qu'elles sont traitées à part, par une unité de branchement dédiée. Pour les branchements indirects, vu que cette adresse change, la prédire celle-ci est particulièrement compliqué (quand c'est possible). Les branchements indirects sont eux aussi traités par une unité de prédiction dédiée rien que pour eux. ===La prédiction de destination pour les branchements directs=== Lorsqu'un branchement est exécuté, on mémoriser l'adresse de destination et la réutiliser lors d'exécutions ultérieures du branchement. Cette technique marche à la perfection pour les branchements directs, pour lesquels cette adresse est toujours la même, mais donne des résultats mitigés pour les branchements indirects. Pour mémoriser l'adresse de destination, on utilise un cache qui mémorise les correspondances entre l'adresse du branchement et l'adresse de destination. Ce cache est appelé le '''''branch target buffer''''' en anglais, qui sera abrévié en BTB dans ce qui suit. Le BTB est une amélioration du tampon d'adresse de branchement vu plus haut, auquel on aurait ajouté les adresses de destination des branchements. Le BTB est utilisé comme suit : on envoie en entrée l'adresse du branchement, le BTB répond au cycle suivant en précisant : s’il reconnaît l'adresse d'entrée, et quelle est l'adresse de destination si c'est le cas. Le BTB peut être un cache totalement associatif, associatif par voie, ou directement adressé, mais ces derniers sont les plus fréquents. Les BTB sont généralement des caches de type directement adressés, où seuls les bits de poids faible de l'adresse du branchement adressent le cache, le reste de l'adresse est tout simplement ignoré. Il existe aussi des BTB qui sont construits comme les caches associatifs à plusieurs voies, mais ceux-ci impliquent généralement la présence de ''tags'', ce qui fait qu'ils sont un peu moins fréquents. [[File:Branch target buffer directement adressé.png|centre|vignette|upright=2|Branch target buffer directement adressé.]] Comme pour tous les caches, un accès au BTB peut entraîner un défaut de cache, c’est-à-dire qu'un branchement censé être dans le BTB n'y est pas. Comme pour les autres caches, les défauts de cache peuvent se classer en trois types : les défauts de cache à froid (''cold miss'') causés par la première exécution d'un branchement, les défauts liés à la capacité du BTB/cache, et les défauts par conflit d'accès au cache. Les défauts liés à la capacité du BTB sont les plus simples à comprendre. La capacité limitée du BTB fait que d'anciens branchements sont éliminées pour laisser la place à de nouveaux. En conséquence, ces branchements peuvent donner des erreurs de prédiction si leur correspondance a été éliminée du cache. On peut réduire des défauts en augmentant la taille du BTB. Les défauts liés aux conflits d'accès au BTB sont eux beaucoup plus intéressants, car la conception du BTB est assez spéciale dans la manière dont sont gérés ce genre de défauts. Le BTB est un cache partiellement associatif, ce qui fait que des défauts de cache par conflit d'accès peuvent avoir lieu. Pour rappel, les défauts par conflit d'accès ont lieu quand deux adresses mémoires se voient attribuer la même ligne de cache. Pour un BTB, cela correspond au cas où deux branchements se voient attribuer la même entrée dans le BTB, ce qui porte le nom d’''aliasing''. Ne pas détecter ce genre de situation sur un cache normal entraînerait des problèmes : la donnée lue/écrite ne serait pas forcément la bonne. Les caches normaux utilisent un système de tags pour éviter ce problème et on s'attendrait à ce que les BTB fassent de même. Mais pour un BTB, l'absence de ''tags'' n'est pas un problème : la seule conséquence est une augmentation du taux de mauvaises prédictions, dont les conséquences en termes de performance peuvent être facilement compensées. Les BTB peuvent donc se passer de tags, ce qui a de nombreux avantages : le circuit est plus rapide, prend moins de portes logiques, consomme moins d'énergie, etc. Ces économies de portes logiques et de performances peuvent être utilisées pour augmenter la taille du BTB, ce qui compense, voire surcompense les mauvaises prédictions induites par l’''aliasing''. Mais ce n'est pas systématique : d'autres BTBs utilisent des ''tags'' pour améliorer le taux de prédiction. Nous ferons donc la différence entre '''BTB tagués''' et '''BTB non-tagués'''. Des processeurs se passent de BTB. À la place, ils mémorisent les correspondances entre branchement et adresse de destination dans les bits de contrôle du cache d'instructions. Mais nous verrons cela en détail dans le chapitre suivant. ===La prédiction de destination pour les branchements indirects=== Un BTB classique peut fonctionner pour les branchements indirects. Son seul défaut est qu'il mémorisera la dernière adresse de destination prise par le branchement. Et cela peut fonctionner pour les branchements indirects fortement biaisés, qui ont presque toujours la même adresse de destination. Mais la majorité des branchements indirects change d'adresse de destination régulièrement. Tous les processeurs commerciaux datant d'avant le Pentium M utilisaient un BTB normal pour prédire les branchements indirects. Les processeurs qui les ont suivi sont capables de prédire l'adresse de destination d'un branchement indirect. , grâce à une '''unité de prédiction des branchements indirects''', séparée de l'unité de prédiction de branchement normale. Elle utilise un BTB amélioré, qui mémorise plusieurs adresses de destination pour un seul branchement, en compagnie d'informations pour prédire laquelle est la bonne. Il est possible d'utiliser un BTB unique, pour tous types de branchements, appelé un '''BTB unifié'''. Un BTB unifié a plusieurs adresse de destination par entrée du BTB. Mais une seule est utilisée pour les branchements directs. Vu que les branchements directs sont majoritaires, cela fait beaucoup d'entrées inutilisées. En conséquence, elle est tout sauf optimale, et n'est presque jamais utilisée en pratique. {|class="wikitable" |+ Branch Taarget Buffer unifié |- ! Adresse du branchement !! Informations de prédiction !! Adresse de destination !! Seconde adresse de destination |- | 0x54462146 || 11101011 || 0x52495674 || 0x24987461 |- | 0x54142446 || 11101101 || 0x15646956 || x0x4158746 |- | 0x25456542 || 10011101 || 0x52495674 || 0x24157461 |- | 0x15455855 || 11010101 || 0x52485754 || 0x24024511 |- | ... || ... || ... || ... |- | ... || ... || ... || ... |- | 0x54462146 || 1001101 || 0x52495674 || 0x24987461 |} Une seconde solution utilise un BTB séparé, distinct du BTB pour les branchements directs. En séparant les deux, on a un BTB simple qui ne mémorise qu'une adresse de destination par branchement, et un BTB pour branchements indirects avec plusieurs adresses de destination par branchement. [[File:BTB avec prédiction de branchements indirects.png|centre|vignette|upright=2.5|BTB avec prédiction de branchements indirects.]] L'idée est que l'on économise des circuits à nombre d'entrées égales. Par exemple, comparons ce que cela donne avec 64 entrées. D'un côté, on a un BTB à 64 entrées, capable de gérer deux adresse de destination par branchement. En pratique, la majorité des branchements sont des branchements directs, qui n'utiliseraient qu'une seule case mémoire. Les circuits du BTB seraient donc sous-utilisés. De l'autre, on a un BTB à 48 entrées pour branchements directs, et un second BTB avec 16 entrées, capable de mémoriser deux adresses par branchements. Le premier BTB utilise moins de circuits, et est utilisé à pleine capacité, il ne gaspille pas de circuits pour mémoriser des adresses de destination vides. Par contre, on perd un peu en flexibilité. L'organisation précédente marche bien si 1/4 des branchements rencontrés sont des branchements indirects. Mais si le ratio est différent, la performance est légèrement sous-optimale. Attention, cela ne veut pas dire qu'il y aura des entrées vides dans un BTB, non. Simplement, le premier BTB risque de manquer de place pour les branchements directs récents, alors que les branchements indirects seront eux bien prédits, même les anciens. À l'opposé, un BTB unique permet de mémoriser les 64 derniers branchements, peu importe qu'ils soient indirects ou non. En clair : un BTB unique est plus flexible, ce qui donne un gain de performance dans certaines situations. Cependant, cet avantage théorique ne tient pas compte de l'économie en circuits, qui peut être utilisée pour faire grossir le BTB pour branchements directs. Par exemple, à budget à transistor équivalent, on a le choix entre BTB à 64 entrées uniques, et un BTB pour branchements directs de 80 entrées avec un BTB indirect de 16 entrées. Le fait qu'un BTB soit plus gros permet des gains de performances qui compensent la perte de flexibilité. On peut ainsi avoir le meilleur des deux monde, à savoir une économie de circuits et les gains en performance qui en découlent, malgré une petite perte de flexibilité. ===La prédiction de l'adresse de retour d'une fonction=== Presque tous les processeurs conçus après les années 2000 peuvent prévoir l'adresse à laquelle il faudra reprendre lorsqu'une fonction/sous-programme a fini de s’exécuter. Ils font de la '''prédiction de retour de fonction'''. Pour cela, ils possèdent pour une unité de prédiction de branchement spécialisée, appelée le ''return stack predictor'', ou '''''pile de prédiction de retour de fonction'''''. Le terme ''return stack predictor'' étant plus court, nous l'utiliserons dans la suite du cours. Le ''return stack predictor'' est capable de reconnaitre les instructions de retour de fonction, et de prédire l'adresse de retour. Pour comprendre comment elle fait, rappelez-vous le chapitre sur la pile d'appel. Dans ce chapitre, j'avais introduit le concept de ''pile d'adresse de retour'', à savoir une pile d'appel très simplifiée, qui ne contient que les adresses de retour des fonctions en cours d'exécution. Le ''return stack predictor'' est essentiellement composé d'une telle pile d'adresse de retour, avec des circuits pour empiler et dépiler les adresses de retour. Le ''return stack predictor'' doit reconstruire une pile d'adresses de retour, à partir du programme exécuté. La pile d'adresse est mémorisée dans le ''return stack predictor'', elle est totalement séparée de la pile d'appel normale. Le programme exécuté continue d'utiliser la même pile d'appel qu'avant, ce qui fait que les adresses de retour sont dispersées dans la pile d'appel, parfois dans des registres spéciaux du processeur. Mais cela ne pose pas de problèmes pour le processeur, qui reconstruit la pile d'adresse de retour avec l'aide du décodeur. La reconstruction se fait à partir des instructions d'appel et de retour de fonction. Une instruction d'appel de fonction empile une adresse de retour, une instruction de retour de fonction la dépile. Lorsque le décodeur d'instruction reconnait une instruction d'appel de fonction, il extrait l'adresse de retour et l'envoie au ''return stack predictor''. L'adresse de retour est alors empilée, ce qui fait qu'elle se retrouve au sommet de la pile d'adresse de retour. Dépiler une adresse de retour revient à prédire un branchement. Lorsque le ''return stack predictor'' détecte une instruction de retour de fonction, il dépile l'adresse au sommet de la pile. L'adresse est alors envoyée du cache d'instruction, pour l'ire l'instruction adéquate, et le ''program counter'' est mis à jour avec cette adresse. Toute la difficulté revient à détecter les instructions de retour de fonction, sans quoi on ne sait pas quand dépiler l'adresse. La documentation là-dessus n'est pas très fournie, il est difficile de savoir ce qui est fait dans les processeurs modernes. Mais on peut faire quelques suppositions. La première possibilité est l'usage du ''prédécodage'', une technique qui décode partiellement les instructions lors de leur chargement dans le cache d'instruction. Elle sera vue dans le chapitre suivant, mais parlons-en rapidement. Le prédécodage ajoute quelques bits au début de chaque instruction pour préciser si c'est un branchement conditionnel, inconditionnel, un retour de fonction. On peut ainsi savoir rapidement quand dépiler : dès qu'une instruction est lue dans le cache d'instruction, le ''return stack predictor'' vérifie les bits ajoutés et agit en fonction. La seconde possibilité est que le BTB reconnait l'instruction de retour de fonction. Pour chaque branchement, il mémorise non seulement son adresse de retour, mais aussi son type. Par type, on veut dire : si c'est un branchement conditionnel, inconditionnel, un retour de fonction. Les retours de fonction sont donc reconnus à condition qu'ils aient été exécutés auparavant. Il est aussi possible d'avoir un BTB spécialisé dans les retours de fonction, qui est alimenté par les décodeurs. Dès que le décodeur reconnait une instruction de retour de fonction, il ajoute son ''program counter'' dans ce BTB spécialisé. Une dernière possibilité est que le ''return stack predictor'' agit une fois l'instruction partiellement décodée. Le décodeur d'instruction reconnait les instructions de retour de fonction rapidement, en un cycle, et consulte alors le ''return stack predictor''. L'adresse est dépilée, et le décodeur sait où brancher. Il n'a pas besoin de devoir lire l'adresse de retour en mémoire, il la lit depuis le ''return stack predictor'', directement. La prédiction se fait donc assez tard dans le pipeline, quelques instructions ont été chargées à tort. Mais le décodeur peut les annuler, comme il le ferait avec un branchement inconditionnel mal prédit. ==La prédiction de branchement locale== La prédiction de branchement tente de prédire si un branchement sera pris ou non-pris, et décide quelle adresse envoyer au cache en fonction du résultat. Si on prédit qu'un branchement est non pris, on laisse le ''program counter'' faire son travail. À l'inverse si le branchement est prédit comme étant pris, l'adresse de destination est envoyée au cache d'instruction et au ''program counter''. La prédiction de branchement assigne une probabilité qu'un soit branchement soit pris en fonction de ces informations : si la probabilité est de plus de 50%, le branchement est considéré comme pris. La prédiction de branchement se base avant tout sur des statistiques, c’est-à-dire qu'elle détermine la probabilité d’exécution d'un branchement en fonction d'informations assez diverses. La prédiction de branchement n'a d'intérêt que si les prédictions sont suffisamment fiables pour valoir le coup. Rappelons que la pénalité lors d'une mauvaise prédiction est importante, d'autant plus coûteuse que le pipeline est long. Il est généralement admis qu'il faut au minimum un taux de réussite proche de 90%, voire plus sur les processeurs modernes. Heureusement, la plupart des branchements sont des '''branchements biaisés''', c’est-à-dire qu'ils sont presque toujours pris ou presque toujours non-pris, sauf en de rares occasions. De tels branchements font que la prédiction des branchements est facile, bien qu'ils posent paradoxalement quelques problèmes, comme nous le verrons plus bas. Un autre point important est que les unités de prédiction de branchement doivent être très rapides. Idéalement, elles doivent fournir leur prédiction en un seul cycle d'horloge. Un seul cycle d'horloge est un temps très court, surtout sur les ordinateurs avec une fréquence élevée. Les techniques de prédiction dynamique ne peuvent donc pas utiliser des méthodes statistiques extraordinairement complexes. Cela va à l'encontre du fait que le taux de mauvaises prédictions doit être très faible, idéalement inférieur à 1% sur les processeurs modernes. Vous comprenez donc aisément que concevoir une unité de prédiction de branchement est un véritable défi d’ingénierie électronique qui requiert des prouesses technologiques sans précédent. Suivant la nature des informations utilisées, on peut distinguer plusieurs types de prédiction de branchement : locale, globale, dynamique, statique, etc. Nous allons d'abord voir les méthodes statiques avant de voir les méthodes dynamiques. La '''prédiction locale''' prédit chaque branchement indépendamment les uns des autres. Elle sépare bien les statistiques/moyennes pour chaque branchement. Et c'est autant un point fort qu'un point faible. En effet, elle n'exploite pas les corrélations entre branchements, à savoir le fait que l'exécution d'un branchement a tendance à déborder sur les suivants. Dans ce qui suit, nous allons voir les techniques de prédiction locales. ===Les ''branch hints''=== Dans son implémentation la plus explicite, la prédiction est inscrite dans le branchement lui-même. Quelques bits de l'opcode du branchement précisent si le branchement est majoritairement pris ou non pris. Ils permettent d'influencer les règles de prédiction de branchement et de passer outre les réglages par défaut. Ces bits sont appelés des '''suggestions de branchement''' (''branch hint''). Mais tous les processeurs ne gèrent pas cette fonctionnalité. Et de plus, cette solution marche assez mal. La raison est que le programmeur ou le compilateur doit déterminer si le branchement est souvent pris ou non-pris, mais qu'il n'a pas de moyen réellement fiable pour cela. Une solution possible est d’exécuter le programme sur un ensemble de données réalistes et d'analyser le comportement de chaque branchement, afin de déterminer les suggestions de branchement adéquates, mais c'est une solution lourde et peu pratique, qui a de bonnes chances de donner des résultats peu reproductibles d'une exécution à l'autre. ===La prédiction statique de branchement=== La '''prédiction statique''' prédit le résultat du branchement en fonction de caractéristiques fixes du branchement lui-même, comme son adresse, son mode d'adressage, l'adresse de destination, la position du branchement en mémoire, si le branchement est direct ou indirect, etc. Les informations en question sont connues une fois le branchement décodé, il n'y a pas besoin de mémoriser la moindre information supplémentaire. Une première implémentation, assez évidente, fait la différence entre les branchements inconditionnels toujours pris, et les branchements conditionnels au résultat variable. L'idée est de prédire les branchements inconditionnels comme toujours pris alors que les branchements conditionnels sont prédits comme non-pris (ce qui est une approximation). L'implémentation est relativement simple : on utilise un BTB qui ne mémorise que les branchements inconditionnels, et basta ! Les branchements conditionnels sont simplement ignorés, traités comme n'importe quelle autre instruction. Cette méthode est particulièrement inefficace pour les branchements de boucle, où la condition est toujours vraie, sauf en sortie de boucle ! Il a donc fallu affiner légèrement l'algorithme de prédiction statique. Une implémentation plus subtile fait la différence entre les branchements des boucles et les autres. Reste à identifier ceux-ci d'une manière ou d'une autre. Et un bon moyen est de regarder si le branchement revient en arrière ou au contraire saute une portion de code. Un programme s'exécute des adresses basses vers les adresses hautes, les instructions vont naturellement dans le sens croissant des adresses. Un branchement de boucle fait revenir en arrière, histoire de répéter un morceau de code, pas les autres branchements (du moins pas forcément). Et cela nous amène à faire la distinction entre les branchements conditionnels ascendants et descendants. Un '''branchement conditionnel ascendant''' est un branchement qui demande au processeur de reprendre plus loin dans la mémoire : l'adresse de destination est supérieure à l'adresse du branchement. Un branchement '''conditionnel descendant''' a une adresse de destination inférieure à l'adresse du branchement : le branchement demande au processeur de reprendre en arrière dans la mémoire, avant le branchement. Les branchements ascendants sont rarement pris (ils servent dans les conditions de type SI…ALORS), contrairement aux branchements descendants (qui servent à fabriquer des boucles). On peut ainsi modifier l’algorithme de prédiction statique comme suit : * les branchements inconditionnels sont toujours pris ; * les branchements descendants sont toujours pris ; * les branchements ascendants ne sont jamais pris. L'implémentation demande d'élargir le BTB. Le BTB n'a pas seulement à mémoriser l'adresse du branchement et son adresse de destination, mais aussi le type du branchement. En général, il se contente de deux bits, qui indiquent si le branchement est conditionnel, inconditionnel, ou un retour de fonction. Les retours de fonction sont traités à part, histoire de profiter du ''return stack predictor''. {|class="wikitable" |+ BTB avec prédiction de branchement statique évoluée |- ! Adresse du branchement !! Adresse de destination !! Type du branchement |- | 0x4165485 || 0x14657445541 | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Retour de fonction |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... || ... |} ===La prédiction avec des compteurs à saturation=== Les méthodes de prédiction de branchement statique sont intéressantes, mais elles montrent rapidement leurs limites. Pour plus de performances, il faut utiliser la '''prédiction dynamique''', qui extrait des statistiques sur l’exécution des branchements. Pour le reste du chapitre, toutes les techniques utilisent la prédiction dynamique, qu'elles soient locales ou globales. Sa version la plus simple se contente de mémoriser ce qui s'est passé lors de l’exécution précédente du branchement. Si le branchement a été pris, alors on suppose qu'il sera pris la prochaine fois. De même, s'il n'a pas été pris, on suppose qu'il ne le sera pas lors de sa prochaine exécution. Pour cela, chaque entrée du BTB contient un bit qui mémorise la dernière exécution du branchement : 1 s'il a été pris, 0 s'il n'a pas été pris. C'est ce qu'on appelle la '''prédiction de branchements sur 1 bit'''. Au vu de sa simplicité, il ne faut pas s'attendre à des miracles. La méthode fonctionne bien pour les branchements des boucles et les branchements inconditionnels, mais elle n'est pas assez performante. Une forme plus élaborée de prédiction utilise des '''compteurs à saturation'''. Un compteur à saturation est un compteur qui est incrémenté à chaque fois qu'un branchement est pris, décrémenté s'il est non-pris. Le compteur est initialisé à la moitié de sa valeur maximale, ce qui correspond à une probabilité proche de 50%. Les compteurs à saturation sont très utilisés et pas seulement pour de la prédiction locale, les méthodes globales en utilisent aussi. Pour faire la prédiction, l'unité de prédiction regarde le bit de poids fort du compteur. Le branchement est considéré comme pris si ce bit de poids fort est à 1, et non pris s'il vaut 0. En clair : on considère que le branchement est pris si le compteur indique une probabilité supérieure à 50%. [[File:Compteur à saturation.png|centre|vignette|upright=2|Compteur à saturation.]] : Pour la culture générale : le compteur à saturation du Pentium 1 était légèrement bogué, ce qui fait que sa prédiction de branchement était sous-optimale. La plupart des processeurs utilisent un compteur à saturation par branchement, ce qui veut dire un compteur à saturation par entrée dans le BTB. Vu chaque compteur est associé à une entrée du BTB, le phénomène d’''aliasing'' mentionné plus haut peut perturber les prédictions de branchement. Deux branchements peuvent se voir associés à la même entrée du BTB, et agir sur le même compteur à saturation, ce qui entraine l'apparition d''''interférences entre branchements'''. L'interférence peut avoir des effets positifs, neutres ou négatifs, suivant la corrélation entre les deux branchements. L’''aliasing'' n'est pas un problème si les deux branchements sont fortement corrélés, par exemple si les deux sont tout le temps pris ou tout le temps non-pris. Si les deux branchements vont dans le même sens, l'interférence est neutre, voire légèrement positive. Mais si l'un est souvent pris et l’autre souvent non-pris, l'interférence est négative. Les deux branchements vont se marcher dessus sans vergogne, chacun interférant sur les prédictions de l'autre ! Il est rare que l’''aliasing'' ait un impact significatif sur les unités de prédiction des deux paragraphes précédents. Il se manifeste surtout quand la BTB est très petite et la seule solution est d'augmenter la taille du BTB. ===La prédiction avec un historique local=== La prédiction avec des compteurs à saturation est très simple, mais il est possible de créer une implémentation alternative, très proche, avec des avantages et désavantages différents. Un compteur à saturation enregistre l'historique d'un branchement, à savoir ses dernières exécutions. L''''historique''' d'un branchement dit quel a été son résultat dans le passé, pour ses exécutions précédentes. Et il est possible d'enregistrer cet historique et l'utiliser pour faire des prédictions. Par exemple, prenons un branchement sont ses 6 dernières exécutions sont "pris, non-pris, pris, pris, pris, non-pris". Le processeur peut encoder cet historique sur 6 bits. Un branchement pris correspond à un bit à 1, un branchement non-pris correspond à un 0. Le bit de poids fort correspond à l'exécution la plus ancienne, le bit de poids faible à l'exécution la plus récente. Par exemple, si l'historique contient 010, cela veut dire que le branchement a été non pris, puis pris, puis non pris. L'historique d'un branchement est appelé son '''historique local''', mais nous parlerons simplement l'historique. Le processeur peut mémoriser l'historique de chaque branchement. Pour cela, il réserve n bits par branchements, pour mémoriser ses n exécutions précédentes. L'historique d'un branchement est mémorisé dans son entrée du BTB. Et plus précisément dans un champ séparé qui est alimentée comme un registre à décalage. Reste à exploiter cet historique local pour faire des prédictions. Une idée assez simple serait de l'utiliser pour calculer une moyenne des N exécutions précédentes. Si la probabilité d'être pris est supérieure à 50 %, le branchement est considéré comme pris. Dans le cas contraire, il est considéré comme non pris. Le calcul de la moyenne utilise une simple porte à majorité, qui prend en entrée tous les bits de l'historique. Cependant, cette méthode est équivalente à utiliser un compteur à saturation par branchement. La sortie de la porte à majorité est équivalente à ce qu'on aurait dans un compteur à saturation. La seule différence : le cout en circuit et la simplicité de conception. Prenons l'exemple des premiers processeurs PA RISC. Ils mémorisaient, pour chaque branchement, les trois dernières exécutions du branchement, avec trois bits par entrée du BTB. La prédiction était réalisée avec une porte à majorité, dont le résultat était utilisé comme prédiction. Ces processeurs ont préféré utiliser un historique de 3 bits, car il était plus simple à concevoir qu'un compteur à saturation équivalent de deux bits. Il suffisait d'alimenter un registre à décalage avec un bit, pas besoin d'incrémenter ou de décrémenter un compteur, pas besoin de signaux d'incrémentation/décrémentation séparés, etc. Mais pour des historiques plus longs, la donne s'inverse et des compteurs à saturation de deux-trois bits l’emportent. Un défaut de la méthode précédente, qui est partagée avec les compteurs à saturation, est qu'elle marche mal pour les branchements qui suivent un ''pattern''. Je veux dire par là que l'exécution d'un branchement suit un cycle, par exemple pris, pris, non-pris, pris, pris, non-pris, et ainsi de suite en boucle. Notons que l'apparition de tels motifs correspond le plus souvent à la présence de boucles dans le programme. Quand une boucle s’exécute, les branchements qui sont à l'intérieur tendent à exprimer de tels motifs. Avec de tels motifs, les compteurs à saturation feront des prédictions incorrectes, là où les prédicteurs qu'on va voir font des prédictions parfaites. Par exemple, prenons un branchement qui suit le ''pattern'' pris, pris, pris, non-pris. Si l'historique local vaut 1110, le branchement est censé refaire une boucle et être pris. Et certes, on a supposé que l'historique faisait 4 bits. Mais le''pattern'' sera visible tant que l'historique fait plus de 4 bits. Par exemple, le ''pattern'' serait 01110 pour un historique local de 5 bits, 101110 pour un historique local de 6 bits, 1101110 pour un historique local de 7 bits, etc. Un ''pattern'' correspond donc à une valeur bien précise de l'historique local. L'historique local permet de voir ces cycles, reste à les exploiter. Prédire correctement les ''pattern'' demande simplement d'associer un compteur à saturation à chaque valeur possible de l'historique local. Le compteur à saturation dira, quand tel pattern est rencontré, si le branchement sera pris ou non-pris par la suite. Au total, il faut donc un registre d'historique local de n bits couplé à 2^n compteurs à saturation. Et ce pour chaque branchement possible. Les compteurs à saturation sont regroupés dans une '''''Pattern History Table''''', que nous noterons PHT dans ce qui suit. Le résultat est une '''unité de prédiction à deux niveaux locale''', qui contient plusieurs registres d'historique et tout autant de PHT locales. En tout, il y a une PHT et un historique par branchement, donc par entrée du BTB. Quand un branchement est exécuté, son historique local indique quel compteur à saturation choisir dans la PHT locale. En général, l'historique local est mémorisé dans le BTB, reste à gérer la PHT. [[File:Unité de prédiction à deux niveaux purement locale.png|centre|vignette|upright=2.5|Unité de prédiction à deux niveaux avec des historiques et des PHT locales.]] Une unité de prédiction locale à deux niveaux contient plusieurs PHT, reste à savoir quelle PHT correspond à tel branchement. La correspondance se fait idéalement à partir de l'adresse du branchement, mais c'est là une implémentation idéale, qui demande beaucoup de circuits. Généralement, l'unité de prédiction n'utilise que les bits de poids faible de l'adresse du branchement, ce qui permet de faire un choix correct, mais avec une possibilité d'''aliasing''. Il est aussi possible de séparer l'historique local du BTB. Les historiques locaux sont alors mémorisés dans une mémoire RAM dédiée, appelé la '''''Branch History Table''''', ou encore la '''table des historiques locaux''', que nous noterons BHT dans ce qui suivra. Chaque byte de la BHT contient l'historique local associé à un branchement. Pour récupérer l'historique local voulu, l'adresse du branchement est envoyée à la BHT, sur les entrées d'adresse de la BHT, directement. Seuls les bits de poids faible de l'adresse sont utilisés. L'historique local est ensuite envoyé à la PHT adéquate, choisie en fonction des mêmes bits de poids faible du PC. Un bon moyen pour cela est d'accéder à toutes les PHT en parallèle, mais de sélectionner la bonne avec un multiplexeur, ce dernier étant commandé par les bits de poids faible du PC. [[File:Table des historiques locaux.jpg|centre|vignette|upright=2|Table des historiques locaux]] Un défaut des unités de prédiction purement locales de ce type est leur cout en termes de circuits. Utiliser un registre d'historique et une PHT par branchement a un cout en transistor élevé, avec tous les défauts que ca implique en termes de consommation électrique et thermique. Elles ont aussi des temps de calcul un peu plus important que les unités purement globales, mais pas de manière flagrante. Les gains en performance avec un historique local s'observent surtout pour les branchements qui sont dans des boucles ou pour les branchements utilisés pour concevoir des boucles, alors que l'implémentation globale marche bien pour les if..else (surtout quand ils sont imbriqués). Les deux sont donc complémentaires. Dans les faits, elles sont rarement utilisées seules du fait de leurs défauts, au profit d'unités de prédiction hybrides, qui mélangent historiques et PHT locaux et globaux. ==La prédiction de branchement globale/mixte== Utiliser un historique local avec une PHT est très puissant. Mais le cout en circuit est très important. Cependant, on peut adapter l'idée pour faire de la prédiction globale. La '''prédiction globale''' fait des prédictions holistiques, qui tiennent compte de tous les branchements, sans les traiter séparément. Elle ne sépare pas les branchements, tous sont mis dans le même panier. La prédiction globale fait donc le compromis inverse : elle exploite les corrélations entre branchements, mais perd les informations statistiques précise pour chaque branchement. L'idée est qu'au lieu d'utiliser un historique par branchement, on utilise un '''historique global''' qui mémorise les exécutions de tous les branchements conditionnels. L'historique enregistre les N exécutions de branchements conditionnels. L'historique global est mémorisé dans un registre unique, appelé le '''registre d'historique global'''. C'est un registre à décalage mis à jour à chaque fois que le processeur exécute un branchement : on fait rentrer un 1 si le branchement est pris, et un 0 sinon. Une unité de prédiction globale utilise cet historique pour faire sa prédiction. L'unité de prédiction globale utilise un compteur à saturation pour chaque valeur possible du registre d'historique. En clair, pour un registre de N bits, elle utilise 2^n compteurs à saturation. Là encore, les compteurs à saturation sont regroupés dans une ''Pattern History Table''. Nous parlerons de '''PHT globale''' pour désigner une PHT commandée par un historique global, de ''PHT locale'' si elle est adressée par un historique local. [[File:Prédiction dynamique à deux niveaux.png|centre|vignette|upright=2|Prédiction dynamique à deux niveaux.]] Il est admis que l'historique global donne de meilleures prédictions que l'historique local. Mais les deux sont complémentaires, ce qui fait qu'il existe des approches hybrides. Les approches hybrides exploitent à la fois les statistiques pour chaque branchement et les corrélations entre branchements. Les prédicteurs hybrides ont de meilleures performances que les prédicteurs purement globaux ou locaux. Avec un historique global, la prédiction tient compte d'éventuelles corrélations entre branchements consécutifs/proches pour faire les prédictions. C'est un avantage pour les applications qui enchaînent des if...else ou qui contiennent beaucoup de if...else imbriqués les uns dans les autres, qui s’exécutent plusieurs fois de suite. Le résultat de chaque if...else dépend généralement des précédents, ce qui rend l'utilisation d'un historique global intéressant. Par contre, ils sont assez mauvais pour le reste des branchements. Mais cette qualité devient un défaut quand elle détecte des corrélations fortuites ou parasites, inutiles pour la prédiction (corrélation n'est pas causalité, même pour prédire des branchements). Du fait de ce défaut, la prédiction globale a besoin de registres d'historiques globaux très larges, de plusieurs centaines de bits au mieux. [[File:2 level branch predictor.svg|centre|vignette|upright=2|Unités de prédiction de branchement à deux niveaux avec une seule PHT.]] Le circuit précédent a cependant un problème : si deux branchements différents s'exécutent et que l'historique est le même, le processeur n'y verra que du feu. Il y a alors une interférence, à savoir que les deux branchements vont agir sur le même compteur à saturation. On se retrouve alors dans une situation conceptuellement identique à l’''aliasing'' dans le BTB ou avec des compteurs à saturation. Sauf que les interférences sont alors beaucoup plus nombreuses et qu'elles sont clairement un problème pour les performances ! Mitiger les interférences devient alors bien plus important qu'avec les unités de prédiction précédentes. Heureusement, si augmenter la taille de la PHT peut mitiger le problème, d'autres solutions sont possibles sur les unités de prédiction globales. Elles sont au nombre de deux : mitiger les interférences liées aux branchements biaisés, ajouter des informations qui discriminent les branchements. Voyons ces solutions dans le détail. ===La mitigation des interférences par usage de l'adresse de branchement=== La première solution pour réduire l'impact de l’''aliasing'' est d'augmenter la taille de la PHT et de l'historique. Plus l'historique et la PHT sont grands, moins l’''aliasing'' est fréquent. Mais avoir des historiques et des PHT de très grande taille n'est pas une mince affaire et le cout en termes de performances et de portes logiques est souvent trop lourd. Une autre solution consiste à utiliser, en plus de l'historique, des informations sur le branchement lui-même pour choisir le compteur à saturation à utiliser. Typiquement, on peut utiliser l'adresse du branchement en plus de l'historique, pour choisir le compteur à saturation adéquat. Pour être précis, on n'utilise que rarement l'adresse complète du branchement, mais seulement les bits de poids faible. La raison est que cela fait moins de bits à utiliser, donc moins de circuits, mais l’''aliasing'' est juste atténué, pas éliminé. Des branchements différents ont beau avoir des adresses différentes, les bits de poids faible de leurs adresses peuvent être identiques. Des confusions sont donc possibles, mais sont assez rares pour que cela suffise en pratique. Une unité de prédiction de ce type est illustrée ci-dessous. Il y a plusieurs PHT séparées, donc plusieurs ensembles de 2^n compteurs à saturation. Le choix de la PHT est réalisé par l'adresse du branchement, alors que le choix du compteur à saturation est réalisé par l'historique. Les compteurs à saturation d'un branchement sont mis à jour seulement quand le branchement s'est chargé pendant que l'historique associé était dans le registre d'historique. Cette méthode a cependant le défaut de gâcher énormément de transistors. [[File:Unité de prédiction de branchement basée sur des compteurs à saturation sélectionnés par l'historique global.jpg|centre|vignette|upright=2|Unité de prédiction de branchement basée sur des compteurs à saturation sélectionnés par l'historique global]] D'autres unités de prédiction fonctionnent sur le même principe que l'unité précédente, sauf que les différentes PHTs sont fusionnées en une seule grosse PHT. C'est ce que font les unités de prédiction '''''gshare''''' et '''''gselect''''', ainsi que leurs dérivées. Avec les unités de prédiction '''''gselect''''', on concatène les bits de poids faible de l'adresse du branchement et l'historique pour obtenir le numéro du compteur à saturation à utiliser. [[File:Prédiction « gselect ».png|centre|vignette|upright=2|Prédiction « gselect ».]] Avec les unités de prédiction '''''gshare''''', on fait un XOR entre le registre d'historique et les bits de poids faible de l'adresse du branchement. Le résultat de ce XOR donne le numéro du compteur à utiliser. Avec cette technique, on utilise une PHT globale et un historique global, mais le registre d'historique est manipulé de manière à limiter l'''aliasing''. [[File:Prédiction « gshare ».png|centre|vignette|upright=2|Prédiction « gshare ».]] Intuitivement, on pourrait croire que les unités de prédiction gselect ont de meilleures performances. C'est vrai que les unités gshare combinent l'adresse du branchement et l'historique d'une manière qui fait perdre un peu d'information (impossible de retrouver l'adresse du branchement et l'historique à partir du résultat du XOR), alors que les unités gselect conservent ces informations. Mais il faut prendre en compte le fait que les deux unités doivent se comparer à PHT identique, de même taille. Prenons par exemple une PHT de 256 compteurs, adressée par 8 bits. Avec une unité gselect, les 8 bits sont utilisés à la fois pour l'historique et pour les bits de poids faible de l'adresse du branchement. Alors qu'avec une unité gshare, on peut utiliser un historique de 8 bits et les 8 bits de poids faible de l'adresse du branchement. L'augmentation de la taille de l'historique et du nombre de bits utilisés fait que l’''aliasing'' est réduit, et cela compense le fait que l'on fait un XOR au lieu d'une concaténation. Les unités précédentes réduisent l'''aliasing'' en segmentant la PHT : telles entrées sont allouées à un branchement, telles entrées à tel autre branchement, etc. Cependant, l'historique local n'est pas utilisé. Mais quitte à allouer des entrées à un branchement, autant en profiter pour leur associer son historique local. Il s'agit alors d'approches hybrides. Par exemple, on peut citer la '''prédiction mixte''' (''alloyed predictor''). Avec celle-ci, la table des compteurs à saturation est adressée avec la concaténation : de bits de l'adresse du branchement, du registre d'historique global et du registre d'historique local adressé par l'adresse du branchement. [[File:Prédiction mixte.jpg|centre|vignette|upright=2|Prédiction mixte.]] Une version optimisée de ce circuit permet d’accéder à la PHT et à la BHT en parallèle. Avec elle les bits de poids faible de l'adresse du branchement sont concaténés avec l'historique global. Le résultat est ensuite envoyé à plusieurs PHT locales distinctes, en parallèle. Les différences PHT envoient leurs résultats à un multiplexeur, commandé par l'historique local, qui sélectionne le résultat adéquat. L'inconvénient de cette mise en œuvre est que le circuit est assez gros, notamment en raison de la présence de plusieurs PHT séparées. [[File:Alloyed predictor optimisé.jpg|centre|vignette|upright=2|''Alloyed predictor'' optimisé]] ===La mitigation des interférences par la structuration en caches des PHTs=== Les techniques précédentes utilisent les bits de poids faible de l'adresse pour sélectionner la PHT adéquate, ou pour sélectionner le compteur dans la PHT. Mais le reste de l'adresse n'est pas utilisé. Cependant, il est théoriquement possible de conserver les bits de poids fort, afin d'identifier le branchement associé à un compteur. Pour cela, chaque compteur à saturation se voit associé à un ''tag'', qui mémorise le reste de l'adresse du branchement associé au compteur, comme pour les mémoires caches. Lorsque l'unité de prédiction démarre une prédiction, elle récupère les bits de poids fort de l'adresse du branchement et compare ceux-ci avec le ''tag''. S’il y a un ''match'', c'est signe que le compteur est bien associé au branchement à prédire. Dans le cas contraire, c'est signe qu'on fait face à une situation d’''aliasing'' et le compteur n'est pas utilisé pour la prédiction. Le résultat transforme la PHT en une sorte de cache assez particulier, qui associe un compteur à saturation à chaque couple adresse-historique. La technique du paragraphe précédent peut être encore améliorée afin de réduire l’''aliasing''. L’''aliasing'' sur une unité de prédiction de branchement est similaire aux conflits d'accès au cache, où deux données/adresses atterrissent dans la même ligne de cache. Une PHT peut formellement être vue comme un cache directement adressé. Une solution pour limiter ces conflits d'accès à de tels, est de les transformer en un cache associatif à n voies. On peut faire la même chose avec les unités de prédiction de branchement, ce qui revient à dupliquer les PHT, de manière à ce que les bits de poids faible de l'adresse se voient attribuer à plusieurs compteurs à saturation, un par branchement possible. Ce faisant, on réduit les interférences entre branchements. Mais cette technique augmente la quantité de circuits utilisés et la consommation d'énergie, ainsi que le temps d'accès, sans compter qu'elle requiert d'utilisation de ''tags'' pour fonctionner à la perfection. Pour les unités de prédiction de branchement, les mesures à ce sujet ne semblent pas montrer un impact réellement important sur les performances, comparé à une organisation de type directement adressé. Il est possible de pousser la logique encore plus loin en ajoutant des caches de victime et d'autres mécanismes courant sur les caches usuels. ===La mitigation des interférences liées aux branchements biaisés=== L’''aliasing'' a un impact négatif quand deux branchements aliasés (qui sont attribués au même compteur à saturation) vont dans des sens différents : si l'un est non-pris, l'autre a de bonnes chances d'être pris (ou inversement). Or, la plupart des branchements sont biaisés, à savoir très fortement orientés dans une direction qu'une autre (presque toujours pris ou presque toujours non-pris, à plus de 90/99% de chances). Plus un branchement a une probabilité élevée d' être pris ou non-pris, plus sa capacité à interférer avec les autres est forte. Le pire des cas est quand deux branchements sont biaisés mais dans des directions inverses : l'interférence est maximale. En conséquence, les branchements biaisés ou quasi biaisés sont les plus problématiques pour l’''aliasing''. Or, il est possible de concevoir des prédicteurs de branchements qui atténuent fortement les interférences des branchements biaisés ou quasi biaisés. C’est le cas de l’''agree predictor'' et de l'unité de prédiction bimodale, que nous allons voir dans ce qui suit. Les branchements très souvent pris vont interférer très fortement avec les branchements très souvent non-pris, et inversement. Par contre, les branchements très souvent pris n'interféreront pas entre eux, de même que les branchements non-pris iront bien ensemble. D'où l'idée de séparer ces deux types de branchements dans des PHT séparées, pour éviter le mauvais ''aliasing''. L''''unité de prédiction bimodale''' part d'une unité gshare, et sépare la PHT globale en deux : une PHT dédiée aux branchements qui sont très souvent pris et une autre pour les branchements très peu pris. Les deux PHT fournissent chacune une prédiction. La bonne prédiction est choisie par un multiplexeur commandé par une unité de sélection, qui se charge de déduire quelle unité a raison. Cette unité de sélection est une unité de prédiction basée sur des compteurs à saturation de 2 bits. On peut encore améliorer l'unité de sélection de prédiction en n'utilisant non pas des compteurs à saturation, mais une unité de prédiction dynamique à deux niveaux, ou toute autre unité vue auparavant. [[File:Prédiction bimodale.jpg|centre|vignette|upright=2|Prédiction bimodale.]] La '''prédiction par consensus''' (''agree predictor'') combine une unité de prédiction à historique global (idéalement de type gshare ou gselect, mais une unité normale marche aussi) et des compteurs à saturation. Il s'agit techniquement d'une technique de prédiction mixte, à la fois globale et locale. Le BTB intègre un compteur à saturation de un bit, qui indique la direction privilégiée du branchement. L'unité à historique global a un fonctionnement changé : elle calcule non pas la probabilité que le branchement soit pris ou non-pris, mais la probabilité que le résultat du branchement soit compatible avec le résultat de l'unité de prédiction de 1 bit. La prédiction finale vérifie que ces deux circuits sont d'accord, en faisant un NXOR entre les résultats des deux unités de prédiction de branchement. [[File:Prédiction par consensus.png|centre|vignette|upright=2|Prédiction par consensus.]] Des calculs simples de probabilités montrent que l'''agree predictor'' a des résultats assez importants. Ses résultats sont d'autant meilleurs que les branchements sont biaisés et penchent fortement vers le côté pris ou non-pris. ==La prédiction de branchement avec des perceptrons== Les méthodes précédentes utilisent au moins un compteur à saturation par historique possible. Sachant qu'il y a 2^n valeurs possibles pour un historique de n bits, le nombre de compteurs à saturation augmente exponentiellement avec la taille de l'historique. Ce qui limite grandement la taille de l'historique, alors qu'on aurait au contraire besoin d'un historique assez large pour faire des prédictions excellentes. Pour contourner le problème, des alternatives basées sur des techniques d'apprentissage artificiel (''machine learning'') existent. Un exemple est celui des unités de prédiction de branchement dites de ''neural branch prediction'', basée sur des perceptrons. Dans ce qui suit, nous les appelerons des '''unités perceptron'''. Les perceptrons sont un algorithme d'apprentissage automatique très simple, ce qui fait que les explications qui vont suivre devraient être simples. En théorie, il est possible d'utiliser des techniques d'apprentissage automatique plus élaborées, mais cela n'est pas possible en pratique. Rappelons qu'une unité de prédiction de branchement doit idéalement fournir sa prédiction en un cycle d'horloge. L'implémentation d'un perceptron est déjà problématique de ce point de vue, car elle demande d'utiliser beaucoup de circuits arithmétiques complexes (multiplication et addition). Autant dire que les autres techniques de ''machine learning'', encore plus gourmandes en calculs, ne sont pas praticables. ===Les perceptrons et leur usage en tant qu'unité de prédiction de branchements=== L'idée derrière les unités perceptron est que certains bits de l'historique sont plus importants que d'autres. En soi, ce genre de situation est réaliste. Il arrive que certains branchements soient pris uniquement si les deux branchements précédents ne le sont pas, ou que si l'avant-dernier branchement est lui aussi pris. Mais les autres résultats de branchement présents dans l'historique ne sont pas utiles. En conséquence, deux historiques semblables, qui ne sont différents que d'un ou deux bits, peuvent donner des prédictions presque identiques. Dans ce cas, on peut faire la prédiction en n'utilisant que les bits identiques entre ces historiques, ou du moins en leur donnant plus d'importance qu'aux autres. On exploite alors des corrélations avec certains bits de l'historique, plutôt qu'avec l'historique entier lui-même. Pour coder ces corrélations, on associe un coefficient à chaque bit de l'historique, qui indique à quel point ce bit est important pour déterminer le résultat final. Ce coefficient est proportionnel à la corrélation entre le bit de l'historique associé, et le résultat du branchement à prédire. Les coefficients sont des nombres entiers qui peuvent être positifs, nuls ou négatifs. Un coefficient positif signifie que si le branchement associé a été pris, alors le branchement à prédire a de bonnes chances de l'être aussi (et inversement). Un coefficient nul signifie que les deux branchements sont indépendants et que le bit de l'historique associé n'est pas à prendre en compte. Pour un coefficient négatif, cela signifie que si le branchement associé a été pris, alors le branchement à prédire a de bonnes chances d'être non-pris (et inversement). Les coefficients en question sont généralement compris entre -1 et 1. Une fois qu'on connait ces coefficients de corrélations, on peut calculer la probabilité que le branchement à prédire soit pris, avec des calculs arithmétiques simples. Dans les calculs qui vont suivre, un branchement pris est codé par un 1, alors qu'un branchement non-pris est codé par un -1 ! Ce détail permet de simplifier grandement les calculs. Dans sa version la plus simple, le perceptron calcule un nombre Z qui est positif ou nul si le branchement est pris, négatif s'il ne l'est pas. Tous les coefficients <math>p_i</math> sont alors des nombres relatifs, pouvant être positifs, nuls ou négatifs. Ils sont généralement compris entre -1 et 1. La formule devient donc celle-ci : : <math>Z = w_0 + \sum_i (h_i \times w_i)</math> [[File:ArtificialNeuronModel francais.png|centre|vignette|upright=2|Perceptron.]] : Sur le plan mathématique, le perceptron effectue un produit scalaire entre deux vecteurs : l'historique et le vecteur des poids. Choisir les coefficients adéquats est le but de l'algorithme du perceptron. L'unité de prédiction part de poids par défaut, qui sont mis à jour à chaque bonne ou mauvaise prédiction. Toute la magie de cet algorithme tient dans la manière dont sont mis à jour les poids. L'idée est de prendre le vecteur des poids, puis d'ajouter ou soustraire l'historique suivant le résultat du branchement. Les poids évoluent ainsi à chaque branchement, améliorant leurs prédictions d'une exécution à l'autre. La règle de mise à jour des poids est donc la suivante : : <math>w_i \leftarrow w_i + t \times h_i</math>, avec t = 1 pour un branchement pris, -1 pour un branchement non-pris. ===L'implémentation matérielle des perceptrons et ses optimisations=== Les calculs réalisés par un perceptrons sont simples, juste des multiplications et des additions et cela se sent dans la conception du circuit. L'unité perceptron utilise un perceptron par entrée du BTB, un par branchement pour simplifier. Pour cela, on utilise une mémoire SRAM qui mémorise les poids de chaque perceptron. La SRAM est adressée par les bits de poids faible du ''program counter'', de l'adresse du branchement. Une fois les poids récupérés, ils sont envoyés à un circuit de calcul de la prédiction, composé de circuits multiplieurs suivis par un additionneur multi-opérande. Le circuit de mise à jour des poids récupère la prédiction, les poids du perceptron, mais aussi le résultat du branchement. La mise à jour étant une simple addition, le circuit est composé d'additionneurs/soustracteurs, couplés à des registres pour mémoriser la prédiction et les poids du perceptron en attendant que le résultat du branchement soit disponible. Vu qu'il y a un délai de quelques cycles avant que le résultat du branchement soit disponible et que les prédictions doivent continuer pendant ce temps, les poids du perceptron et la prédiction sont stockés dans des FIFOs lues/écrites à chaque cycle. [[File:Unité de prédiction de branchement basée sur des perceptrons.png|centre|vignette|upright=2|Unité de prédiction de branchement basée sur des perceptrons]] Rien de bien compliqué sur le principe, mais rappelons que le perceptron doit fournir un résultat en moins d'un cycle d'horloge, et cela tient compte de l'accès au BTB. C'est un temps très court, surtout pour un circuit qui implique des multiplications. Les multiplications prennent facilement deux à trois cycles d'horloge. Autant dire que si on ajoute le temps d'accès à une petite SRAM, pour récupérer le perceptron, c'est presque impossible. Mais quelques optimisations simples permettent de rendre le calcul plus rapide. Premièrement, la taille de la SRAM est réduite grâce à quelques économies assez simples. Notamment, le perceptron utilise des poids codés sur quelques bits, généralement un octet, parfois 7 ou 5 bits, guère plus. Les poids sont encodés en complément à 1 ou en complément à 2, là où les perceptrons normaux utilisent des nombres flottants. Rien que ces deux choix simplifient les circuits et les rendent plus rapides. Le désavantage d'utiliser peu de bits par poids est une perte mineure en termes de taux de prédiction, qui est plus que compensée par l'économie en portes logiques, qui permet d'augmenter la taille de la SRAM. D'autres optimisations portent sur le circuit de calcul. Par exemple, la multiplication <math>h_i \times w_i</math> est facultative. Vu que l'historique contient les valeurs 1 et -1, il suffit d'additionner le poids associé si la valeur est 1 et de le soustraire s'il vaut -1. On peut même simplifier la soustraction et se limiter à une complémentation à un (une inversion des bits du poids). En faisant cela, les circuits multiplieurs disparaissent et le circuit de prédiction se résume à un simple additionneur multi-opérande couplé à quelques inverseurs commandables. Certains perceptrons prennent en compte à la fois l'historique global et l'historique local pour faire leur prédiction. Les deux historiques sont simplement concaténés et envoyés en entrée du perceptron. Cela marche assez bien, car les perceptrons peuvent utiliser des historiques de grande taille sans problèmes. Par contre, il y a besoin d'ajouter une ''branch history table'' au circuit, ce qui demande des portes logiques en plus, mais aussi un temps de calcul supplémentaire (l'accès à cette table n'est pas gratuit). ===Les avantages et inconvénients des perceptrons pour la prédiction de branchements=== L'avantage des unités perceptron est qu'elles utilisent un nombre de portes logiques qui est proportionnel à la taille de l'historique, et non pas exponentiel. Cela permet d'utiliser un historique de grande taille, et donc d'obtenir un taux de bonnes prédictions très élevé. Leur désavantage est que leurs performances de prédiction sont moins bonnes à historique équivalent. Les unités perceptrons ont des taux de prédiction élevés, mais seulement quand certaines conditions mathématiques sont respectées par les historiques. La condition en question est que les historiques correspondants à un branchement pris et les historiques où ce même branchement est non-pris doivent être linéairement séparables. Heureusement, les branchements des programmes informatiques sont une majorité à respecter la condition de séparation linéaire, ce qui rend les perceptrons parfaitement adaptés à la prédiction de branchements. La notion de linéairement séparable est un peu difficile à comprendre. Pour l'expliquer, imaginez que les N bits de l'historique forme un espace à N-dimensions discrets. Chaque historique est un point dans cet espace N-dimensionnel et chaque bit sert de coordonnée pour se repérer dans cet espace. Une fonction est linéairement séparable si l'espace N-dimensionnel peut être coupé en deux par un hyperplan : d'un côté de ce plan se trouvent les historiques des branchements pris, de l'autre se trouvent les historiques des branchements non-pris. Cet hyperplan est défini par l'ensemble des points qui respecte l'équation suivante : : <math>w_0 + \sum_i (h_i \times w_i) = 0</math> Un exemple où cette condition est respectée est quand le résultat d'une prédiction se calcule avec un ET entre plusieurs branchements de l'historique. Si un branchement est pris quand plusieurs branchements de l’historique sont tous pris ou tous non-pris, la condition est respectée et le perceptron donnera des prédictions correctes. Un exemple où cette condition n'est pas respectée est un banal XOR. Prenons l'exemple d'un historique global de 2 bits. Imaginons que le branchement à prédire soit pris : soit quand le premier branchement de l'historique est pris et le second non-pris, soit quand le premier est non-pris et le second pris. Concrètement, la prédiction exacte se calcule en faisant un XOR entre les deux bits de l'historique. Dans ce cas, la condition "linéairement séparable" n'est pas respectée et le perceptron n'aura pas des performances optimales. Les unités perceptron utilisent idéalement un perceptron par branchement. Cela demande d'associer, l'adresse du branchement pour chaque perceptron, généralement en donnant un perceptron par entrée du BTB. Mais une telle organisation n'est pas possible en pratique, car elle demanderait d'ajouter un ''tag'' à chaque perceptron/entrée du BTB, ce qui demanderait beaucoup de circuits et de ressources matérielles. Dans les faits l'association entre un branchement et un perceptron se fait en utilisant les bits de poids faible de l'adresse de branchement. Mais faire cela entrainera l'apparition de phénomènes d'''aliasing'', comme pour les unités de prédiction à deux niveaux. Les techniques vues précédemment peuvent en théorie être adaptées sur les unités à perceptrons, dans une certaine mesure. Notons que les perceptrons sont des algorithmes ou des circuits qui permettent de classer des données d'entrée en deux types. Ici, la donnée d'entrée est l'historique global et la sortie du perceptron indique si le branchement prédit est pris ou non-pris. Ils ne sont donc pas adaptés à d'autres tâches de prédiction, comme pour le préchargement des lignes de cache ou la prédiction de l'adresse de destination d'un branchement, ou toute autre tâche d'exécution spéculative un tant soit peu complexe. Leur utilisation reste cantonnée à la prédiction de branchement proprement dit, guère plus. Par contre, les autres techniques vues plus haut peuvent être utilisées pour le préchargement des lignes de cache, ou d'autres mécanismes de prédiction plus complexes, que nous aborderons dans la suite du cours. ==La prédiction avec plusieurs unités de branchement distinctes== Il est possible de combiner plusieurs unités de prédiction de branchement différentes et de combiner leurs résultats en une seule prédiction. On peut par exemple combiner une unité de prédiction spécialisée dans les boucles, une unité de prédiction 2 bits basée sur des compteurs à saturation, une unité de prédiction statique et une unité ''gshare''. Tout le problème est de décider quelle unité a fait la bonne prédiction, comment combiner les résultats des différentes unités de prédiction. Soit on choisit un résultat qui parait plus fiable que les autres, soit on combine les résultats pour faire une sorte de moyenne des prédictions. La méthode la plus simple de choisir le résultat d'une unité de prédiction parmi toutes les autres. Pour implémenter le tout, il suffit d'un multiplexeur qui effectue le choix de la prédiction. Reste à commander le multiplexeur, ce qui demande un '''circuit méta-prédicteur''' qui sélectionne la bonne unité de prédiction. Il arrive que le circuit méta-prédicteur soit conçu avec les mêmes techniques que les unités de prédiction de branchement elle-mêmes. Dans le cas le plus simple, il s'agit d'un simple compteur à saturation, mis à jour suivant la concordance des prédictions avec le résultat effectif du branchement. Mais c'est loin d'être systématique. Une autre solution combine le résultat des différentes unités de prédiction avec une fonction mathématique adéquate. Avec un nombre impair d'unités de prédiction, une méthode assez efficace est de simplement prendre le résultat majoritaire. Si une majorité d'unités pense que le branchement est pris, alors on le considère comme pris, et comme non pris dans le cas contraire. Par contre, avec un nombre pair d'unités, cette méthode simple ne marche pas, il y a un risque d'égalité. L'unité de prédiction de branchement « '''''e-gskew''''' » utilise trois unités gshare et combine leurs résultats avec un vote à majorité. Les trois unités gshare utilisent des XOR légèrement différents, dans le sens où les bits de l'adresse du branchement choisi ne sont pas les mêmes dans les trois unités, sans compter que le résultat du XOR peut subir une modification qui dépend de l'unité de prédiction choisie. Autrement dit, la fonction de hachage qui associe une entrée à un branchement dépend de l'unité. [[File:Unité de prédiction « e-gskew ».png|centre|vignette|upright=2|Unité de prédiction « e-gskew ».]] Enfin, une dernière solution est que certaines unités de prédiction ont la priorité sur toutes les autres. Par exemple, prenons un processeur avec un ''return stack predictor'', une unité qui détecte les boucles et une unité de prédiction avec un historique. Si le ''return stack predictor'' détecte une instruction de retour de fonction, sa prédiction passera avant toutes les autres unités de prédiction. Et dans ce qui va suivre, c'est cette solution qui sera utilisé pour les unités de prédiction qu'on va étudier. ===La prédiction des branchements des boucles=== Les boucles sont à part, pour ce qui est de la prédiction de branchement. Rappelons qu'une boucle FOR/WHILE répète une série d'instruction N fois, le nombre N étant appelé le '''compteur de boucle'''. Ce qui fait que les branchements d'une boucle FOR sont pris n − 1 fois, la dernière exécution étant non prise. La conséquence est que les unités de prédiction usuelles sont assez mauvaises pour prédire les branchements des boucles. Par contre, la prédiction statique marche particulièrement bien comparé au reste. La prédiction statique prédit que les branchements des boucles sont toujours pris, ce qui donne des prédictions qui sont d'autant meilleures que la boucle est répétée un grand nombre de fois. Reste à gérer les branchements des boucles au mieux, en sachant cela. L'idée est d'utiliser plusieurs unités de prédiction séparées : une spécialisée dans les boucles et d'autres unités de prédiction. Uen unité de méta-prédiction se charge d'activer l'unité de prédiction des boucles dès qu'une boucle est détéctée. Les unités de prédiction modernes essayent de détecter les branchements des boucles afin de les mettre à part. En soi, ce n'est pas compliqué les branchements des boucles sont presque toujours des branchements descendants et réciproquement. De plus, ces branchements sont pris à chaque répétition de la boucle, et ne sont non-pris que quand on quitte la boucle. Le compteur de boucle analyse l'exécution des branchements, pour détecter les branchements des boucles. Un branchement est considéré comme un branchement de boucle si : il est pris N fois, avant de ne pas être pris une fois. Au moment où le branchement n'est plus pris, le ''Loop Predictor'' lui attribue une entrée dédié. L'entrée mémorise mémorise le nombre de fois que ce branchement a été pris, à savoir N, et est associée à un compteur. Le compteur est incrémenté/décrémenté à chaque exécution du branchement, et sa valeur est comparée à la valeur N. Quand le compteur atteint N+1, il est réinitialisé. Il s'agit d'une implémentation simple, mais est visiblement proche de celle utilisée sur le Pentium M, si j'en crois l'article "The Intel Pentium M Processor : Microarchitecture and Performance". Il faut noter que la boucle doit s'exécuter au moins une fois pour être détectée, et seules les exécutions suivantes sont prédites correctement. La boucle doit donc s'exécuter plusieurs fois, ce qui implique qu'elle est elle-même dans une boucle plus grande qu'elle. Une telle unité est donc capable de prédire spécifiquement les branchements de boucles FOR imbriquées, à savoir où une boucle FOR est dans une autre boucle FOR. Concrètement, cela permet de répéter la première boucle FOR plusieurs fois de suite, souvent un grand nombre de fois. Avec les boucles imbriquées, le compteur de boucle peut changer d'une répétition à l'autre, mais ce n'est que rarement le cas. Dans de nombreux cas, le compteur de boucle reste le même d'une répétition à l'autre. Ce qui fait qu'une fois la boucle exécutée une première fois, on peut prédire à la perfection les exécutions suivantes. Pour cela, il suffit de déterminer le compteur de boucle après la première exécution de la boucle, puis de le mémoriser dans un compteur. Lors des exécutions suivantes de la boucle, on compte le nombre de fois que le branchement de la boucle s'exécute : il est prédit comme pris tant que le compteur est différent du compteur de boucle, mais non-pris en cas d'égalité. ===Le prédicteur L-TAGE=== Il est possible d'utiliser plusieurs unités de prédiction, chacune utilisant des historiques de taille différente. Pour donner un exemple simple, on pourrait avoir une unité avec un historique de 2 bits, une autre avec un historique de 3 bits, une autre de 4 bits, etc. Une méta-unité de branchement prédit alors quelle taille d'historique donne les meilleurs résultats. Il est fréquent que les tailles d'historiques utilisées suivent une formule mathématique précise, appelée suite géométrique, ce qui est un mot bien barbare pour dire que l'on passe d'un historique au suivant en multipliant sa taille par une constante bien précise. Par exemple, on pourrait avoir des historiques de taille 2, 4, 8, 16, 32. C'est d'ailleurs le principe derrière les '''unités de branchement TAGE (''TAgged GEometric length predictor'')'''. Elles utilisent plusieurs registres d'historiques, chacun avec sa propre PHT, dont la taille suit une suite géométrique. Avec cette méthode, les branchements qui ont un motif répétitif sont alors parfaitement détectés, peu importe sa taille. Par exemple, un branchement avec un historique dont le cycle est pris, non-pris, non-pris, sera parfaitement prédit avec un historique de 3 bits, 6 bits, ou de 3*n bits, mais pas forcément avec un historique de 4 ou 8 bits. De ce fait, utiliser plusieurs unités de branchement avec plusieurs tailles d'historique fonctionne plutôt bien. Le prédicteur TAGE a été raffiné avec le temps, sur plusieurs points, que je ne vais pas détailler dans ce qui suit. Précisons cependant que les PHT sont structurées en caches, afin d'éliminer les phénomènes d'''aliasing''. L'ajout d'une unité de prédiction pour les boucles, a donné le '''prédicteur L-TAGE''', qui a lui-même été amélioré par la suite. Les unités de branchement TAGE sont les plus utilisées au moment où j'écris ces lignes (2026), et la majorité des processeurs x86 commerciaux utilisent une variante de ce prédicteur, à quelques exceptions près. ===La prédiction de branchement à deux niveaux=== Les processeurs modernes utilisent une prédiction de branchement à deux niveaux. Une première unité de branchement fournit des résultats rapides et peu précis, la seconde a des résultats plus précis mais fournit ses résultats après quelques cycles d'horloge. La première unité envoie les adresses prédites au cache d'instruction, pour charger les instructions prédites et met à jour le ''program counter'' en conséquence. La seconde envoie les adresses prédites à l'étage de décodage d'instruction, histoire d'annuler des branchements considérés à tort comme pris/non-pris. Il y a donc une unité de prédiction rapide, dont le résultat est corrigé par une seconde unité. La seconde unité a le dernier mot, elle corrige ou annule les prédictions de la première. Ce qui lui vaut le nom, en anglais, d'''overiding predictor''. La correction se fait simplement en corrigeant le ''program counter'' et en forçant le décodeur à émettre des NOPs pendant quelques cycles, le temps d'éliminer les instructions chargées à tort. [[File:Double BTB sur les CPU modernes.png|centre|vignette|upright=2|Double BTB sur les CPU modernes.]] Les deux unités de prédiction n'utilisent pas le même algorithme. Par exemple, la microarchitecture Silvermont d'Intel utilisait deux unités de prédiction : une unité rapide basée sur des compteurs à saturation, et un prédicteur ''gshare'' lent. Les deux unités de prédiction utilisent des BTB séparés, de taille différente, au moins deux. Vu que les BTB sont des caches, on les appelle des '''BTB de niveau L1 et L2'''. Par exemple, les processeurs Bulldozer d'AMD disposent de deux BTBs : un BTB L1 de 512 entrées, et un BTB L2 de 5120 entrées. Le BTB de niveau L2 est souvent un cache de victime, qui mémorise les branchements retirés de la BTB de niveau L1. La BTB pour les branchements indirects vient en plus de ces deux BTBs, elle complète souvent la BTB de niveau L2. Le ''return stack predictor'' vient en plus des BTB L1 et L2, mais sa petite taille et sa rapidité font qu'il complète l'unité/BTB de niveau L1. Quelques processeurs ont trois niveaux de BTB, avec un niveau L0, L1 et L2. La BTB L0 aliment le cache d'instruction, alors que la L1 et la L2 préviennent les décodeurs d'instruction. La différence entre la L1 et la L2 est que la L2 met plus de temps à prévenir les décodeurs. Les branchements peuvent donc être annulés/forcés à deux étages de décodage différents. Pour donner quelques exemples, les processeurs Zen d'AMD disposent de plusieurs BTBs, avec des niveaux L0, L1 et L2. Le tableau ci-dessous vous donne la taille et la latence de chaque niveau de BTB. La BTB de niveau L0 alimente le cache d'instruction, la L1 permet d'annuler les instructions au premier cycle de décodage, la BTB L2 annule les instructions vers le milieu/fin du décodage. {|class="wikitable" |- ! rowspan="3" | Zen 2 | 16 || 0 |- | 512 || 1 |- | 7168 || 4 |- ! rowspan="3" | Zen 3 | 16 || 0 |- | 1024 || 1 |- | 6656 + 1536 (branchements indirects)|| 4 |} ==Les optimisations de la prédiction de branchements== La prédiction de branchement est complexe et toute économie est bonne à prendre. Pour améliorer les performances, ou simplement améliorer l'utilisation du BTB et des PHT, diverses techniques ont été inventées. Ces techniques marchent quel que soit l'unité de prédiction prise en compte. Elles peuvent s'appliquer aussi bien aux unités basées sur des perceptrons, que sur des unités à deux niveaux ou des unités de prédiction dynamique en général. Ces techniques sont assez variés : certaines profitent du fait que de nombreux branchements sont biaisés, d'autres tentent de réduire les interférences entre branchements sans utiliser l'historique global/local, etc. ===Le filtrage de branchements=== Une première optimisation permet d'économiser l'usage des différentes ressources matérielles, comme la BTB ou les PHT, en les réservant aux branchements non-biaisés. Idéalement, les branchements biaisés peuvent être prédits en utilisant des techniques très simples. D'où l'idée d'utiliser deux unités de prédiction spécialisées : une pour les branchements biaisés et une autre plus complexe. L'unité de prédiction pour les branchements biaisés est une unité de prédiction à 1 bit qui indique si le branchement est pris ou non, ce qui suffit largement pour de tels branchements. Cette technique s'appelle le '''filtrage de branchement''' et son nom est assez parlant. On filtre les branchements biaisés pour éviter qu'ils utilisent des PHT et d'autres ressources qui gagneraient à être utilisées pour des branchements plus difficiles à prédire. Appliquer cette idée demande de reconnaître les branchements fortement biaisés d'une manière ou d'une autre, ce qui est plus facile à dire qu'à faire. Une solution similaire enregistre quels branchements sont biaisés dans le BTB, et utilise cette information pour faire des prédictions. ===L'usage de fonctions de hashage pour indexer les diverses SRAM/tables=== Plus haut, nous avons vu que les unités de prédiction de branchement contiennent des structures qui sont adressées par l'adresse de branchement. Tel est le cas du ''Branch Target Buffer'', de certaines PHT globales ou locales, de la ''Branch History Table'' qui stocke les historiques locaux, ou encore de la SRAM des poids des unités à base de perceptrons. En pratique, ces structures sont adressées non pas par l'adresse de branchement complète, mais pas les bits de poids faible de cette adresse. Cela a pour conséquence l'apparition d'un ''aliasing'' lié au fait que ces structures vont confondre deux branchements pour lesquels les bits de poids faible de l'adresse sont identiques. Il est possible d'utiliser autre chose que les bits de poids faible de l'adresse du branchement, afin de limiter les interférences. Et les possibilités sont multiples. Les possibilités en question s'inspirent des traitements effectués sur les adresses des banques dans les mémoires évoluées. Une première solution serait de faire un XOR entre les bits de poids faible de l'adresse du branchement, et d'autres bits de cette même adresse. Ainsi, deux branchements éloignés en mémoire donneraient des résultats différents, même si leurs bits de poids faible sont identiques. Une autre possibilité serait de diviser l'adresse du branchement par un nombre et de garder le reste. Ce calcul de modulo n'est en soi pas très différent du fait de conserver seulement les bits de poids faible. Conserver les N bits de poids faible consiste en effet à prendre le modulo par 2^N de l'adresse. Ici, l'idée serait de faire un modulo par un nombre P, qui serait un nombre premier proche de 2^N. Le fait que le nombre soit premier limite les cas où deux adresses différentes donneraient le même reste, ce qui réduit l'''aliasing''. Le fait de prendre un nombre proche de 2^N pour une entrée de N bits est que le résultat reste assez proche de ce qu'on obtiendrait en gardant les bits de poids faible, cette dernière étant une solution pas trop mauvaise. D'autres possibilités similaires se basent sur des réductions polynomiales ou d'autres astuces impliquant des nombres premiers. L'efficacité de ces méthodes dépend grandement de la taille de la SRAM/table considérée, des adresses des branchements et de beaucoup d'autres paramètres. La réduction des interférences par telle ou telle méthode dépend aussi de l'unité de prédiction considérée. Les résultats ne sont pas les mêmes selon que l'on parle d'une unité à perceptrons ou d'une unité à deux niveaux ou d'unités à base de compteurs à saturation, ou que l'on parle du BTB. Par contre, toutes les méthodes ne sont pas équivalentes en termes de temps de calcul ou de portes logiques. Autant la première solution avec un XOR ajoute un temps de calcul négligeable et quelques portes logiques, autant les autres méthodes requièrent l'ajout d'un diviseur très lent et gourmand en portes logiques pour calculer des modulos. Elles ne sont généralement pas praticables pour ces raisons, le temps de calcul serait trop élevé. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Le pipeline | prevText=Le pipeline | next=Les optimisations du chargement des instructions | nextText=Les optimisations du chargement des instructions }} </noinclude> 7gk2wz37dvaz3jlox8n4rul731gr0pm 774405 774404 2026-10-07T21:48:04Z Mewtow 31375 /* La prédiction de destination pour les branchements indirects */ 774405 wikitext text/x-wiki Les branchements sont pris en charge par plusieurs étages du pipeline. Exécuter un branchement demande de savoir deux choses : s'il est pris ou non, quelle est l'adresse de destination. L'adresse de destination est localisée grâce au mode d'adressage du branchement. Avec le mode d'adressage direct, elle est extraite de l'instruction par le séquenceur. Avec l'adressage indirect, elle est lue dans un registre. Avec les branchements relatifs, elle est calculée via un additionneur, qui additionne un décalage au ''program counter''. L'additionneur peut être soit dans l'étage de ''PC Update'', soit dans l'unité de calcul de l'étage d'exécution. Passons maintenant au résultat du branchement, à savoir s'il est pris ou non. Pour savoir si le branchement est pris, il faut distinguer les branchements conditionnels et inconditionnels. Pour rappel, les branchements inconditionnels regroupent les appels de fonction, les simples instructions de saut, et quelques autres. Ils sont toujours pris, ils s'exécutent toujours. À partir du moment où le décodeur a décodé un branchement inconditionnel, il sait que le branchement est pris. Pour les branchements conditionnels, ils sont pris seulement si une condition précise est respectée. On sait s'ils sont exécutés une fois que la condition est calculée par l'unité de calcul, ou par une unité de branchement spécialisée. [[File:Gestion des branchements sur un pipeline.png|centre|vignette|upright=2.0|Gestion des branchements conditionnels sur un pipeline.]] Sur le schéma précédent, les branchements inconditionnels ne sont pas montrés. Pour cela, il faudrait rajouter une connexion du décodeur vers le multiplexeur de branchement. De plus, il faudrait aussi rajouter de quoi retarder l'exécution du branchement seulement une fois que l'adresse est connue. Par exemple, elle est connue un cycle d'horloge plus si l'adresse est lue depuis les registres, comparé à un branchement direct. ==Les branchements et les exceptions imprécises/précises== Un branchement est censé altérer le ''program counter'' immédiatement, en un cycle d'horloge. Mais sur un pipeline, Le branchement fait bien reprendre le processeur au bon endroit, mais avec du retard. La raison est qu'un branchement met plusieurs cycles pour s'exécuter. Il faut plusieurs cycles pour déterminer quelle est l'adresse de destination, temps qui dépend de là où est lue l'adresse. Et on sait si le branchement est pris seulement au niveau du décodeur, voire au niveau de l'unité de calcul. Le branchement doit traverser plusieurs étages avant qu'on connaisse son résultat. Il y a bien 3 à 4 cycles avant cela. Et pendant ce temps de trajet, des instructions seront chargées dans le pipeline, potentiellement à tort. Si le branchement n'est pas pris, ce n'est pas grave : les instructions n'ont pas été chargées à tort. Mais dans le cas contraire, les instructions n'auraient pas dû rentrer dans le pipeline. Le problème est similaire à celui observé avec les exceptions précises/imprécises. La différence est que les branchements sont des instructions machines, pas les exceptions matérielles. Mais le problème des instructions chargées à tort est le même dans les grandes lignes. Une différence est que les branchements sont résolus plus ou moins au même étage, alors que les exceptions peuvent survenir dans tout le pipeline. Une autre différence, bien plus importante, est que les branchements non-pris ne chargent pas d'instructions à tort et qu'il faut faire la différence entre branchement pris et non-pris. Dans la suite, nous parlerons d'instructions fautives pour parler des instructions chargées à tort. Pour corriger ce problème, il existe une solution logicielle. Le compilateur fait suivre les branchements par des instructions qui ne font rien, des NOP (No OPeration) : c'est ce qu'on appelle un '''délai de branchement'''. L'idée est que toutes les instructions chargées à tort sont des NOPs, qui ne font rien. Mais le nombre de NOP à ajouter dépend du pipeline du processeur, ce qui peut poser des problèmes de compatibilité. Mais d'autres solutions, matérielles cette fois, sont utilisées de nos jours. ===L'annulation des instructions au décodage=== Les branchements inconditionnels sont dans un cas plus favorable : ils sont identifiés dans l'unité de décodage et sont toujours pris. D'autres branchements sont dans ce cas : les appels de fonctions, les interruptions logicielles. Avec eux, le décodeur annule les instructions fautives, il les remplace par des instructions NOP. L'étage de décodage sait que les instructions suivant un branchement inconditionnel ont été chargées à tort. Précisément N instructions, avec N le nombre d'étages entre le chargement et le décodage (inclus). Les N instructions suivantes sont tout simplement annulés, elles sont remplacées par des NOPs. L'implémentation demande juste un simple compteur et quelques circuits annexes. Cependant, la technique ne marche pas du tout pour les branchements conditionnels. En effet, on ne sait pas s'ils sont pris ou non à l'étage de décodage. Du moins pas avec un pipeline normal. Mais certains processeurs, basés sur le pipeline RISC classique, calculaient les branchements pendant l'étape de décodage, dans une ALU spécialisée qui travaille en parallèle de l'unité de décodage. Le défaut de cette technique est que l'ALU en question doit lire les registres. Pour rappel, sur ce pipeline, l'étape de décodage effectue la lecture des opérandes dans les registres, en parallèle de l'unité de décodage. Mais ce qu'on n'a pas dit dans le chapitre précédent, c'est qu'elle effectue aussi le calcul des branchements dans une mini-ALU séparée ! Ce faisant, le résultat du branchement est connu dès la fin de l'étape de décodage. ===L'usage du système d'exceptions précises pour les branchements conditionnels=== La solution gère le problème en empêchant les instructions chargées à tort d'enregistrer leurs résultats, afin qu'elles n'aient pas le moindre effet sur l'état du processeur. Le hardware pour les exceptions précises, décrit quelques paragraphes avant, est réutilisé pour cela. Les branchements conditionnels peuvent être pris ou non-pris et on doit tenir compte de ces deux cas. Si le branchement n'est pas pris, les instructions n'ont pas été chargées à tort, elles peuvent enregistrer leur résultat dans les registres. Par contre, ce n'est pas le cas pour les branchements pris, les mécanismes d'exceptions précise doivent alors d'activer. Pour cela, la meilleure solution est que l'unité de calcul génère un indicateur d'exception si le branchement n'est pas pris. L'ALU effectue la comparaison et génère ou non l'indicateur d'exception adéquat suivant le résultat de celle-ci. L'idée est d'utiliser un indicateur d'exception particulier, qui précise qu'un branchement a été pris, signe que des instructions ont été chargées à tort. L'indicateur est propagé dans le pipeline, jusqu'à atteindre l'étage d'enregistrement dans les registres. Cet étage décide s'il faut autoriser ou non les écritures qu'il recoit, pour annuler les instructions chargées à tort. Le ''program counter'' est corrigé immédiatement, simplement en exécutant le branchement. Reste à savoir qui génère cet indicateur d'exception de branchement pris. Diverses optimisations visent à éliminer les instructions chargées à tort, à faire en sorte que des instructions utiles soient chargées. Le chapitre suivant va détailler ces optimisations dans le détail, qui sont regroupées sous le terme de prédiction de branchement. Elles ne fonctionnent pas pour les exceptions précises, seulement les branchements. ===La prédiction de branchement=== Les techniques précédentes ont pour défaut que de nombreux cycles d'horloge sont gâchés à cause des instructions fautives. Pour éviter ce gâchis, les concepteurs de processeurs ont inventé l'''exécution spéculative de branchement'', qui consiste à deviner l'adresse de destination du branchement et charger les bonnes instructions dès le départ. Le but est de ne pas charger d'instruction à tort, en prédisant quelles seront les bonnes instructions à charger. Et cela demande de résoudre trois problèmes : * reconnaître les branchements ; * savoir si un branchement sera exécuté ou non : c'est la '''prédiction de branchement''' ; * dans le cas où un branchement serait exécuté, il faut aussi savoir quelle est l'adresse de destination : c'est la '''prédiction de l'adresse de destination''' d'un branchement, aussi appelée ''branch target prediction''. Pour résoudre le second problème, le processeur contient un circuit qui déterminer si le branchement est pris (on doit brancher vers l'adresse de destination) ou non pris (on poursuit l’exécution du programme immédiatement après le branchement) : c'est l''''unité de prédiction de branchement'''. La prédiction de direction de branchement est elle aussi déléguée à un circuit spécialisé : l’'''unité de prédiction de direction de branchement'''. C'est l'unité de prédiction de branchement qui autorise l'unité de prédiction de destination de branchement à modifier le ''program counter''. Dans certains processeurs, les deux unités sont regroupées dans le même circuit. [[File:Relation entre prédiction de branchement et de direction de branchement.png|centre|vignette|upright=2|Relation entre prédiction de branchement et de direction de branchement.]] ==Les erreurs de prédiction de branchement== Le processeur peut parfaitement se tromper en faisant ses prédictions, ce qui charge des instructions par erreur dans le pipeline. Dans ce cas, on parle d''''erreur de prédiction'''. Les erreurs de prédiction sont corrigées par le système d'exception précises, ou un équivalent. En clair, les instructions chargées à tort se propagent jusqu'à la sortie du pipeline, où leurs résultats sont alors annulés. N'importe quelle technique de gestion des exceptions précise fait le travail, mais les processeurs modernes utilisent un tampon de réordonnancement (''reorder buffer'') qu'on détaillera dans quelques chapitres. Détecter les erreurs de prédiction demande d'ajouter un circuit dans le processeur : l’''unité de vérification de branchement'' (''branch verification unit''). Elle génère un indicateur d'exception, qui est géré par le système d'exception précise. Pour générer l'indicateur, elle compare l'adresse de destination prédite et celle calculée en exécutant le branchement. Pour cela, l'adresse prédite doit être propagée dans le pipeline jusqu’à ce que l'adresse de destination du branchement soit calculée. [[File:Unité de vérification de branchement.png|centre|vignette|upright=2|Unité de vérification de branchement]] ===La pénalité de mauvaise prédiction=== Le temps pour vider le pipeline des instructions fautives dépend du pipeline. La dernière instruction à être chargée dans le pipeline le sera en même temps qu'on détecte l'erreur de prédiction. Il faudra attendre que cette instruction quitte le pipeline, et donc qu'elle traverse tous les étages. Il y a donc un temps d'attente pour collecter les résultats de ces instructions et la décision de ne pas enregistrer leurs résultats dans les registres Le nombre d'instructions chargées à tort dépend de où est détectée l'erreur de prédiction. Plus le résultat du branchement est connu tard, plus le nombre d'instructions chargées à tort sera grand, et plus le nombre de cycles d'horloge gâchés le sera aussi. Le nombre de cycles gâchés s'appelle la '''pénalité de mauvaise prédiction'''. Elle correspond précisément au nombre de cycles/étages entre le premier étage du pipeline, et l'étage où le branchement est résolu (on connait l'adresse à laquelle brancher). Plus celui-ci est éloigné du début du pipeline, plus la pénalité est importante. [[File:MIT 6.823 Computer System Architecture, Fall 2005 - P-3 vs. P-4 Pipelines - 4473425702.jpg|centre|vignette|upright=2.5|Comparaison de la pénalité de mauvaise prédiction entre le Pentium 3 et le Pentium 4 d'Intel.]] Elle est généralement d'autant plus longue que le pipeline lui-même est long. Les concepteurs de processeurs font donc face à un compromis : un pipeline long permet d'exécuter plus d'instructions en parallèle, mais cela augmente aussi la pénalité de mauvaise prédiction. Un pipeline de 10-15 étapes est un bon compromis. Les pipelines plus longs ont besoin d'une prédiction de branchement élaborée pour fonctionner correctement. Plus la prédiction de branchement est performante, plus le pipeline peut être long. Les pénalités de mauvaise prédiction seront plus importantes, mais les mauvaises prédictions seront plus rares, ce qui compense complétement. ===La ''minimal control dependency''=== En utilisant une technique du nom de ''minimal control dependency'', seules les instructions qui dépendent du résultat du branchement sont supprimées du pipeline en cas de mauvaise prédiction, les autres n'étant pas annulées. Sans ''minimal control dependency'', toutes les instructions qui suivent un branchement dans notre pipeline sont annulées. Pourtant, certaines d'entre elles pourraient être utiles. Prenons un exemple : supposons que l'on dispose d'un processeur de 31 étages (un Pentium 4 par exemple). Supposons que l'adresse du branchement est connue au 9éme étage. On fait face à un branchement qui envoie le processeur seulement 6 instructions plus loin. Si toutes les instructions qui suivent le branchement sont supprimées, les instructions en rouge sont celles qui sont chargées incorrectement. On remarque pourtant que certaines instructions chargées sont potentiellement correctes : celles qui suivent le point d'arrivée du branchement. Elles ne le sont pas forcément : il se peut qu'elles aient des dépendances avec les instructions supprimées. Mais si elles n'en ont pas, alors ces instructions auraient dûes être exécutées. Il serait donc plus efficace de les laisser enregistrer leurs résultats au lieu de les ré-exécuter à l’identique un peu plus tard. Ce genre de choses est possible sur les processeurs qui implémentent une technique du nom de ''Minimal Control Dependancy''. En gros, cette technique fait en sorte que seules les instructions qui dépendent du résultat du branchement soient supprimées du pipeline en cas de mauvaise prédiction. [[File:Minimal cntrol dependency.png|centre|vignette|upright=2|Minimal control dependency]] ==La reconnaissance des branchements== Pour prédire des branchements, le processeur doit faire la différence entre branchements et autres instructions, dès l'étage de chargement. Or, un processeur « normal », sans prédiction de branchement, ne peut faire cette différence qu'à l'étage de décodage, et pas avant. Les chercheurs ont donc dû trouver une solution. La première solution se base sur les techniques de prédécodage vues dans le chapitre sur le cache. Pour rappel, ce prédécodage consiste à décoder partiellement les instructions lors de leur chargement dans le cache d'instructions et à mémoriser des informations utiles dans la ligne de cache. Dans le cas des branchements, les circuits de prédécodage peuvent identifier les branchements et mémoriser cette information dans la ligne de cache. Une autre solution mémorise les branchements déjà rencontrés, dans un cache dédié. Ce cache mémorise l'adresse (le ''program counter'') des branchements déjà rencontrés : il s'appelle le '''tampon d’adresse de branchement''' (''branch adress buffer''). Il est intégré à l'unité de prédiction de branchement, mais est alimenté par le décodeur d'instruction. Reconnaitre un branchement demande l'implication du décodeur d'instruction, qui sait si l'instruction qu'il vient de décoder est un branchement ou non. Il peut même savoir si le branchement est conditionnel, inconditionnel, un appel de retour de fonction, etc. Lorsque le décodeur d'instruction reconnait un branchement, il ajoute ce branchement dans le cache Le tapon d'adresse de branchement contient une adresse mémoire par branchement, et éventuellement d'autres informations. Et pour comprendre quelle adresse est mémorisée, il faut faire attention à un détail. Les explications qui vont suivre vont faire intervenir deux adresses. * La première est l''''adresse de destination''' du branchement, à savoir l'adresse à laquelle le processeur doit reprendre son exécution si le branchement est pris. * L'autre adresse est l''''adresse du branchement''' lui-même. Elle correspond à sa position en mémoire RAM. L'adresse du branchement est la valeur du ''program counter'' quand le branchement est chargé dans le cache, alors que l'adresse de destination est celle du ''program Counter'' une fois le branchement exécuté. Il faudra faire bien attention à ne pas confondre les deux adresses dans ce qui suit. L'adresse du branchement est celle qui est utilisée pour la reconnaissance du branchement. Le tampon d'adresse de branchement mémorise, pour chaque branchement, son adresse de branchement (d'où son nom). À chaque cycle d'horloge, l'unité de chargement envoie le ''program counter'' en entrée du tampon d’adresse de branchement. Un succès dans ce cache indique qu'un branchement déjà rencontré est ré-executé, on peut utiliser la prédiction de branchement. Dans le cas contraire, la prédiction de branchement n'est pas utilisée, et l'instruction est chargée normalement. ==La prédiction de l'adresse de destination== La '''prédiction de l'adresse de destination d'un branchement''' détermine l'adresse de destination d'un branchement. Rappelons qu'il existe plusieurs modes d'adressage pour les branchements et que chacun d'entre eux précise l'adresse de destination à sa manière. Suivant le mode d'adressage, l'adresse de destination est soit dans l'instruction elle-même (adressage direct), soit calculée à l’exécution en additionnant un ''offset'' au ''program counter'' (adressage relatif), soit dans un registre du processeur (branchement indirect), soit précisée de manière implicite (retour de fonction, adresse au sommet de la pile). : Histoire de simplifier la terminologie, je parlerais parfois de '''prédiction de destination''' pour parler de la ''prédiction de l'adresse de destination d'un branchement''. Pour rappel, les branchements se classent en trois types : * les branchements directs pour lequel l'adresse de destination est toujours la même ; * les branchements indirects où l'adresse de destination est dans un registre et peut changer ; * les instructions de retour de fonction. Les branchements directs sont facilement prévisibles, vu que l'adresse vers laquelle il faut brancher est toujours la même. La prédiction des branchements directs et relatifs se fait globalement de la même manière, ce qui fait que nous ferons la confusion dans ce qui suit. Pour les instructions de retour de fonction, une prédiction parfaite est possible. Et c'est ce qui explique qu'elles sont traitées à part, par une unité de branchement dédiée. Pour les branchements indirects, vu que cette adresse change, la prédire celle-ci est particulièrement compliqué (quand c'est possible). Les branchements indirects sont eux aussi traités par une unité de prédiction dédiée rien que pour eux. ===La prédiction de destination pour les branchements directs=== Lorsqu'un branchement est exécuté, on mémoriser l'adresse de destination et la réutiliser lors d'exécutions ultérieures du branchement. Cette technique marche à la perfection pour les branchements directs, pour lesquels cette adresse est toujours la même, mais donne des résultats mitigés pour les branchements indirects. Pour mémoriser l'adresse de destination, on utilise un cache qui mémorise les correspondances entre l'adresse du branchement et l'adresse de destination. Ce cache est appelé le '''''branch target buffer''''' en anglais, qui sera abrévié en BTB dans ce qui suit. Le BTB est une amélioration du tampon d'adresse de branchement vu plus haut, auquel on aurait ajouté les adresses de destination des branchements. Le BTB est utilisé comme suit : on envoie en entrée l'adresse du branchement, le BTB répond au cycle suivant en précisant : s’il reconnaît l'adresse d'entrée, et quelle est l'adresse de destination si c'est le cas. Le BTB peut être un cache totalement associatif, associatif par voie, ou directement adressé, mais ces derniers sont les plus fréquents. Les BTB sont généralement des caches de type directement adressés, où seuls les bits de poids faible de l'adresse du branchement adressent le cache, le reste de l'adresse est tout simplement ignoré. Il existe aussi des BTB qui sont construits comme les caches associatifs à plusieurs voies, mais ceux-ci impliquent généralement la présence de ''tags'', ce qui fait qu'ils sont un peu moins fréquents. [[File:Branch target buffer directement adressé.png|centre|vignette|upright=2|Branch target buffer directement adressé.]] Comme pour tous les caches, un accès au BTB peut entraîner un défaut de cache, c’est-à-dire qu'un branchement censé être dans le BTB n'y est pas. Comme pour les autres caches, les défauts de cache peuvent se classer en trois types : les défauts de cache à froid (''cold miss'') causés par la première exécution d'un branchement, les défauts liés à la capacité du BTB/cache, et les défauts par conflit d'accès au cache. Les défauts liés à la capacité du BTB sont les plus simples à comprendre. La capacité limitée du BTB fait que d'anciens branchements sont éliminées pour laisser la place à de nouveaux. En conséquence, ces branchements peuvent donner des erreurs de prédiction si leur correspondance a été éliminée du cache. On peut réduire des défauts en augmentant la taille du BTB. Les défauts liés aux conflits d'accès au BTB sont eux beaucoup plus intéressants, car la conception du BTB est assez spéciale dans la manière dont sont gérés ce genre de défauts. Le BTB est un cache partiellement associatif, ce qui fait que des défauts de cache par conflit d'accès peuvent avoir lieu. Pour rappel, les défauts par conflit d'accès ont lieu quand deux adresses mémoires se voient attribuer la même ligne de cache. Pour un BTB, cela correspond au cas où deux branchements se voient attribuer la même entrée dans le BTB, ce qui porte le nom d’''aliasing''. Ne pas détecter ce genre de situation sur un cache normal entraînerait des problèmes : la donnée lue/écrite ne serait pas forcément la bonne. Les caches normaux utilisent un système de tags pour éviter ce problème et on s'attendrait à ce que les BTB fassent de même. Mais pour un BTB, l'absence de ''tags'' n'est pas un problème : la seule conséquence est une augmentation du taux de mauvaises prédictions, dont les conséquences en termes de performance peuvent être facilement compensées. Les BTB peuvent donc se passer de tags, ce qui a de nombreux avantages : le circuit est plus rapide, prend moins de portes logiques, consomme moins d'énergie, etc. Ces économies de portes logiques et de performances peuvent être utilisées pour augmenter la taille du BTB, ce qui compense, voire surcompense les mauvaises prédictions induites par l’''aliasing''. Mais ce n'est pas systématique : d'autres BTBs utilisent des ''tags'' pour améliorer le taux de prédiction. Nous ferons donc la différence entre '''BTB tagués''' et '''BTB non-tagués'''. Des processeurs se passent de BTB. À la place, ils mémorisent les correspondances entre branchement et adresse de destination dans les bits de contrôle du cache d'instructions. Mais nous verrons cela en détail dans le chapitre suivant. ===La prédiction de destination pour les branchements indirects=== Un BTB classique peut fonctionner pour les branchements indirects. Son seul défaut est qu'il mémorisera la dernière adresse de destination prise par le branchement. Et cela peut fonctionner pour les branchements indirects fortement biaisés, qui ont presque toujours la même adresse de destination. Mais la majorité des branchements indirects change d'adresse de destination régulièrement. Tous les processeurs commerciaux datant d'avant le Pentium M utilisaient un BTB normal pour prédire les branchements indirects. Les processeurs qui les ont suivi sont capables de prédire l'adresse de destination d'un branchement indirect. , grâce à une '''unité de prédiction des branchements indirects''', séparée de l'unité de prédiction de branchement normale. Elle utilise un BTB amélioré, qui mémorise plusieurs adresses de destination pour un seul branchement, en compagnie d'informations pour prédire laquelle est la bonne. Il est possible d'utiliser un BTB unique, pour tous types de branchements, appelé un '''BTB unifié'''. Un BTB unifié a plusieurs adresse de destination par entrée du BTB. Mais une seule est utilisée pour les branchements directs. Vu que les branchements directs sont majoritaires, cela fait beaucoup d'entrées inutilisées. En conséquence, elle est tout sauf optimale, et n'est presque jamais utilisée en pratique. {|class="wikitable" |+ Branch Taarget Buffer unifié |- ! Adresse du branchement !! Informations de prédiction !! Adresse de destination !! Seconde adresse de destination |- | 0x54462146 || 11101011 || 0x52495674 || 0x24987461 |- | 0x54142446 || 11101101 || 0x15646956 || x0x4158746 |- | 0x25456542 || 10011101 || 0x52495674 || 0x24157461 |- | 0x15455855 || 11010101 || 0x52485754 || 0x24024511 |- | ... || ... || ... || ... |- | ... || ... || ... || ... |- | 0x54462146 || 1001101 || 0x52495674 || 0x24987461 |} Une seconde solution utilise un BTB séparé, distinct du BTB pour les branchements directs. En séparant les deux, on a un BTB simple qui ne mémorise qu'une adresse de destination par branchement, et un BTB pour branchements indirects avec plusieurs adresses de destination par branchement. [[File:BTB avec prédiction de branchements indirects.png|centre|vignette|upright=2.5|BTB avec prédiction de branchements indirects.]] L'idée est que l'on économise des circuits à nombre d'entrées égales. Par exemple, comparons ce que cela donne avec 64 entrées. D'un côté, on a un BTB à 64 entrées, capable de gérer deux adresse de destination par branchement. En pratique, la majorité des branchements sont des branchements directs, qui n'utiliseraient qu'une seule case mémoire. Les circuits du BTB seraient donc sous-utilisés. De l'autre, on a un BTB à 48 entrées pour branchements directs, et un second BTB avec 16 entrées, capable de mémoriser deux adresses par branchements. Le premier BTB utilise moins de circuits, et est utilisé à pleine capacité, il ne gaspille pas de circuits pour mémoriser des adresses de destination vides. Par contre, on perd un peu en flexibilité. L'organisation précédente marche bien si 1/4 des branchements rencontrés sont des branchements indirects. Mais si le ratio est différent, la performance est légèrement sous-optimale. Attention, cela ne veut pas dire qu'il y aura des entrées vides dans un BTB, non. Simplement, le premier BTB risque de manquer de place pour les branchements directs récents, alors que les branchements indirects seront eux bien prédits, même les anciens. À l'opposé, un BTB unique permet de mémoriser les 64 derniers branchements, peu importe qu'ils soient indirects ou non. En clair : un BTB unique est plus flexible, ce qui donne un gain de performance dans certaines situations. Cependant, cet avantage théorique ne tient pas compte de l'économie en circuits, qui peut être utilisée pour faire grossir le BTB pour branchements directs. Par exemple, à budget à transistor équivalent, on a le choix entre BTB à 64 entrées uniques, et un BTB pour branchements directs de 80 entrées avec un BTB indirect de 16 entrées. Le fait qu'un BTB soit plus gros permet des gains de performances qui compensent la perte de flexibilité. On peut ainsi avoir le meilleur des deux monde, à savoir une économie de circuits et les gains en performance qui en découlent, malgré une petite perte de flexibilité. Une troisième solution utilise un BTB normal, mais réserve plusieurs entrées pour un branchement indirect. Par exemple, un branchement indirect qui a trois adresses de destination prendra trois entrées : une par adresse de destination. Mais quelques difficultés surviennent alors. Comment placer trois copies d'un même branchement, alors que ces trois copies ont le même ''program counter'' ? Et que faire pour savoir quelle copie sera prise ? Que faire si l'unité de prédiction de branchement prédit les trois copies comme prises ? La technique du '''''Program Counter'' virtuel''' (''Virtual Program Counter'') résoud ces problèmes d'une manière assez élégante. L'idée est la même que précédemment, à savoir qu'il y a une copie du branchement par adresse de destination, dans le BTB. Mais ces copies n'ont pas exactement le même ''Program counter''. Chaque copie se voit attribuer un ''Program Counter'' virtuel. Et comprendre pourquoi demande de comprendre l'idée sous-jacente. La technique a été inspirée par une optimisation des compilateurs, qui transforme un branchement indirects en une séquence de branchements conditionnels. Par exemple, un branchement indirects qui peut brancher vers les adresses A, B et C est transformé en trois branchements conditionnels : un qui branche vers A, un autre qui branche vers B et un autre vers C. Elle ne marche pas pour tous les branchements indirects, elle demande des circonstances particulières pour fonctionner. Mais l'idée est adaptée pour le ''design'' de l'unité de prédiction de branchement. L'idée est qu'un branchement indirect est déroulé en plusieurs branchements dans le BTB. Les branchements sont traités en séquence : l'unité de prédiction prédit si le premier branchement est pris ou non. S'il est pris, son adresse de destination est envoyée au cache d'instruction. S'il n'est pas pris, l'unité de prédiction tester la seconde copie du branchement, et rebelote. Il teste toutes les copies, une par une, et s’arrête quand il tombe sur un branchement pris. Si aucune copie n'est prise, alors le branchement est prédit comme non-pris. Première remarque : il s'agit d'un processus itératif, où on test un branchement à la fois. Et oui, c'est une prédiction par cycle d'horloge. Prédire un branchement indirect peut donc demander plusieurs cycles d'horloge. Mais ce n'est pas un problème, car la prédiction peut être utilisée par l'unité de décodage, pour annuler les instructions chargées à tord. Deuxième remarque : comment passer d'un branchement à l'autre ? La première copie a un ''program counter'' normal, qui correspond à l'adresse du branchement. La seconde a un ''program counter'' qui est calculé à partir de cette première adresse, la troisième copie a une adresse calculée à partir de la seconde, etc. Concrètement, n'importe quelle fonction un tant soit peu simple suffit, tant qu'elle est déterministe. L'article qui a proposé cette techniques (''Virtual Program Counter (VPC) Prediction : Very Low Cost Indirect Branch Prediction Using Conditional Branch Prediction Hardware'') se contenait de faire un XOR entre l'adresse précédente et une constante, avec une constante différente par itération. Troisième remarque : l'algorithme demande de faire une parenthèse dans le fonctionnement normal du BTB. Quand un branchement indirect est détecté, il lance une succession de prédiction pour prédire un branchement. L'incrémentation du ''program counter'' normale cesse pendant ce processus. Et cela ne doit être fait que quand un branchement indirect est détecté. Pour cela, le BTB mémorise le type de chaque branchement, sans l'entrée du BTBT : est-ce qu'il est conditionnel, inconditionnel, mais aussi direct ou indirect. Et le type est accesible en sortie du BTB, sur une sortie de quelques bit. Quand l'unité de prédiction remarque qu'un branchement indirect vient de sortir du BTB, alors elle lance le processus. ===La prédiction de l'adresse de retour d'une fonction=== Presque tous les processeurs conçus après les années 2000 peuvent prévoir l'adresse à laquelle il faudra reprendre lorsqu'une fonction/sous-programme a fini de s’exécuter. Ils font de la '''prédiction de retour de fonction'''. Pour cela, ils possèdent pour une unité de prédiction de branchement spécialisée, appelée le ''return stack predictor'', ou '''''pile de prédiction de retour de fonction'''''. Le terme ''return stack predictor'' étant plus court, nous l'utiliserons dans la suite du cours. Le ''return stack predictor'' est capable de reconnaitre les instructions de retour de fonction, et de prédire l'adresse de retour. Pour comprendre comment elle fait, rappelez-vous le chapitre sur la pile d'appel. Dans ce chapitre, j'avais introduit le concept de ''pile d'adresse de retour'', à savoir une pile d'appel très simplifiée, qui ne contient que les adresses de retour des fonctions en cours d'exécution. Le ''return stack predictor'' est essentiellement composé d'une telle pile d'adresse de retour, avec des circuits pour empiler et dépiler les adresses de retour. Le ''return stack predictor'' doit reconstruire une pile d'adresses de retour, à partir du programme exécuté. La pile d'adresse est mémorisée dans le ''return stack predictor'', elle est totalement séparée de la pile d'appel normale. Le programme exécuté continue d'utiliser la même pile d'appel qu'avant, ce qui fait que les adresses de retour sont dispersées dans la pile d'appel, parfois dans des registres spéciaux du processeur. Mais cela ne pose pas de problèmes pour le processeur, qui reconstruit la pile d'adresse de retour avec l'aide du décodeur. La reconstruction se fait à partir des instructions d'appel et de retour de fonction. Une instruction d'appel de fonction empile une adresse de retour, une instruction de retour de fonction la dépile. Lorsque le décodeur d'instruction reconnait une instruction d'appel de fonction, il extrait l'adresse de retour et l'envoie au ''return stack predictor''. L'adresse de retour est alors empilée, ce qui fait qu'elle se retrouve au sommet de la pile d'adresse de retour. Dépiler une adresse de retour revient à prédire un branchement. Lorsque le ''return stack predictor'' détecte une instruction de retour de fonction, il dépile l'adresse au sommet de la pile. L'adresse est alors envoyée du cache d'instruction, pour l'ire l'instruction adéquate, et le ''program counter'' est mis à jour avec cette adresse. Toute la difficulté revient à détecter les instructions de retour de fonction, sans quoi on ne sait pas quand dépiler l'adresse. La documentation là-dessus n'est pas très fournie, il est difficile de savoir ce qui est fait dans les processeurs modernes. Mais on peut faire quelques suppositions. La première possibilité est l'usage du ''prédécodage'', une technique qui décode partiellement les instructions lors de leur chargement dans le cache d'instruction. Elle sera vue dans le chapitre suivant, mais parlons-en rapidement. Le prédécodage ajoute quelques bits au début de chaque instruction pour préciser si c'est un branchement conditionnel, inconditionnel, un retour de fonction. On peut ainsi savoir rapidement quand dépiler : dès qu'une instruction est lue dans le cache d'instruction, le ''return stack predictor'' vérifie les bits ajoutés et agit en fonction. La seconde possibilité est que le BTB reconnait l'instruction de retour de fonction. Pour chaque branchement, il mémorise non seulement son adresse de retour, mais aussi son type. Par type, on veut dire : si c'est un branchement conditionnel, inconditionnel, un retour de fonction. Les retours de fonction sont donc reconnus à condition qu'ils aient été exécutés auparavant. Il est aussi possible d'avoir un BTB spécialisé dans les retours de fonction, qui est alimenté par les décodeurs. Dès que le décodeur reconnait une instruction de retour de fonction, il ajoute son ''program counter'' dans ce BTB spécialisé. Une dernière possibilité est que le ''return stack predictor'' agit une fois l'instruction partiellement décodée. Le décodeur d'instruction reconnait les instructions de retour de fonction rapidement, en un cycle, et consulte alors le ''return stack predictor''. L'adresse est dépilée, et le décodeur sait où brancher. Il n'a pas besoin de devoir lire l'adresse de retour en mémoire, il la lit depuis le ''return stack predictor'', directement. La prédiction se fait donc assez tard dans le pipeline, quelques instructions ont été chargées à tort. Mais le décodeur peut les annuler, comme il le ferait avec un branchement inconditionnel mal prédit. ==La prédiction de branchement locale== La prédiction de branchement tente de prédire si un branchement sera pris ou non-pris, et décide quelle adresse envoyer au cache en fonction du résultat. Si on prédit qu'un branchement est non pris, on laisse le ''program counter'' faire son travail. À l'inverse si le branchement est prédit comme étant pris, l'adresse de destination est envoyée au cache d'instruction et au ''program counter''. La prédiction de branchement assigne une probabilité qu'un soit branchement soit pris en fonction de ces informations : si la probabilité est de plus de 50%, le branchement est considéré comme pris. La prédiction de branchement se base avant tout sur des statistiques, c’est-à-dire qu'elle détermine la probabilité d’exécution d'un branchement en fonction d'informations assez diverses. La prédiction de branchement n'a d'intérêt que si les prédictions sont suffisamment fiables pour valoir le coup. Rappelons que la pénalité lors d'une mauvaise prédiction est importante, d'autant plus coûteuse que le pipeline est long. Il est généralement admis qu'il faut au minimum un taux de réussite proche de 90%, voire plus sur les processeurs modernes. Heureusement, la plupart des branchements sont des '''branchements biaisés''', c’est-à-dire qu'ils sont presque toujours pris ou presque toujours non-pris, sauf en de rares occasions. De tels branchements font que la prédiction des branchements est facile, bien qu'ils posent paradoxalement quelques problèmes, comme nous le verrons plus bas. Un autre point important est que les unités de prédiction de branchement doivent être très rapides. Idéalement, elles doivent fournir leur prédiction en un seul cycle d'horloge. Un seul cycle d'horloge est un temps très court, surtout sur les ordinateurs avec une fréquence élevée. Les techniques de prédiction dynamique ne peuvent donc pas utiliser des méthodes statistiques extraordinairement complexes. Cela va à l'encontre du fait que le taux de mauvaises prédictions doit être très faible, idéalement inférieur à 1% sur les processeurs modernes. Vous comprenez donc aisément que concevoir une unité de prédiction de branchement est un véritable défi d’ingénierie électronique qui requiert des prouesses technologiques sans précédent. Suivant la nature des informations utilisées, on peut distinguer plusieurs types de prédiction de branchement : locale, globale, dynamique, statique, etc. Nous allons d'abord voir les méthodes statiques avant de voir les méthodes dynamiques. La '''prédiction locale''' prédit chaque branchement indépendamment les uns des autres. Elle sépare bien les statistiques/moyennes pour chaque branchement. Et c'est autant un point fort qu'un point faible. En effet, elle n'exploite pas les corrélations entre branchements, à savoir le fait que l'exécution d'un branchement a tendance à déborder sur les suivants. Dans ce qui suit, nous allons voir les techniques de prédiction locales. ===Les ''branch hints''=== Dans son implémentation la plus explicite, la prédiction est inscrite dans le branchement lui-même. Quelques bits de l'opcode du branchement précisent si le branchement est majoritairement pris ou non pris. Ils permettent d'influencer les règles de prédiction de branchement et de passer outre les réglages par défaut. Ces bits sont appelés des '''suggestions de branchement''' (''branch hint''). Mais tous les processeurs ne gèrent pas cette fonctionnalité. Et de plus, cette solution marche assez mal. La raison est que le programmeur ou le compilateur doit déterminer si le branchement est souvent pris ou non-pris, mais qu'il n'a pas de moyen réellement fiable pour cela. Une solution possible est d’exécuter le programme sur un ensemble de données réalistes et d'analyser le comportement de chaque branchement, afin de déterminer les suggestions de branchement adéquates, mais c'est une solution lourde et peu pratique, qui a de bonnes chances de donner des résultats peu reproductibles d'une exécution à l'autre. ===La prédiction statique de branchement=== La '''prédiction statique''' prédit le résultat du branchement en fonction de caractéristiques fixes du branchement lui-même, comme son adresse, son mode d'adressage, l'adresse de destination, la position du branchement en mémoire, si le branchement est direct ou indirect, etc. Les informations en question sont connues une fois le branchement décodé, il n'y a pas besoin de mémoriser la moindre information supplémentaire. Une première implémentation, assez évidente, fait la différence entre les branchements inconditionnels toujours pris, et les branchements conditionnels au résultat variable. L'idée est de prédire les branchements inconditionnels comme toujours pris alors que les branchements conditionnels sont prédits comme non-pris (ce qui est une approximation). L'implémentation est relativement simple : on utilise un BTB qui ne mémorise que les branchements inconditionnels, et basta ! Les branchements conditionnels sont simplement ignorés, traités comme n'importe quelle autre instruction. Cette méthode est particulièrement inefficace pour les branchements de boucle, où la condition est toujours vraie, sauf en sortie de boucle ! Il a donc fallu affiner légèrement l'algorithme de prédiction statique. Une implémentation plus subtile fait la différence entre les branchements des boucles et les autres. Reste à identifier ceux-ci d'une manière ou d'une autre. Et un bon moyen est de regarder si le branchement revient en arrière ou au contraire saute une portion de code. Un programme s'exécute des adresses basses vers les adresses hautes, les instructions vont naturellement dans le sens croissant des adresses. Un branchement de boucle fait revenir en arrière, histoire de répéter un morceau de code, pas les autres branchements (du moins pas forcément). Et cela nous amène à faire la distinction entre les branchements conditionnels ascendants et descendants. Un '''branchement conditionnel ascendant''' est un branchement qui demande au processeur de reprendre plus loin dans la mémoire : l'adresse de destination est supérieure à l'adresse du branchement. Un branchement '''conditionnel descendant''' a une adresse de destination inférieure à l'adresse du branchement : le branchement demande au processeur de reprendre en arrière dans la mémoire, avant le branchement. Les branchements ascendants sont rarement pris (ils servent dans les conditions de type SI…ALORS), contrairement aux branchements descendants (qui servent à fabriquer des boucles). On peut ainsi modifier l’algorithme de prédiction statique comme suit : * les branchements inconditionnels sont toujours pris ; * les branchements descendants sont toujours pris ; * les branchements ascendants ne sont jamais pris. L'implémentation demande d'élargir le BTB. Le BTB n'a pas seulement à mémoriser l'adresse du branchement et son adresse de destination, mais aussi le type du branchement. En général, il se contente de deux bits, qui indiquent si le branchement est conditionnel, inconditionnel, ou un retour de fonction. Les retours de fonction sont traités à part, histoire de profiter du ''return stack predictor''. {|class="wikitable" |+ BTB avec prédiction de branchement statique évoluée |- ! Adresse du branchement !! Adresse de destination !! Type du branchement |- | 0x4165485 || 0x14657445541 | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Retour de fonction |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... || ... |} ===La prédiction avec des compteurs à saturation=== Les méthodes de prédiction de branchement statique sont intéressantes, mais elles montrent rapidement leurs limites. Pour plus de performances, il faut utiliser la '''prédiction dynamique''', qui extrait des statistiques sur l’exécution des branchements. Pour le reste du chapitre, toutes les techniques utilisent la prédiction dynamique, qu'elles soient locales ou globales. Sa version la plus simple se contente de mémoriser ce qui s'est passé lors de l’exécution précédente du branchement. Si le branchement a été pris, alors on suppose qu'il sera pris la prochaine fois. De même, s'il n'a pas été pris, on suppose qu'il ne le sera pas lors de sa prochaine exécution. Pour cela, chaque entrée du BTB contient un bit qui mémorise la dernière exécution du branchement : 1 s'il a été pris, 0 s'il n'a pas été pris. C'est ce qu'on appelle la '''prédiction de branchements sur 1 bit'''. Au vu de sa simplicité, il ne faut pas s'attendre à des miracles. La méthode fonctionne bien pour les branchements des boucles et les branchements inconditionnels, mais elle n'est pas assez performante. Une forme plus élaborée de prédiction utilise des '''compteurs à saturation'''. Un compteur à saturation est un compteur qui est incrémenté à chaque fois qu'un branchement est pris, décrémenté s'il est non-pris. Le compteur est initialisé à la moitié de sa valeur maximale, ce qui correspond à une probabilité proche de 50%. Les compteurs à saturation sont très utilisés et pas seulement pour de la prédiction locale, les méthodes globales en utilisent aussi. Pour faire la prédiction, l'unité de prédiction regarde le bit de poids fort du compteur. Le branchement est considéré comme pris si ce bit de poids fort est à 1, et non pris s'il vaut 0. En clair : on considère que le branchement est pris si le compteur indique une probabilité supérieure à 50%. [[File:Compteur à saturation.png|centre|vignette|upright=2|Compteur à saturation.]] : Pour la culture générale : le compteur à saturation du Pentium 1 était légèrement bogué, ce qui fait que sa prédiction de branchement était sous-optimale. La plupart des processeurs utilisent un compteur à saturation par branchement, ce qui veut dire un compteur à saturation par entrée dans le BTB. Vu chaque compteur est associé à une entrée du BTB, le phénomène d’''aliasing'' mentionné plus haut peut perturber les prédictions de branchement. Deux branchements peuvent se voir associés à la même entrée du BTB, et agir sur le même compteur à saturation, ce qui entraine l'apparition d''''interférences entre branchements'''. L'interférence peut avoir des effets positifs, neutres ou négatifs, suivant la corrélation entre les deux branchements. L’''aliasing'' n'est pas un problème si les deux branchements sont fortement corrélés, par exemple si les deux sont tout le temps pris ou tout le temps non-pris. Si les deux branchements vont dans le même sens, l'interférence est neutre, voire légèrement positive. Mais si l'un est souvent pris et l’autre souvent non-pris, l'interférence est négative. Les deux branchements vont se marcher dessus sans vergogne, chacun interférant sur les prédictions de l'autre ! Il est rare que l’''aliasing'' ait un impact significatif sur les unités de prédiction des deux paragraphes précédents. Il se manifeste surtout quand la BTB est très petite et la seule solution est d'augmenter la taille du BTB. ===La prédiction avec un historique local=== La prédiction avec des compteurs à saturation est très simple, mais il est possible de créer une implémentation alternative, très proche, avec des avantages et désavantages différents. Un compteur à saturation enregistre l'historique d'un branchement, à savoir ses dernières exécutions. L''''historique''' d'un branchement dit quel a été son résultat dans le passé, pour ses exécutions précédentes. Et il est possible d'enregistrer cet historique et l'utiliser pour faire des prédictions. Par exemple, prenons un branchement sont ses 6 dernières exécutions sont "pris, non-pris, pris, pris, pris, non-pris". Le processeur peut encoder cet historique sur 6 bits. Un branchement pris correspond à un bit à 1, un branchement non-pris correspond à un 0. Le bit de poids fort correspond à l'exécution la plus ancienne, le bit de poids faible à l'exécution la plus récente. Par exemple, si l'historique contient 010, cela veut dire que le branchement a été non pris, puis pris, puis non pris. L'historique d'un branchement est appelé son '''historique local''', mais nous parlerons simplement l'historique. Le processeur peut mémoriser l'historique de chaque branchement. Pour cela, il réserve n bits par branchements, pour mémoriser ses n exécutions précédentes. L'historique d'un branchement est mémorisé dans son entrée du BTB. Et plus précisément dans un champ séparé qui est alimentée comme un registre à décalage. Reste à exploiter cet historique local pour faire des prédictions. Une idée assez simple serait de l'utiliser pour calculer une moyenne des N exécutions précédentes. Si la probabilité d'être pris est supérieure à 50 %, le branchement est considéré comme pris. Dans le cas contraire, il est considéré comme non pris. Le calcul de la moyenne utilise une simple porte à majorité, qui prend en entrée tous les bits de l'historique. Cependant, cette méthode est équivalente à utiliser un compteur à saturation par branchement. La sortie de la porte à majorité est équivalente à ce qu'on aurait dans un compteur à saturation. La seule différence : le cout en circuit et la simplicité de conception. Prenons l'exemple des premiers processeurs PA RISC. Ils mémorisaient, pour chaque branchement, les trois dernières exécutions du branchement, avec trois bits par entrée du BTB. La prédiction était réalisée avec une porte à majorité, dont le résultat était utilisé comme prédiction. Ces processeurs ont préféré utiliser un historique de 3 bits, car il était plus simple à concevoir qu'un compteur à saturation équivalent de deux bits. Il suffisait d'alimenter un registre à décalage avec un bit, pas besoin d'incrémenter ou de décrémenter un compteur, pas besoin de signaux d'incrémentation/décrémentation séparés, etc. Mais pour des historiques plus longs, la donne s'inverse et des compteurs à saturation de deux-trois bits l’emportent. Un défaut de la méthode précédente, qui est partagée avec les compteurs à saturation, est qu'elle marche mal pour les branchements qui suivent un ''pattern''. Je veux dire par là que l'exécution d'un branchement suit un cycle, par exemple pris, pris, non-pris, pris, pris, non-pris, et ainsi de suite en boucle. Notons que l'apparition de tels motifs correspond le plus souvent à la présence de boucles dans le programme. Quand une boucle s’exécute, les branchements qui sont à l'intérieur tendent à exprimer de tels motifs. Avec de tels motifs, les compteurs à saturation feront des prédictions incorrectes, là où les prédicteurs qu'on va voir font des prédictions parfaites. Par exemple, prenons un branchement qui suit le ''pattern'' pris, pris, pris, non-pris. Si l'historique local vaut 1110, le branchement est censé refaire une boucle et être pris. Et certes, on a supposé que l'historique faisait 4 bits. Mais le''pattern'' sera visible tant que l'historique fait plus de 4 bits. Par exemple, le ''pattern'' serait 01110 pour un historique local de 5 bits, 101110 pour un historique local de 6 bits, 1101110 pour un historique local de 7 bits, etc. Un ''pattern'' correspond donc à une valeur bien précise de l'historique local. L'historique local permet de voir ces cycles, reste à les exploiter. Prédire correctement les ''pattern'' demande simplement d'associer un compteur à saturation à chaque valeur possible de l'historique local. Le compteur à saturation dira, quand tel pattern est rencontré, si le branchement sera pris ou non-pris par la suite. Au total, il faut donc un registre d'historique local de n bits couplé à 2^n compteurs à saturation. Et ce pour chaque branchement possible. Les compteurs à saturation sont regroupés dans une '''''Pattern History Table''''', que nous noterons PHT dans ce qui suit. Le résultat est une '''unité de prédiction à deux niveaux locale''', qui contient plusieurs registres d'historique et tout autant de PHT locales. En tout, il y a une PHT et un historique par branchement, donc par entrée du BTB. Quand un branchement est exécuté, son historique local indique quel compteur à saturation choisir dans la PHT locale. En général, l'historique local est mémorisé dans le BTB, reste à gérer la PHT. [[File:Unité de prédiction à deux niveaux purement locale.png|centre|vignette|upright=2.5|Unité de prédiction à deux niveaux avec des historiques et des PHT locales.]] Une unité de prédiction locale à deux niveaux contient plusieurs PHT, reste à savoir quelle PHT correspond à tel branchement. La correspondance se fait idéalement à partir de l'adresse du branchement, mais c'est là une implémentation idéale, qui demande beaucoup de circuits. Généralement, l'unité de prédiction n'utilise que les bits de poids faible de l'adresse du branchement, ce qui permet de faire un choix correct, mais avec une possibilité d'''aliasing''. Il est aussi possible de séparer l'historique local du BTB. Les historiques locaux sont alors mémorisés dans une mémoire RAM dédiée, appelé la '''''Branch History Table''''', ou encore la '''table des historiques locaux''', que nous noterons BHT dans ce qui suivra. Chaque byte de la BHT contient l'historique local associé à un branchement. Pour récupérer l'historique local voulu, l'adresse du branchement est envoyée à la BHT, sur les entrées d'adresse de la BHT, directement. Seuls les bits de poids faible de l'adresse sont utilisés. L'historique local est ensuite envoyé à la PHT adéquate, choisie en fonction des mêmes bits de poids faible du PC. Un bon moyen pour cela est d'accéder à toutes les PHT en parallèle, mais de sélectionner la bonne avec un multiplexeur, ce dernier étant commandé par les bits de poids faible du PC. [[File:Table des historiques locaux.jpg|centre|vignette|upright=2|Table des historiques locaux]] Un défaut des unités de prédiction purement locales de ce type est leur cout en termes de circuits. Utiliser un registre d'historique et une PHT par branchement a un cout en transistor élevé, avec tous les défauts que ca implique en termes de consommation électrique et thermique. Elles ont aussi des temps de calcul un peu plus important que les unités purement globales, mais pas de manière flagrante. Les gains en performance avec un historique local s'observent surtout pour les branchements qui sont dans des boucles ou pour les branchements utilisés pour concevoir des boucles, alors que l'implémentation globale marche bien pour les if..else (surtout quand ils sont imbriqués). Les deux sont donc complémentaires. Dans les faits, elles sont rarement utilisées seules du fait de leurs défauts, au profit d'unités de prédiction hybrides, qui mélangent historiques et PHT locaux et globaux. ==La prédiction de branchement globale/mixte== Utiliser un historique local avec une PHT est très puissant. Mais le cout en circuit est très important. Cependant, on peut adapter l'idée pour faire de la prédiction globale. La '''prédiction globale''' fait des prédictions holistiques, qui tiennent compte de tous les branchements, sans les traiter séparément. Elle ne sépare pas les branchements, tous sont mis dans le même panier. La prédiction globale fait donc le compromis inverse : elle exploite les corrélations entre branchements, mais perd les informations statistiques précise pour chaque branchement. L'idée est qu'au lieu d'utiliser un historique par branchement, on utilise un '''historique global''' qui mémorise les exécutions de tous les branchements conditionnels. L'historique enregistre les N exécutions de branchements conditionnels. L'historique global est mémorisé dans un registre unique, appelé le '''registre d'historique global'''. C'est un registre à décalage mis à jour à chaque fois que le processeur exécute un branchement : on fait rentrer un 1 si le branchement est pris, et un 0 sinon. Une unité de prédiction globale utilise cet historique pour faire sa prédiction. L'unité de prédiction globale utilise un compteur à saturation pour chaque valeur possible du registre d'historique. En clair, pour un registre de N bits, elle utilise 2^n compteurs à saturation. Là encore, les compteurs à saturation sont regroupés dans une ''Pattern History Table''. Nous parlerons de '''PHT globale''' pour désigner une PHT commandée par un historique global, de ''PHT locale'' si elle est adressée par un historique local. [[File:Prédiction dynamique à deux niveaux.png|centre|vignette|upright=2|Prédiction dynamique à deux niveaux.]] Il est admis que l'historique global donne de meilleures prédictions que l'historique local. Mais les deux sont complémentaires, ce qui fait qu'il existe des approches hybrides. Les approches hybrides exploitent à la fois les statistiques pour chaque branchement et les corrélations entre branchements. Les prédicteurs hybrides ont de meilleures performances que les prédicteurs purement globaux ou locaux. Avec un historique global, la prédiction tient compte d'éventuelles corrélations entre branchements consécutifs/proches pour faire les prédictions. C'est un avantage pour les applications qui enchaînent des if...else ou qui contiennent beaucoup de if...else imbriqués les uns dans les autres, qui s’exécutent plusieurs fois de suite. Le résultat de chaque if...else dépend généralement des précédents, ce qui rend l'utilisation d'un historique global intéressant. Par contre, ils sont assez mauvais pour le reste des branchements. Mais cette qualité devient un défaut quand elle détecte des corrélations fortuites ou parasites, inutiles pour la prédiction (corrélation n'est pas causalité, même pour prédire des branchements). Du fait de ce défaut, la prédiction globale a besoin de registres d'historiques globaux très larges, de plusieurs centaines de bits au mieux. [[File:2 level branch predictor.svg|centre|vignette|upright=2|Unités de prédiction de branchement à deux niveaux avec une seule PHT.]] Le circuit précédent a cependant un problème : si deux branchements différents s'exécutent et que l'historique est le même, le processeur n'y verra que du feu. Il y a alors une interférence, à savoir que les deux branchements vont agir sur le même compteur à saturation. On se retrouve alors dans une situation conceptuellement identique à l’''aliasing'' dans le BTB ou avec des compteurs à saturation. Sauf que les interférences sont alors beaucoup plus nombreuses et qu'elles sont clairement un problème pour les performances ! Mitiger les interférences devient alors bien plus important qu'avec les unités de prédiction précédentes. Heureusement, si augmenter la taille de la PHT peut mitiger le problème, d'autres solutions sont possibles sur les unités de prédiction globales. Elles sont au nombre de deux : mitiger les interférences liées aux branchements biaisés, ajouter des informations qui discriminent les branchements. Voyons ces solutions dans le détail. ===La mitigation des interférences par usage de l'adresse de branchement=== La première solution pour réduire l'impact de l’''aliasing'' est d'augmenter la taille de la PHT et de l'historique. Plus l'historique et la PHT sont grands, moins l’''aliasing'' est fréquent. Mais avoir des historiques et des PHT de très grande taille n'est pas une mince affaire et le cout en termes de performances et de portes logiques est souvent trop lourd. Une autre solution consiste à utiliser, en plus de l'historique, des informations sur le branchement lui-même pour choisir le compteur à saturation à utiliser. Typiquement, on peut utiliser l'adresse du branchement en plus de l'historique, pour choisir le compteur à saturation adéquat. Pour être précis, on n'utilise que rarement l'adresse complète du branchement, mais seulement les bits de poids faible. La raison est que cela fait moins de bits à utiliser, donc moins de circuits, mais l’''aliasing'' est juste atténué, pas éliminé. Des branchements différents ont beau avoir des adresses différentes, les bits de poids faible de leurs adresses peuvent être identiques. Des confusions sont donc possibles, mais sont assez rares pour que cela suffise en pratique. Une unité de prédiction de ce type est illustrée ci-dessous. Il y a plusieurs PHT séparées, donc plusieurs ensembles de 2^n compteurs à saturation. Le choix de la PHT est réalisé par l'adresse du branchement, alors que le choix du compteur à saturation est réalisé par l'historique. Les compteurs à saturation d'un branchement sont mis à jour seulement quand le branchement s'est chargé pendant que l'historique associé était dans le registre d'historique. Cette méthode a cependant le défaut de gâcher énormément de transistors. [[File:Unité de prédiction de branchement basée sur des compteurs à saturation sélectionnés par l'historique global.jpg|centre|vignette|upright=2|Unité de prédiction de branchement basée sur des compteurs à saturation sélectionnés par l'historique global]] D'autres unités de prédiction fonctionnent sur le même principe que l'unité précédente, sauf que les différentes PHTs sont fusionnées en une seule grosse PHT. C'est ce que font les unités de prédiction '''''gshare''''' et '''''gselect''''', ainsi que leurs dérivées. Avec les unités de prédiction '''''gselect''''', on concatène les bits de poids faible de l'adresse du branchement et l'historique pour obtenir le numéro du compteur à saturation à utiliser. [[File:Prédiction « gselect ».png|centre|vignette|upright=2|Prédiction « gselect ».]] Avec les unités de prédiction '''''gshare''''', on fait un XOR entre le registre d'historique et les bits de poids faible de l'adresse du branchement. Le résultat de ce XOR donne le numéro du compteur à utiliser. Avec cette technique, on utilise une PHT globale et un historique global, mais le registre d'historique est manipulé de manière à limiter l'''aliasing''. [[File:Prédiction « gshare ».png|centre|vignette|upright=2|Prédiction « gshare ».]] Intuitivement, on pourrait croire que les unités de prédiction gselect ont de meilleures performances. C'est vrai que les unités gshare combinent l'adresse du branchement et l'historique d'une manière qui fait perdre un peu d'information (impossible de retrouver l'adresse du branchement et l'historique à partir du résultat du XOR), alors que les unités gselect conservent ces informations. Mais il faut prendre en compte le fait que les deux unités doivent se comparer à PHT identique, de même taille. Prenons par exemple une PHT de 256 compteurs, adressée par 8 bits. Avec une unité gselect, les 8 bits sont utilisés à la fois pour l'historique et pour les bits de poids faible de l'adresse du branchement. Alors qu'avec une unité gshare, on peut utiliser un historique de 8 bits et les 8 bits de poids faible de l'adresse du branchement. L'augmentation de la taille de l'historique et du nombre de bits utilisés fait que l’''aliasing'' est réduit, et cela compense le fait que l'on fait un XOR au lieu d'une concaténation. Les unités précédentes réduisent l'''aliasing'' en segmentant la PHT : telles entrées sont allouées à un branchement, telles entrées à tel autre branchement, etc. Cependant, l'historique local n'est pas utilisé. Mais quitte à allouer des entrées à un branchement, autant en profiter pour leur associer son historique local. Il s'agit alors d'approches hybrides. Par exemple, on peut citer la '''prédiction mixte''' (''alloyed predictor''). Avec celle-ci, la table des compteurs à saturation est adressée avec la concaténation : de bits de l'adresse du branchement, du registre d'historique global et du registre d'historique local adressé par l'adresse du branchement. [[File:Prédiction mixte.jpg|centre|vignette|upright=2|Prédiction mixte.]] Une version optimisée de ce circuit permet d’accéder à la PHT et à la BHT en parallèle. Avec elle les bits de poids faible de l'adresse du branchement sont concaténés avec l'historique global. Le résultat est ensuite envoyé à plusieurs PHT locales distinctes, en parallèle. Les différences PHT envoient leurs résultats à un multiplexeur, commandé par l'historique local, qui sélectionne le résultat adéquat. L'inconvénient de cette mise en œuvre est que le circuit est assez gros, notamment en raison de la présence de plusieurs PHT séparées. [[File:Alloyed predictor optimisé.jpg|centre|vignette|upright=2|''Alloyed predictor'' optimisé]] ===La mitigation des interférences par la structuration en caches des PHTs=== Les techniques précédentes utilisent les bits de poids faible de l'adresse pour sélectionner la PHT adéquate, ou pour sélectionner le compteur dans la PHT. Mais le reste de l'adresse n'est pas utilisé. Cependant, il est théoriquement possible de conserver les bits de poids fort, afin d'identifier le branchement associé à un compteur. Pour cela, chaque compteur à saturation se voit associé à un ''tag'', qui mémorise le reste de l'adresse du branchement associé au compteur, comme pour les mémoires caches. Lorsque l'unité de prédiction démarre une prédiction, elle récupère les bits de poids fort de l'adresse du branchement et compare ceux-ci avec le ''tag''. S’il y a un ''match'', c'est signe que le compteur est bien associé au branchement à prédire. Dans le cas contraire, c'est signe qu'on fait face à une situation d’''aliasing'' et le compteur n'est pas utilisé pour la prédiction. Le résultat transforme la PHT en une sorte de cache assez particulier, qui associe un compteur à saturation à chaque couple adresse-historique. La technique du paragraphe précédent peut être encore améliorée afin de réduire l’''aliasing''. L’''aliasing'' sur une unité de prédiction de branchement est similaire aux conflits d'accès au cache, où deux données/adresses atterrissent dans la même ligne de cache. Une PHT peut formellement être vue comme un cache directement adressé. Une solution pour limiter ces conflits d'accès à de tels, est de les transformer en un cache associatif à n voies. On peut faire la même chose avec les unités de prédiction de branchement, ce qui revient à dupliquer les PHT, de manière à ce que les bits de poids faible de l'adresse se voient attribuer à plusieurs compteurs à saturation, un par branchement possible. Ce faisant, on réduit les interférences entre branchements. Mais cette technique augmente la quantité de circuits utilisés et la consommation d'énergie, ainsi que le temps d'accès, sans compter qu'elle requiert d'utilisation de ''tags'' pour fonctionner à la perfection. Pour les unités de prédiction de branchement, les mesures à ce sujet ne semblent pas montrer un impact réellement important sur les performances, comparé à une organisation de type directement adressé. Il est possible de pousser la logique encore plus loin en ajoutant des caches de victime et d'autres mécanismes courant sur les caches usuels. ===La mitigation des interférences liées aux branchements biaisés=== L’''aliasing'' a un impact négatif quand deux branchements aliasés (qui sont attribués au même compteur à saturation) vont dans des sens différents : si l'un est non-pris, l'autre a de bonnes chances d'être pris (ou inversement). Or, la plupart des branchements sont biaisés, à savoir très fortement orientés dans une direction qu'une autre (presque toujours pris ou presque toujours non-pris, à plus de 90/99% de chances). Plus un branchement a une probabilité élevée d' être pris ou non-pris, plus sa capacité à interférer avec les autres est forte. Le pire des cas est quand deux branchements sont biaisés mais dans des directions inverses : l'interférence est maximale. En conséquence, les branchements biaisés ou quasi biaisés sont les plus problématiques pour l’''aliasing''. Or, il est possible de concevoir des prédicteurs de branchements qui atténuent fortement les interférences des branchements biaisés ou quasi biaisés. C’est le cas de l’''agree predictor'' et de l'unité de prédiction bimodale, que nous allons voir dans ce qui suit. Les branchements très souvent pris vont interférer très fortement avec les branchements très souvent non-pris, et inversement. Par contre, les branchements très souvent pris n'interféreront pas entre eux, de même que les branchements non-pris iront bien ensemble. D'où l'idée de séparer ces deux types de branchements dans des PHT séparées, pour éviter le mauvais ''aliasing''. L''''unité de prédiction bimodale''' part d'une unité gshare, et sépare la PHT globale en deux : une PHT dédiée aux branchements qui sont très souvent pris et une autre pour les branchements très peu pris. Les deux PHT fournissent chacune une prédiction. La bonne prédiction est choisie par un multiplexeur commandé par une unité de sélection, qui se charge de déduire quelle unité a raison. Cette unité de sélection est une unité de prédiction basée sur des compteurs à saturation de 2 bits. On peut encore améliorer l'unité de sélection de prédiction en n'utilisant non pas des compteurs à saturation, mais une unité de prédiction dynamique à deux niveaux, ou toute autre unité vue auparavant. [[File:Prédiction bimodale.jpg|centre|vignette|upright=2|Prédiction bimodale.]] La '''prédiction par consensus''' (''agree predictor'') combine une unité de prédiction à historique global (idéalement de type gshare ou gselect, mais une unité normale marche aussi) et des compteurs à saturation. Il s'agit techniquement d'une technique de prédiction mixte, à la fois globale et locale. Le BTB intègre un compteur à saturation de un bit, qui indique la direction privilégiée du branchement. L'unité à historique global a un fonctionnement changé : elle calcule non pas la probabilité que le branchement soit pris ou non-pris, mais la probabilité que le résultat du branchement soit compatible avec le résultat de l'unité de prédiction de 1 bit. La prédiction finale vérifie que ces deux circuits sont d'accord, en faisant un NXOR entre les résultats des deux unités de prédiction de branchement. [[File:Prédiction par consensus.png|centre|vignette|upright=2|Prédiction par consensus.]] Des calculs simples de probabilités montrent que l'''agree predictor'' a des résultats assez importants. Ses résultats sont d'autant meilleurs que les branchements sont biaisés et penchent fortement vers le côté pris ou non-pris. ==La prédiction de branchement avec des perceptrons== Les méthodes précédentes utilisent au moins un compteur à saturation par historique possible. Sachant qu'il y a 2^n valeurs possibles pour un historique de n bits, le nombre de compteurs à saturation augmente exponentiellement avec la taille de l'historique. Ce qui limite grandement la taille de l'historique, alors qu'on aurait au contraire besoin d'un historique assez large pour faire des prédictions excellentes. Pour contourner le problème, des alternatives basées sur des techniques d'apprentissage artificiel (''machine learning'') existent. Un exemple est celui des unités de prédiction de branchement dites de ''neural branch prediction'', basée sur des perceptrons. Dans ce qui suit, nous les appelerons des '''unités perceptron'''. Les perceptrons sont un algorithme d'apprentissage automatique très simple, ce qui fait que les explications qui vont suivre devraient être simples. En théorie, il est possible d'utiliser des techniques d'apprentissage automatique plus élaborées, mais cela n'est pas possible en pratique. Rappelons qu'une unité de prédiction de branchement doit idéalement fournir sa prédiction en un cycle d'horloge. L'implémentation d'un perceptron est déjà problématique de ce point de vue, car elle demande d'utiliser beaucoup de circuits arithmétiques complexes (multiplication et addition). Autant dire que les autres techniques de ''machine learning'', encore plus gourmandes en calculs, ne sont pas praticables. ===Les perceptrons et leur usage en tant qu'unité de prédiction de branchements=== L'idée derrière les unités perceptron est que certains bits de l'historique sont plus importants que d'autres. En soi, ce genre de situation est réaliste. Il arrive que certains branchements soient pris uniquement si les deux branchements précédents ne le sont pas, ou que si l'avant-dernier branchement est lui aussi pris. Mais les autres résultats de branchement présents dans l'historique ne sont pas utiles. En conséquence, deux historiques semblables, qui ne sont différents que d'un ou deux bits, peuvent donner des prédictions presque identiques. Dans ce cas, on peut faire la prédiction en n'utilisant que les bits identiques entre ces historiques, ou du moins en leur donnant plus d'importance qu'aux autres. On exploite alors des corrélations avec certains bits de l'historique, plutôt qu'avec l'historique entier lui-même. Pour coder ces corrélations, on associe un coefficient à chaque bit de l'historique, qui indique à quel point ce bit est important pour déterminer le résultat final. Ce coefficient est proportionnel à la corrélation entre le bit de l'historique associé, et le résultat du branchement à prédire. Les coefficients sont des nombres entiers qui peuvent être positifs, nuls ou négatifs. Un coefficient positif signifie que si le branchement associé a été pris, alors le branchement à prédire a de bonnes chances de l'être aussi (et inversement). Un coefficient nul signifie que les deux branchements sont indépendants et que le bit de l'historique associé n'est pas à prendre en compte. Pour un coefficient négatif, cela signifie que si le branchement associé a été pris, alors le branchement à prédire a de bonnes chances d'être non-pris (et inversement). Les coefficients en question sont généralement compris entre -1 et 1. Une fois qu'on connait ces coefficients de corrélations, on peut calculer la probabilité que le branchement à prédire soit pris, avec des calculs arithmétiques simples. Dans les calculs qui vont suivre, un branchement pris est codé par un 1, alors qu'un branchement non-pris est codé par un -1 ! Ce détail permet de simplifier grandement les calculs. Dans sa version la plus simple, le perceptron calcule un nombre Z qui est positif ou nul si le branchement est pris, négatif s'il ne l'est pas. Tous les coefficients <math>p_i</math> sont alors des nombres relatifs, pouvant être positifs, nuls ou négatifs. Ils sont généralement compris entre -1 et 1. La formule devient donc celle-ci : : <math>Z = w_0 + \sum_i (h_i \times w_i)</math> [[File:ArtificialNeuronModel francais.png|centre|vignette|upright=2|Perceptron.]] : Sur le plan mathématique, le perceptron effectue un produit scalaire entre deux vecteurs : l'historique et le vecteur des poids. Choisir les coefficients adéquats est le but de l'algorithme du perceptron. L'unité de prédiction part de poids par défaut, qui sont mis à jour à chaque bonne ou mauvaise prédiction. Toute la magie de cet algorithme tient dans la manière dont sont mis à jour les poids. L'idée est de prendre le vecteur des poids, puis d'ajouter ou soustraire l'historique suivant le résultat du branchement. Les poids évoluent ainsi à chaque branchement, améliorant leurs prédictions d'une exécution à l'autre. La règle de mise à jour des poids est donc la suivante : : <math>w_i \leftarrow w_i + t \times h_i</math>, avec t = 1 pour un branchement pris, -1 pour un branchement non-pris. ===L'implémentation matérielle des perceptrons et ses optimisations=== Les calculs réalisés par un perceptrons sont simples, juste des multiplications et des additions et cela se sent dans la conception du circuit. L'unité perceptron utilise un perceptron par entrée du BTB, un par branchement pour simplifier. Pour cela, on utilise une mémoire SRAM qui mémorise les poids de chaque perceptron. La SRAM est adressée par les bits de poids faible du ''program counter'', de l'adresse du branchement. Une fois les poids récupérés, ils sont envoyés à un circuit de calcul de la prédiction, composé de circuits multiplieurs suivis par un additionneur multi-opérande. Le circuit de mise à jour des poids récupère la prédiction, les poids du perceptron, mais aussi le résultat du branchement. La mise à jour étant une simple addition, le circuit est composé d'additionneurs/soustracteurs, couplés à des registres pour mémoriser la prédiction et les poids du perceptron en attendant que le résultat du branchement soit disponible. Vu qu'il y a un délai de quelques cycles avant que le résultat du branchement soit disponible et que les prédictions doivent continuer pendant ce temps, les poids du perceptron et la prédiction sont stockés dans des FIFOs lues/écrites à chaque cycle. [[File:Unité de prédiction de branchement basée sur des perceptrons.png|centre|vignette|upright=2|Unité de prédiction de branchement basée sur des perceptrons]] Rien de bien compliqué sur le principe, mais rappelons que le perceptron doit fournir un résultat en moins d'un cycle d'horloge, et cela tient compte de l'accès au BTB. C'est un temps très court, surtout pour un circuit qui implique des multiplications. Les multiplications prennent facilement deux à trois cycles d'horloge. Autant dire que si on ajoute le temps d'accès à une petite SRAM, pour récupérer le perceptron, c'est presque impossible. Mais quelques optimisations simples permettent de rendre le calcul plus rapide. Premièrement, la taille de la SRAM est réduite grâce à quelques économies assez simples. Notamment, le perceptron utilise des poids codés sur quelques bits, généralement un octet, parfois 7 ou 5 bits, guère plus. Les poids sont encodés en complément à 1 ou en complément à 2, là où les perceptrons normaux utilisent des nombres flottants. Rien que ces deux choix simplifient les circuits et les rendent plus rapides. Le désavantage d'utiliser peu de bits par poids est une perte mineure en termes de taux de prédiction, qui est plus que compensée par l'économie en portes logiques, qui permet d'augmenter la taille de la SRAM. D'autres optimisations portent sur le circuit de calcul. Par exemple, la multiplication <math>h_i \times w_i</math> est facultative. Vu que l'historique contient les valeurs 1 et -1, il suffit d'additionner le poids associé si la valeur est 1 et de le soustraire s'il vaut -1. On peut même simplifier la soustraction et se limiter à une complémentation à un (une inversion des bits du poids). En faisant cela, les circuits multiplieurs disparaissent et le circuit de prédiction se résume à un simple additionneur multi-opérande couplé à quelques inverseurs commandables. Certains perceptrons prennent en compte à la fois l'historique global et l'historique local pour faire leur prédiction. Les deux historiques sont simplement concaténés et envoyés en entrée du perceptron. Cela marche assez bien, car les perceptrons peuvent utiliser des historiques de grande taille sans problèmes. Par contre, il y a besoin d'ajouter une ''branch history table'' au circuit, ce qui demande des portes logiques en plus, mais aussi un temps de calcul supplémentaire (l'accès à cette table n'est pas gratuit). ===Les avantages et inconvénients des perceptrons pour la prédiction de branchements=== L'avantage des unités perceptron est qu'elles utilisent un nombre de portes logiques qui est proportionnel à la taille de l'historique, et non pas exponentiel. Cela permet d'utiliser un historique de grande taille, et donc d'obtenir un taux de bonnes prédictions très élevé. Leur désavantage est que leurs performances de prédiction sont moins bonnes à historique équivalent. Les unités perceptrons ont des taux de prédiction élevés, mais seulement quand certaines conditions mathématiques sont respectées par les historiques. La condition en question est que les historiques correspondants à un branchement pris et les historiques où ce même branchement est non-pris doivent être linéairement séparables. Heureusement, les branchements des programmes informatiques sont une majorité à respecter la condition de séparation linéaire, ce qui rend les perceptrons parfaitement adaptés à la prédiction de branchements. La notion de linéairement séparable est un peu difficile à comprendre. Pour l'expliquer, imaginez que les N bits de l'historique forme un espace à N-dimensions discrets. Chaque historique est un point dans cet espace N-dimensionnel et chaque bit sert de coordonnée pour se repérer dans cet espace. Une fonction est linéairement séparable si l'espace N-dimensionnel peut être coupé en deux par un hyperplan : d'un côté de ce plan se trouvent les historiques des branchements pris, de l'autre se trouvent les historiques des branchements non-pris. Cet hyperplan est défini par l'ensemble des points qui respecte l'équation suivante : : <math>w_0 + \sum_i (h_i \times w_i) = 0</math> Un exemple où cette condition est respectée est quand le résultat d'une prédiction se calcule avec un ET entre plusieurs branchements de l'historique. Si un branchement est pris quand plusieurs branchements de l’historique sont tous pris ou tous non-pris, la condition est respectée et le perceptron donnera des prédictions correctes. Un exemple où cette condition n'est pas respectée est un banal XOR. Prenons l'exemple d'un historique global de 2 bits. Imaginons que le branchement à prédire soit pris : soit quand le premier branchement de l'historique est pris et le second non-pris, soit quand le premier est non-pris et le second pris. Concrètement, la prédiction exacte se calcule en faisant un XOR entre les deux bits de l'historique. Dans ce cas, la condition "linéairement séparable" n'est pas respectée et le perceptron n'aura pas des performances optimales. Les unités perceptron utilisent idéalement un perceptron par branchement. Cela demande d'associer, l'adresse du branchement pour chaque perceptron, généralement en donnant un perceptron par entrée du BTB. Mais une telle organisation n'est pas possible en pratique, car elle demanderait d'ajouter un ''tag'' à chaque perceptron/entrée du BTB, ce qui demanderait beaucoup de circuits et de ressources matérielles. Dans les faits l'association entre un branchement et un perceptron se fait en utilisant les bits de poids faible de l'adresse de branchement. Mais faire cela entrainera l'apparition de phénomènes d'''aliasing'', comme pour les unités de prédiction à deux niveaux. Les techniques vues précédemment peuvent en théorie être adaptées sur les unités à perceptrons, dans une certaine mesure. Notons que les perceptrons sont des algorithmes ou des circuits qui permettent de classer des données d'entrée en deux types. Ici, la donnée d'entrée est l'historique global et la sortie du perceptron indique si le branchement prédit est pris ou non-pris. Ils ne sont donc pas adaptés à d'autres tâches de prédiction, comme pour le préchargement des lignes de cache ou la prédiction de l'adresse de destination d'un branchement, ou toute autre tâche d'exécution spéculative un tant soit peu complexe. Leur utilisation reste cantonnée à la prédiction de branchement proprement dit, guère plus. Par contre, les autres techniques vues plus haut peuvent être utilisées pour le préchargement des lignes de cache, ou d'autres mécanismes de prédiction plus complexes, que nous aborderons dans la suite du cours. ==La prédiction avec plusieurs unités de branchement distinctes== Il est possible de combiner plusieurs unités de prédiction de branchement différentes et de combiner leurs résultats en une seule prédiction. On peut par exemple combiner une unité de prédiction spécialisée dans les boucles, une unité de prédiction 2 bits basée sur des compteurs à saturation, une unité de prédiction statique et une unité ''gshare''. Tout le problème est de décider quelle unité a fait la bonne prédiction, comment combiner les résultats des différentes unités de prédiction. Soit on choisit un résultat qui parait plus fiable que les autres, soit on combine les résultats pour faire une sorte de moyenne des prédictions. La méthode la plus simple de choisir le résultat d'une unité de prédiction parmi toutes les autres. Pour implémenter le tout, il suffit d'un multiplexeur qui effectue le choix de la prédiction. Reste à commander le multiplexeur, ce qui demande un '''circuit méta-prédicteur''' qui sélectionne la bonne unité de prédiction. Il arrive que le circuit méta-prédicteur soit conçu avec les mêmes techniques que les unités de prédiction de branchement elle-mêmes. Dans le cas le plus simple, il s'agit d'un simple compteur à saturation, mis à jour suivant la concordance des prédictions avec le résultat effectif du branchement. Mais c'est loin d'être systématique. Une autre solution combine le résultat des différentes unités de prédiction avec une fonction mathématique adéquate. Avec un nombre impair d'unités de prédiction, une méthode assez efficace est de simplement prendre le résultat majoritaire. Si une majorité d'unités pense que le branchement est pris, alors on le considère comme pris, et comme non pris dans le cas contraire. Par contre, avec un nombre pair d'unités, cette méthode simple ne marche pas, il y a un risque d'égalité. L'unité de prédiction de branchement « '''''e-gskew''''' » utilise trois unités gshare et combine leurs résultats avec un vote à majorité. Les trois unités gshare utilisent des XOR légèrement différents, dans le sens où les bits de l'adresse du branchement choisi ne sont pas les mêmes dans les trois unités, sans compter que le résultat du XOR peut subir une modification qui dépend de l'unité de prédiction choisie. Autrement dit, la fonction de hachage qui associe une entrée à un branchement dépend de l'unité. [[File:Unité de prédiction « e-gskew ».png|centre|vignette|upright=2|Unité de prédiction « e-gskew ».]] Enfin, une dernière solution est que certaines unités de prédiction ont la priorité sur toutes les autres. Par exemple, prenons un processeur avec un ''return stack predictor'', une unité qui détecte les boucles et une unité de prédiction avec un historique. Si le ''return stack predictor'' détecte une instruction de retour de fonction, sa prédiction passera avant toutes les autres unités de prédiction. Et dans ce qui va suivre, c'est cette solution qui sera utilisé pour les unités de prédiction qu'on va étudier. ===La prédiction des branchements des boucles=== Les boucles sont à part, pour ce qui est de la prédiction de branchement. Rappelons qu'une boucle FOR/WHILE répète une série d'instruction N fois, le nombre N étant appelé le '''compteur de boucle'''. Ce qui fait que les branchements d'une boucle FOR sont pris n − 1 fois, la dernière exécution étant non prise. La conséquence est que les unités de prédiction usuelles sont assez mauvaises pour prédire les branchements des boucles. Par contre, la prédiction statique marche particulièrement bien comparé au reste. La prédiction statique prédit que les branchements des boucles sont toujours pris, ce qui donne des prédictions qui sont d'autant meilleures que la boucle est répétée un grand nombre de fois. Reste à gérer les branchements des boucles au mieux, en sachant cela. L'idée est d'utiliser plusieurs unités de prédiction séparées : une spécialisée dans les boucles et d'autres unités de prédiction. Uen unité de méta-prédiction se charge d'activer l'unité de prédiction des boucles dès qu'une boucle est détéctée. Les unités de prédiction modernes essayent de détecter les branchements des boucles afin de les mettre à part. En soi, ce n'est pas compliqué les branchements des boucles sont presque toujours des branchements descendants et réciproquement. De plus, ces branchements sont pris à chaque répétition de la boucle, et ne sont non-pris que quand on quitte la boucle. Le compteur de boucle analyse l'exécution des branchements, pour détecter les branchements des boucles. Un branchement est considéré comme un branchement de boucle si : il est pris N fois, avant de ne pas être pris une fois. Au moment où le branchement n'est plus pris, le ''Loop Predictor'' lui attribue une entrée dédié. L'entrée mémorise mémorise le nombre de fois que ce branchement a été pris, à savoir N, et est associée à un compteur. Le compteur est incrémenté/décrémenté à chaque exécution du branchement, et sa valeur est comparée à la valeur N. Quand le compteur atteint N+1, il est réinitialisé. Il s'agit d'une implémentation simple, mais est visiblement proche de celle utilisée sur le Pentium M, si j'en crois l'article "The Intel Pentium M Processor : Microarchitecture and Performance". Il faut noter que la boucle doit s'exécuter au moins une fois pour être détectée, et seules les exécutions suivantes sont prédites correctement. La boucle doit donc s'exécuter plusieurs fois, ce qui implique qu'elle est elle-même dans une boucle plus grande qu'elle. Une telle unité est donc capable de prédire spécifiquement les branchements de boucles FOR imbriquées, à savoir où une boucle FOR est dans une autre boucle FOR. Concrètement, cela permet de répéter la première boucle FOR plusieurs fois de suite, souvent un grand nombre de fois. Avec les boucles imbriquées, le compteur de boucle peut changer d'une répétition à l'autre, mais ce n'est que rarement le cas. Dans de nombreux cas, le compteur de boucle reste le même d'une répétition à l'autre. Ce qui fait qu'une fois la boucle exécutée une première fois, on peut prédire à la perfection les exécutions suivantes. Pour cela, il suffit de déterminer le compteur de boucle après la première exécution de la boucle, puis de le mémoriser dans un compteur. Lors des exécutions suivantes de la boucle, on compte le nombre de fois que le branchement de la boucle s'exécute : il est prédit comme pris tant que le compteur est différent du compteur de boucle, mais non-pris en cas d'égalité. ===Le prédicteur L-TAGE=== Il est possible d'utiliser plusieurs unités de prédiction, chacune utilisant des historiques de taille différente. Pour donner un exemple simple, on pourrait avoir une unité avec un historique de 2 bits, une autre avec un historique de 3 bits, une autre de 4 bits, etc. Une méta-unité de branchement prédit alors quelle taille d'historique donne les meilleurs résultats. Il est fréquent que les tailles d'historiques utilisées suivent une formule mathématique précise, appelée suite géométrique, ce qui est un mot bien barbare pour dire que l'on passe d'un historique au suivant en multipliant sa taille par une constante bien précise. Par exemple, on pourrait avoir des historiques de taille 2, 4, 8, 16, 32. C'est d'ailleurs le principe derrière les '''unités de branchement TAGE (''TAgged GEometric length predictor'')'''. Elles utilisent plusieurs registres d'historiques, chacun avec sa propre PHT, dont la taille suit une suite géométrique. Avec cette méthode, les branchements qui ont un motif répétitif sont alors parfaitement détectés, peu importe sa taille. Par exemple, un branchement avec un historique dont le cycle est pris, non-pris, non-pris, sera parfaitement prédit avec un historique de 3 bits, 6 bits, ou de 3*n bits, mais pas forcément avec un historique de 4 ou 8 bits. De ce fait, utiliser plusieurs unités de branchement avec plusieurs tailles d'historique fonctionne plutôt bien. Le prédicteur TAGE a été raffiné avec le temps, sur plusieurs points, que je ne vais pas détailler dans ce qui suit. Précisons cependant que les PHT sont structurées en caches, afin d'éliminer les phénomènes d'''aliasing''. L'ajout d'une unité de prédiction pour les boucles, a donné le '''prédicteur L-TAGE''', qui a lui-même été amélioré par la suite. Les unités de branchement TAGE sont les plus utilisées au moment où j'écris ces lignes (2026), et la majorité des processeurs x86 commerciaux utilisent une variante de ce prédicteur, à quelques exceptions près. ===La prédiction de branchement à deux niveaux=== Les processeurs modernes utilisent une prédiction de branchement à deux niveaux. Une première unité de branchement fournit des résultats rapides et peu précis, la seconde a des résultats plus précis mais fournit ses résultats après quelques cycles d'horloge. La première unité envoie les adresses prédites au cache d'instruction, pour charger les instructions prédites et met à jour le ''program counter'' en conséquence. La seconde envoie les adresses prédites à l'étage de décodage d'instruction, histoire d'annuler des branchements considérés à tort comme pris/non-pris. Il y a donc une unité de prédiction rapide, dont le résultat est corrigé par une seconde unité. La seconde unité a le dernier mot, elle corrige ou annule les prédictions de la première. Ce qui lui vaut le nom, en anglais, d'''overiding predictor''. La correction se fait simplement en corrigeant le ''program counter'' et en forçant le décodeur à émettre des NOPs pendant quelques cycles, le temps d'éliminer les instructions chargées à tort. [[File:Double BTB sur les CPU modernes.png|centre|vignette|upright=2|Double BTB sur les CPU modernes.]] Les deux unités de prédiction n'utilisent pas le même algorithme. Par exemple, la microarchitecture Silvermont d'Intel utilisait deux unités de prédiction : une unité rapide basée sur des compteurs à saturation, et un prédicteur ''gshare'' lent. Les deux unités de prédiction utilisent des BTB séparés, de taille différente, au moins deux. Vu que les BTB sont des caches, on les appelle des '''BTB de niveau L1 et L2'''. Par exemple, les processeurs Bulldozer d'AMD disposent de deux BTBs : un BTB L1 de 512 entrées, et un BTB L2 de 5120 entrées. Le BTB de niveau L2 est souvent un cache de victime, qui mémorise les branchements retirés de la BTB de niveau L1. La BTB pour les branchements indirects vient en plus de ces deux BTBs, elle complète souvent la BTB de niveau L2. Le ''return stack predictor'' vient en plus des BTB L1 et L2, mais sa petite taille et sa rapidité font qu'il complète l'unité/BTB de niveau L1. Quelques processeurs ont trois niveaux de BTB, avec un niveau L0, L1 et L2. La BTB L0 aliment le cache d'instruction, alors que la L1 et la L2 préviennent les décodeurs d'instruction. La différence entre la L1 et la L2 est que la L2 met plus de temps à prévenir les décodeurs. Les branchements peuvent donc être annulés/forcés à deux étages de décodage différents. Pour donner quelques exemples, les processeurs Zen d'AMD disposent de plusieurs BTBs, avec des niveaux L0, L1 et L2. Le tableau ci-dessous vous donne la taille et la latence de chaque niveau de BTB. La BTB de niveau L0 alimente le cache d'instruction, la L1 permet d'annuler les instructions au premier cycle de décodage, la BTB L2 annule les instructions vers le milieu/fin du décodage. {|class="wikitable" |- ! rowspan="3" | Zen 2 | 16 || 0 |- | 512 || 1 |- | 7168 || 4 |- ! rowspan="3" | Zen 3 | 16 || 0 |- | 1024 || 1 |- | 6656 + 1536 (branchements indirects)|| 4 |} ==Les optimisations de la prédiction de branchements== La prédiction de branchement est complexe et toute économie est bonne à prendre. Pour améliorer les performances, ou simplement améliorer l'utilisation du BTB et des PHT, diverses techniques ont été inventées. Ces techniques marchent quel que soit l'unité de prédiction prise en compte. Elles peuvent s'appliquer aussi bien aux unités basées sur des perceptrons, que sur des unités à deux niveaux ou des unités de prédiction dynamique en général. Ces techniques sont assez variés : certaines profitent du fait que de nombreux branchements sont biaisés, d'autres tentent de réduire les interférences entre branchements sans utiliser l'historique global/local, etc. ===Le filtrage de branchements=== Une première optimisation permet d'économiser l'usage des différentes ressources matérielles, comme la BTB ou les PHT, en les réservant aux branchements non-biaisés. Idéalement, les branchements biaisés peuvent être prédits en utilisant des techniques très simples. D'où l'idée d'utiliser deux unités de prédiction spécialisées : une pour les branchements biaisés et une autre plus complexe. L'unité de prédiction pour les branchements biaisés est une unité de prédiction à 1 bit qui indique si le branchement est pris ou non, ce qui suffit largement pour de tels branchements. Cette technique s'appelle le '''filtrage de branchement''' et son nom est assez parlant. On filtre les branchements biaisés pour éviter qu'ils utilisent des PHT et d'autres ressources qui gagneraient à être utilisées pour des branchements plus difficiles à prédire. Appliquer cette idée demande de reconnaître les branchements fortement biaisés d'une manière ou d'une autre, ce qui est plus facile à dire qu'à faire. Une solution similaire enregistre quels branchements sont biaisés dans le BTB, et utilise cette information pour faire des prédictions. ===L'usage de fonctions de hashage pour indexer les diverses SRAM/tables=== Plus haut, nous avons vu que les unités de prédiction de branchement contiennent des structures qui sont adressées par l'adresse de branchement. Tel est le cas du ''Branch Target Buffer'', de certaines PHT globales ou locales, de la ''Branch History Table'' qui stocke les historiques locaux, ou encore de la SRAM des poids des unités à base de perceptrons. En pratique, ces structures sont adressées non pas par l'adresse de branchement complète, mais pas les bits de poids faible de cette adresse. Cela a pour conséquence l'apparition d'un ''aliasing'' lié au fait que ces structures vont confondre deux branchements pour lesquels les bits de poids faible de l'adresse sont identiques. Il est possible d'utiliser autre chose que les bits de poids faible de l'adresse du branchement, afin de limiter les interférences. Et les possibilités sont multiples. Les possibilités en question s'inspirent des traitements effectués sur les adresses des banques dans les mémoires évoluées. Une première solution serait de faire un XOR entre les bits de poids faible de l'adresse du branchement, et d'autres bits de cette même adresse. Ainsi, deux branchements éloignés en mémoire donneraient des résultats différents, même si leurs bits de poids faible sont identiques. Une autre possibilité serait de diviser l'adresse du branchement par un nombre et de garder le reste. Ce calcul de modulo n'est en soi pas très différent du fait de conserver seulement les bits de poids faible. Conserver les N bits de poids faible consiste en effet à prendre le modulo par 2^N de l'adresse. Ici, l'idée serait de faire un modulo par un nombre P, qui serait un nombre premier proche de 2^N. Le fait que le nombre soit premier limite les cas où deux adresses différentes donneraient le même reste, ce qui réduit l'''aliasing''. Le fait de prendre un nombre proche de 2^N pour une entrée de N bits est que le résultat reste assez proche de ce qu'on obtiendrait en gardant les bits de poids faible, cette dernière étant une solution pas trop mauvaise. D'autres possibilités similaires se basent sur des réductions polynomiales ou d'autres astuces impliquant des nombres premiers. L'efficacité de ces méthodes dépend grandement de la taille de la SRAM/table considérée, des adresses des branchements et de beaucoup d'autres paramètres. La réduction des interférences par telle ou telle méthode dépend aussi de l'unité de prédiction considérée. Les résultats ne sont pas les mêmes selon que l'on parle d'une unité à perceptrons ou d'une unité à deux niveaux ou d'unités à base de compteurs à saturation, ou que l'on parle du BTB. Par contre, toutes les méthodes ne sont pas équivalentes en termes de temps de calcul ou de portes logiques. Autant la première solution avec un XOR ajoute un temps de calcul négligeable et quelques portes logiques, autant les autres méthodes requièrent l'ajout d'un diviseur très lent et gourmand en portes logiques pour calculer des modulos. Elles ne sont généralement pas praticables pour ces raisons, le temps de calcul serait trop élevé. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Le pipeline | prevText=Le pipeline | next=Les optimisations du chargement des instructions | nextText=Les optimisations du chargement des instructions }} </noinclude> npkwh78u1zby3slbh5xmwlq9h9xvdng 774406 774405 2026-10-07T21:59:11Z Mewtow 31375 /* La prédiction de destination pour les branchements indirects */ 774406 wikitext text/x-wiki Les branchements sont pris en charge par plusieurs étages du pipeline. Exécuter un branchement demande de savoir deux choses : s'il est pris ou non, quelle est l'adresse de destination. L'adresse de destination est localisée grâce au mode d'adressage du branchement. Avec le mode d'adressage direct, elle est extraite de l'instruction par le séquenceur. Avec l'adressage indirect, elle est lue dans un registre. Avec les branchements relatifs, elle est calculée via un additionneur, qui additionne un décalage au ''program counter''. L'additionneur peut être soit dans l'étage de ''PC Update'', soit dans l'unité de calcul de l'étage d'exécution. Passons maintenant au résultat du branchement, à savoir s'il est pris ou non. Pour savoir si le branchement est pris, il faut distinguer les branchements conditionnels et inconditionnels. Pour rappel, les branchements inconditionnels regroupent les appels de fonction, les simples instructions de saut, et quelques autres. Ils sont toujours pris, ils s'exécutent toujours. À partir du moment où le décodeur a décodé un branchement inconditionnel, il sait que le branchement est pris. Pour les branchements conditionnels, ils sont pris seulement si une condition précise est respectée. On sait s'ils sont exécutés une fois que la condition est calculée par l'unité de calcul, ou par une unité de branchement spécialisée. [[File:Gestion des branchements sur un pipeline.png|centre|vignette|upright=2.0|Gestion des branchements conditionnels sur un pipeline.]] Sur le schéma précédent, les branchements inconditionnels ne sont pas montrés. Pour cela, il faudrait rajouter une connexion du décodeur vers le multiplexeur de branchement. De plus, il faudrait aussi rajouter de quoi retarder l'exécution du branchement seulement une fois que l'adresse est connue. Par exemple, elle est connue un cycle d'horloge plus si l'adresse est lue depuis les registres, comparé à un branchement direct. ==Les branchements et les exceptions imprécises/précises== Un branchement est censé altérer le ''program counter'' immédiatement, en un cycle d'horloge. Mais sur un pipeline, Le branchement fait bien reprendre le processeur au bon endroit, mais avec du retard. La raison est qu'un branchement met plusieurs cycles pour s'exécuter. Il faut plusieurs cycles pour déterminer quelle est l'adresse de destination, temps qui dépend de là où est lue l'adresse. Et on sait si le branchement est pris seulement au niveau du décodeur, voire au niveau de l'unité de calcul. Le branchement doit traverser plusieurs étages avant qu'on connaisse son résultat. Il y a bien 3 à 4 cycles avant cela. Et pendant ce temps de trajet, des instructions seront chargées dans le pipeline, potentiellement à tort. Si le branchement n'est pas pris, ce n'est pas grave : les instructions n'ont pas été chargées à tort. Mais dans le cas contraire, les instructions n'auraient pas dû rentrer dans le pipeline. Le problème est similaire à celui observé avec les exceptions précises/imprécises. La différence est que les branchements sont des instructions machines, pas les exceptions matérielles. Mais le problème des instructions chargées à tort est le même dans les grandes lignes. Une différence est que les branchements sont résolus plus ou moins au même étage, alors que les exceptions peuvent survenir dans tout le pipeline. Une autre différence, bien plus importante, est que les branchements non-pris ne chargent pas d'instructions à tort et qu'il faut faire la différence entre branchement pris et non-pris. Dans la suite, nous parlerons d'instructions fautives pour parler des instructions chargées à tort. Pour corriger ce problème, il existe une solution logicielle. Le compilateur fait suivre les branchements par des instructions qui ne font rien, des NOP (No OPeration) : c'est ce qu'on appelle un '''délai de branchement'''. L'idée est que toutes les instructions chargées à tort sont des NOPs, qui ne font rien. Mais le nombre de NOP à ajouter dépend du pipeline du processeur, ce qui peut poser des problèmes de compatibilité. Mais d'autres solutions, matérielles cette fois, sont utilisées de nos jours. ===L'annulation des instructions au décodage=== Les branchements inconditionnels sont dans un cas plus favorable : ils sont identifiés dans l'unité de décodage et sont toujours pris. D'autres branchements sont dans ce cas : les appels de fonctions, les interruptions logicielles. Avec eux, le décodeur annule les instructions fautives, il les remplace par des instructions NOP. L'étage de décodage sait que les instructions suivant un branchement inconditionnel ont été chargées à tort. Précisément N instructions, avec N le nombre d'étages entre le chargement et le décodage (inclus). Les N instructions suivantes sont tout simplement annulés, elles sont remplacées par des NOPs. L'implémentation demande juste un simple compteur et quelques circuits annexes. Cependant, la technique ne marche pas du tout pour les branchements conditionnels. En effet, on ne sait pas s'ils sont pris ou non à l'étage de décodage. Du moins pas avec un pipeline normal. Mais certains processeurs, basés sur le pipeline RISC classique, calculaient les branchements pendant l'étape de décodage, dans une ALU spécialisée qui travaille en parallèle de l'unité de décodage. Le défaut de cette technique est que l'ALU en question doit lire les registres. Pour rappel, sur ce pipeline, l'étape de décodage effectue la lecture des opérandes dans les registres, en parallèle de l'unité de décodage. Mais ce qu'on n'a pas dit dans le chapitre précédent, c'est qu'elle effectue aussi le calcul des branchements dans une mini-ALU séparée ! Ce faisant, le résultat du branchement est connu dès la fin de l'étape de décodage. ===L'usage du système d'exceptions précises pour les branchements conditionnels=== La solution gère le problème en empêchant les instructions chargées à tort d'enregistrer leurs résultats, afin qu'elles n'aient pas le moindre effet sur l'état du processeur. Le hardware pour les exceptions précises, décrit quelques paragraphes avant, est réutilisé pour cela. Les branchements conditionnels peuvent être pris ou non-pris et on doit tenir compte de ces deux cas. Si le branchement n'est pas pris, les instructions n'ont pas été chargées à tort, elles peuvent enregistrer leur résultat dans les registres. Par contre, ce n'est pas le cas pour les branchements pris, les mécanismes d'exceptions précise doivent alors d'activer. Pour cela, la meilleure solution est que l'unité de calcul génère un indicateur d'exception si le branchement n'est pas pris. L'ALU effectue la comparaison et génère ou non l'indicateur d'exception adéquat suivant le résultat de celle-ci. L'idée est d'utiliser un indicateur d'exception particulier, qui précise qu'un branchement a été pris, signe que des instructions ont été chargées à tort. L'indicateur est propagé dans le pipeline, jusqu'à atteindre l'étage d'enregistrement dans les registres. Cet étage décide s'il faut autoriser ou non les écritures qu'il recoit, pour annuler les instructions chargées à tort. Le ''program counter'' est corrigé immédiatement, simplement en exécutant le branchement. Reste à savoir qui génère cet indicateur d'exception de branchement pris. Diverses optimisations visent à éliminer les instructions chargées à tort, à faire en sorte que des instructions utiles soient chargées. Le chapitre suivant va détailler ces optimisations dans le détail, qui sont regroupées sous le terme de prédiction de branchement. Elles ne fonctionnent pas pour les exceptions précises, seulement les branchements. ===La prédiction de branchement=== Les techniques précédentes ont pour défaut que de nombreux cycles d'horloge sont gâchés à cause des instructions fautives. Pour éviter ce gâchis, les concepteurs de processeurs ont inventé l'''exécution spéculative de branchement'', qui consiste à deviner l'adresse de destination du branchement et charger les bonnes instructions dès le départ. Le but est de ne pas charger d'instruction à tort, en prédisant quelles seront les bonnes instructions à charger. Et cela demande de résoudre trois problèmes : * reconnaître les branchements ; * savoir si un branchement sera exécuté ou non : c'est la '''prédiction de branchement''' ; * dans le cas où un branchement serait exécuté, il faut aussi savoir quelle est l'adresse de destination : c'est la '''prédiction de l'adresse de destination''' d'un branchement, aussi appelée ''branch target prediction''. Pour résoudre le second problème, le processeur contient un circuit qui déterminer si le branchement est pris (on doit brancher vers l'adresse de destination) ou non pris (on poursuit l’exécution du programme immédiatement après le branchement) : c'est l''''unité de prédiction de branchement'''. La prédiction de direction de branchement est elle aussi déléguée à un circuit spécialisé : l’'''unité de prédiction de direction de branchement'''. C'est l'unité de prédiction de branchement qui autorise l'unité de prédiction de destination de branchement à modifier le ''program counter''. Dans certains processeurs, les deux unités sont regroupées dans le même circuit. [[File:Relation entre prédiction de branchement et de direction de branchement.png|centre|vignette|upright=2|Relation entre prédiction de branchement et de direction de branchement.]] ==Les erreurs de prédiction de branchement== Le processeur peut parfaitement se tromper en faisant ses prédictions, ce qui charge des instructions par erreur dans le pipeline. Dans ce cas, on parle d''''erreur de prédiction'''. Les erreurs de prédiction sont corrigées par le système d'exception précises, ou un équivalent. En clair, les instructions chargées à tort se propagent jusqu'à la sortie du pipeline, où leurs résultats sont alors annulés. N'importe quelle technique de gestion des exceptions précise fait le travail, mais les processeurs modernes utilisent un tampon de réordonnancement (''reorder buffer'') qu'on détaillera dans quelques chapitres. Détecter les erreurs de prédiction demande d'ajouter un circuit dans le processeur : l’''unité de vérification de branchement'' (''branch verification unit''). Elle génère un indicateur d'exception, qui est géré par le système d'exception précise. Pour générer l'indicateur, elle compare l'adresse de destination prédite et celle calculée en exécutant le branchement. Pour cela, l'adresse prédite doit être propagée dans le pipeline jusqu’à ce que l'adresse de destination du branchement soit calculée. [[File:Unité de vérification de branchement.png|centre|vignette|upright=2|Unité de vérification de branchement]] ===La pénalité de mauvaise prédiction=== Le temps pour vider le pipeline des instructions fautives dépend du pipeline. La dernière instruction à être chargée dans le pipeline le sera en même temps qu'on détecte l'erreur de prédiction. Il faudra attendre que cette instruction quitte le pipeline, et donc qu'elle traverse tous les étages. Il y a donc un temps d'attente pour collecter les résultats de ces instructions et la décision de ne pas enregistrer leurs résultats dans les registres Le nombre d'instructions chargées à tort dépend de où est détectée l'erreur de prédiction. Plus le résultat du branchement est connu tard, plus le nombre d'instructions chargées à tort sera grand, et plus le nombre de cycles d'horloge gâchés le sera aussi. Le nombre de cycles gâchés s'appelle la '''pénalité de mauvaise prédiction'''. Elle correspond précisément au nombre de cycles/étages entre le premier étage du pipeline, et l'étage où le branchement est résolu (on connait l'adresse à laquelle brancher). Plus celui-ci est éloigné du début du pipeline, plus la pénalité est importante. [[File:MIT 6.823 Computer System Architecture, Fall 2005 - P-3 vs. P-4 Pipelines - 4473425702.jpg|centre|vignette|upright=2.5|Comparaison de la pénalité de mauvaise prédiction entre le Pentium 3 et le Pentium 4 d'Intel.]] Elle est généralement d'autant plus longue que le pipeline lui-même est long. Les concepteurs de processeurs font donc face à un compromis : un pipeline long permet d'exécuter plus d'instructions en parallèle, mais cela augmente aussi la pénalité de mauvaise prédiction. Un pipeline de 10-15 étapes est un bon compromis. Les pipelines plus longs ont besoin d'une prédiction de branchement élaborée pour fonctionner correctement. Plus la prédiction de branchement est performante, plus le pipeline peut être long. Les pénalités de mauvaise prédiction seront plus importantes, mais les mauvaises prédictions seront plus rares, ce qui compense complétement. ===La ''minimal control dependency''=== En utilisant une technique du nom de ''minimal control dependency'', seules les instructions qui dépendent du résultat du branchement sont supprimées du pipeline en cas de mauvaise prédiction, les autres n'étant pas annulées. Sans ''minimal control dependency'', toutes les instructions qui suivent un branchement dans notre pipeline sont annulées. Pourtant, certaines d'entre elles pourraient être utiles. Prenons un exemple : supposons que l'on dispose d'un processeur de 31 étages (un Pentium 4 par exemple). Supposons que l'adresse du branchement est connue au 9éme étage. On fait face à un branchement qui envoie le processeur seulement 6 instructions plus loin. Si toutes les instructions qui suivent le branchement sont supprimées, les instructions en rouge sont celles qui sont chargées incorrectement. On remarque pourtant que certaines instructions chargées sont potentiellement correctes : celles qui suivent le point d'arrivée du branchement. Elles ne le sont pas forcément : il se peut qu'elles aient des dépendances avec les instructions supprimées. Mais si elles n'en ont pas, alors ces instructions auraient dûes être exécutées. Il serait donc plus efficace de les laisser enregistrer leurs résultats au lieu de les ré-exécuter à l’identique un peu plus tard. Ce genre de choses est possible sur les processeurs qui implémentent une technique du nom de ''Minimal Control Dependancy''. En gros, cette technique fait en sorte que seules les instructions qui dépendent du résultat du branchement soient supprimées du pipeline en cas de mauvaise prédiction. [[File:Minimal cntrol dependency.png|centre|vignette|upright=2|Minimal control dependency]] ==La reconnaissance des branchements== Pour prédire des branchements, le processeur doit faire la différence entre branchements et autres instructions, dès l'étage de chargement. Or, un processeur « normal », sans prédiction de branchement, ne peut faire cette différence qu'à l'étage de décodage, et pas avant. Les chercheurs ont donc dû trouver une solution. La première solution se base sur les techniques de prédécodage vues dans le chapitre sur le cache. Pour rappel, ce prédécodage consiste à décoder partiellement les instructions lors de leur chargement dans le cache d'instructions et à mémoriser des informations utiles dans la ligne de cache. Dans le cas des branchements, les circuits de prédécodage peuvent identifier les branchements et mémoriser cette information dans la ligne de cache. Une autre solution mémorise les branchements déjà rencontrés, dans un cache dédié. Ce cache mémorise l'adresse (le ''program counter'') des branchements déjà rencontrés : il s'appelle le '''tampon d’adresse de branchement''' (''branch adress buffer''). Il est intégré à l'unité de prédiction de branchement, mais est alimenté par le décodeur d'instruction. Reconnaitre un branchement demande l'implication du décodeur d'instruction, qui sait si l'instruction qu'il vient de décoder est un branchement ou non. Il peut même savoir si le branchement est conditionnel, inconditionnel, un appel de retour de fonction, etc. Lorsque le décodeur d'instruction reconnait un branchement, il ajoute ce branchement dans le cache Le tapon d'adresse de branchement contient une adresse mémoire par branchement, et éventuellement d'autres informations. Et pour comprendre quelle adresse est mémorisée, il faut faire attention à un détail. Les explications qui vont suivre vont faire intervenir deux adresses. * La première est l''''adresse de destination''' du branchement, à savoir l'adresse à laquelle le processeur doit reprendre son exécution si le branchement est pris. * L'autre adresse est l''''adresse du branchement''' lui-même. Elle correspond à sa position en mémoire RAM. L'adresse du branchement est la valeur du ''program counter'' quand le branchement est chargé dans le cache, alors que l'adresse de destination est celle du ''program Counter'' une fois le branchement exécuté. Il faudra faire bien attention à ne pas confondre les deux adresses dans ce qui suit. L'adresse du branchement est celle qui est utilisée pour la reconnaissance du branchement. Le tampon d'adresse de branchement mémorise, pour chaque branchement, son adresse de branchement (d'où son nom). À chaque cycle d'horloge, l'unité de chargement envoie le ''program counter'' en entrée du tampon d’adresse de branchement. Un succès dans ce cache indique qu'un branchement déjà rencontré est ré-executé, on peut utiliser la prédiction de branchement. Dans le cas contraire, la prédiction de branchement n'est pas utilisée, et l'instruction est chargée normalement. ==La prédiction de l'adresse de destination== La '''prédiction de l'adresse de destination d'un branchement''' détermine l'adresse de destination d'un branchement. Rappelons qu'il existe plusieurs modes d'adressage pour les branchements et que chacun d'entre eux précise l'adresse de destination à sa manière. Suivant le mode d'adressage, l'adresse de destination est soit dans l'instruction elle-même (adressage direct), soit calculée à l’exécution en additionnant un ''offset'' au ''program counter'' (adressage relatif), soit dans un registre du processeur (branchement indirect), soit précisée de manière implicite (retour de fonction, adresse au sommet de la pile). : Histoire de simplifier la terminologie, je parlerais parfois de '''prédiction de destination''' pour parler de la ''prédiction de l'adresse de destination d'un branchement''. Pour rappel, les branchements se classent en trois types : * les branchements directs pour lequel l'adresse de destination est toujours la même ; * les branchements indirects où l'adresse de destination est dans un registre et peut changer ; * les instructions de retour de fonction. Les branchements directs sont facilement prévisibles, vu que l'adresse vers laquelle il faut brancher est toujours la même. La prédiction des branchements directs et relatifs se fait globalement de la même manière, ce qui fait que nous ferons la confusion dans ce qui suit. Pour les instructions de retour de fonction, une prédiction parfaite est possible. Et c'est ce qui explique qu'elles sont traitées à part, par une unité de branchement dédiée. Pour les branchements indirects, vu que cette adresse change, la prédire celle-ci est particulièrement compliqué (quand c'est possible). Les branchements indirects sont eux aussi traités par une unité de prédiction dédiée rien que pour eux. ===La prédiction de destination pour les branchements directs=== Lorsqu'un branchement est exécuté, on mémoriser l'adresse de destination et la réutiliser lors d'exécutions ultérieures du branchement. Cette technique marche à la perfection pour les branchements directs, pour lesquels cette adresse est toujours la même, mais donne des résultats mitigés pour les branchements indirects. Pour mémoriser l'adresse de destination, on utilise un cache qui mémorise les correspondances entre l'adresse du branchement et l'adresse de destination. Ce cache est appelé le '''''branch target buffer''''' en anglais, qui sera abrévié en BTB dans ce qui suit. Le BTB est une amélioration du tampon d'adresse de branchement vu plus haut, auquel on aurait ajouté les adresses de destination des branchements. Le BTB est utilisé comme suit : on envoie en entrée l'adresse du branchement, le BTB répond au cycle suivant en précisant : s’il reconnaît l'adresse d'entrée, et quelle est l'adresse de destination si c'est le cas. Le BTB peut être un cache totalement associatif, associatif par voie, ou directement adressé, mais ces derniers sont les plus fréquents. Les BTB sont généralement des caches de type directement adressés, où seuls les bits de poids faible de l'adresse du branchement adressent le cache, le reste de l'adresse est tout simplement ignoré. Il existe aussi des BTB qui sont construits comme les caches associatifs à plusieurs voies, mais ceux-ci impliquent généralement la présence de ''tags'', ce qui fait qu'ils sont un peu moins fréquents. [[File:Branch target buffer directement adressé.png|centre|vignette|upright=2|Branch target buffer directement adressé.]] Comme pour tous les caches, un accès au BTB peut entraîner un défaut de cache, c’est-à-dire qu'un branchement censé être dans le BTB n'y est pas. Comme pour les autres caches, les défauts de cache peuvent se classer en trois types : les défauts de cache à froid (''cold miss'') causés par la première exécution d'un branchement, les défauts liés à la capacité du BTB/cache, et les défauts par conflit d'accès au cache. Les défauts liés à la capacité du BTB sont les plus simples à comprendre. La capacité limitée du BTB fait que d'anciens branchements sont éliminées pour laisser la place à de nouveaux. En conséquence, ces branchements peuvent donner des erreurs de prédiction si leur correspondance a été éliminée du cache. On peut réduire des défauts en augmentant la taille du BTB. Les défauts liés aux conflits d'accès au BTB sont eux beaucoup plus intéressants, car la conception du BTB est assez spéciale dans la manière dont sont gérés ce genre de défauts. Le BTB est un cache partiellement associatif, ce qui fait que des défauts de cache par conflit d'accès peuvent avoir lieu. Pour rappel, les défauts par conflit d'accès ont lieu quand deux adresses mémoires se voient attribuer la même ligne de cache. Pour un BTB, cela correspond au cas où deux branchements se voient attribuer la même entrée dans le BTB, ce qui porte le nom d’''aliasing''. Ne pas détecter ce genre de situation sur un cache normal entraînerait des problèmes : la donnée lue/écrite ne serait pas forcément la bonne. Les caches normaux utilisent un système de tags pour éviter ce problème et on s'attendrait à ce que les BTB fassent de même. Mais pour un BTB, l'absence de ''tags'' n'est pas un problème : la seule conséquence est une augmentation du taux de mauvaises prédictions, dont les conséquences en termes de performance peuvent être facilement compensées. Les BTB peuvent donc se passer de tags, ce qui a de nombreux avantages : le circuit est plus rapide, prend moins de portes logiques, consomme moins d'énergie, etc. Ces économies de portes logiques et de performances peuvent être utilisées pour augmenter la taille du BTB, ce qui compense, voire surcompense les mauvaises prédictions induites par l’''aliasing''. Mais ce n'est pas systématique : d'autres BTBs utilisent des ''tags'' pour améliorer le taux de prédiction. Nous ferons donc la différence entre '''BTB tagués''' et '''BTB non-tagués'''. Des processeurs se passent de BTB. À la place, ils mémorisent les correspondances entre branchement et adresse de destination dans les bits de contrôle du cache d'instructions. Mais nous verrons cela en détail dans le chapitre suivant. ===La prédiction de destination pour les branchements indirects=== Un BTB classique peut fonctionner pour les branchements indirects. Son seul défaut est qu'il mémorisera la dernière adresse de destination prise par le branchement. Et cela peut fonctionner pour les branchements indirects fortement biaisés, qui ont presque toujours la même adresse de destination. Mais la majorité des branchements indirects change d'adresse de destination régulièrement. Tous les processeurs commerciaux datant d'avant le Pentium M utilisaient un BTB normal pour prédire les branchements indirects. Les processeurs qui les ont suivi sont capables de prédire l'adresse de destination d'un branchement indirect. , grâce à une '''unité de prédiction des branchements indirects''', séparée de l'unité de prédiction de branchement normale. Elle utilise un BTB amélioré, qui mémorise plusieurs adresses de destination pour un seul branchement, en compagnie d'informations pour prédire laquelle est la bonne. Il est possible d'utiliser un BTB unique, pour tous types de branchements, appelé un '''BTB unifié'''. Un BTB unifié a plusieurs adresse de destination par entrée du BTB. Mais une seule est utilisée pour les branchements directs. Vu que les branchements directs sont majoritaires, cela fait beaucoup d'entrées inutilisées. En conséquence, elle est tout sauf optimale, et n'est presque jamais utilisée en pratique. {|class="wikitable" |+ Branch Taarget Buffer unifié |- ! Adresse du branchement !! Informations de prédiction !! Adresse de destination !! Seconde adresse de destination |- | 0x54462146 || 11101011 || 0x52495674 || 0x24987461 |- | 0x54142446 || 11101101 || 0x15646956 || x0x4158746 |- | 0x25456542 || 10011101 || 0x52495674 || 0x24157461 |- | 0x15455855 || 11010101 || 0x52485754 || 0x24024511 |- | ... || ... || ... || ... |- | ... || ... || ... || ... |- | 0x54462146 || 1001101 || 0x52495674 || 0x24987461 |} Une seconde solution utilise un BTB séparé, distinct du BTB pour les branchements directs. En séparant les deux, on a un BTB simple qui ne mémorise qu'une adresse de destination par branchement, et un BTB pour branchements indirects avec plusieurs adresses de destination par branchement. [[File:BTB avec prédiction de branchements indirects.png|centre|vignette|upright=2.5|BTB avec prédiction de branchements indirects.]] L'idée est que l'on économise des circuits à nombre d'entrées égales. Par exemple, comparons ce que cela donne avec 64 entrées. D'un côté, on a un BTB à 64 entrées, capable de gérer deux adresse de destination par branchement. En pratique, la majorité des branchements sont des branchements directs, qui n'utiliseraient qu'une seule case mémoire. Les circuits du BTB seraient donc sous-utilisés. De l'autre, on a un BTB à 48 entrées pour branchements directs, et un second BTB avec 16 entrées, capable de mémoriser deux adresses par branchements. Le premier BTB utilise moins de circuits, et est utilisé à pleine capacité, il ne gaspille pas de circuits pour mémoriser des adresses de destination vides. Par contre, on perd un peu en flexibilité. L'organisation précédente marche bien si 1/4 des branchements rencontrés sont des branchements indirects. Mais si le ratio est différent, la performance est légèrement sous-optimale. Attention, cela ne veut pas dire qu'il y aura des entrées vides dans un BTB, non. Simplement, le premier BTB risque de manquer de place pour les branchements directs récents, alors que les branchements indirects seront eux bien prédits, même les anciens. À l'opposé, un BTB unique permet de mémoriser les 64 derniers branchements, peu importe qu'ils soient indirects ou non. En clair : un BTB unique est plus flexible, ce qui donne un gain de performance dans certaines situations. Cependant, cet avantage théorique ne tient pas compte de l'économie en circuits, qui peut être utilisée pour faire grossir le BTB pour branchements directs. Par exemple, à budget à transistor équivalent, on a le choix entre BTB à 64 entrées uniques, et un BTB pour branchements directs de 80 entrées avec un BTB indirect de 16 entrées. Le fait qu'un BTB soit plus gros permet des gains de performances qui compensent la perte de flexibilité. On peut ainsi avoir le meilleur des deux monde, à savoir une économie de circuits et les gains en performance qui en découlent, malgré une petite perte de flexibilité. Une troisième solution utilise un BTB normal, mais réserve plusieurs entrées pour un branchement indirect. Par exemple, un branchement indirect qui a trois adresses de destination prendra trois entrées : une par adresse de destination. Mais quelques difficultés surviennent alors. Comment placer trois copies d'un même branchement, alors que ces trois copies ont le même ''program counter'' ? Et que faire pour savoir quelle copie sera prise ? Que faire si l'unité de prédiction de branchement prédit les trois copies comme prises ? La technique du '''''Program Counter'' virtuel''' (''Virtual Program Counter'') résoud ces problèmes d'une manière assez élégante. L'idée est la même que précédemment, à savoir qu'il y a une copie du branchement par adresse de destination, dans le BTB. Mais ces copies n'ont pas exactement le même ''Program counter''. Chaque copie se voit attribuer un ''Program Counter'' virtuel. Et comprendre pourquoi demande de comprendre l'idée sous-jacente. La technique a été inspirée par une optimisation des compilateurs, qui transforme un branchement indirects en une séquence de branchements conditionnels. Par exemple, un branchement indirects qui peut brancher vers les adresses A, B et C est transformé en trois branchements conditionnels : un qui branche vers A, un autre qui branche vers B et un autre vers C. Elle ne marche pas pour tous les branchements indirects, elle demande des circonstances particulières pour fonctionner. Mais l'idée est adaptée pour le ''design'' de l'unité de prédiction de branchement. L'idée est qu'un branchement indirect est déroulé en plusieurs branchements dans le BTB. Les branchements sont traités en séquence : l'unité de prédiction prédit si le premier branchement est pris ou non. S'il est pris, son adresse de destination est envoyée au cache d'instruction. S'il n'est pas pris, l'unité de prédiction tester la seconde copie du branchement, et rebelote. Il teste toutes les copies, une par une, et s’arrête quand il tombe sur un branchement pris. Si aucune copie n'est prise, alors le branchement est prédit comme non-pris. Première remarque : il s'agit d'un processus itératif, où on test un branchement à la fois. Et oui, c'est une prédiction par cycle d'horloge. Prédire un branchement indirect peut donc demander plusieurs cycles d'horloge. Mais ce n'est pas un problème, car la prédiction peut être utilisée par l'unité de décodage, pour annuler les instructions chargées à tord. Deuxième remarque : comment passer d'un branchement à l'autre ? La première copie a un ''program counter'' normal, qui correspond à l'adresse du branchement. La seconde a un ''program counter'' qui est calculé à partir de cette première adresse, la troisième copie a une adresse calculée à partir de la seconde, etc. Concrètement, n'importe quelle fonction un tant soit peu simple suffit, tant qu'elle est déterministe. L'article qui a proposé cette techniques (''Virtual Program Counter (VPC) Prediction : Very Low Cost Indirect Branch Prediction Using Conditional Branch Prediction Hardware'') se contenait de faire un XOR entre l'adresse précédente et une constante, avec une constante différente par itération. Troisième remarque : l'algorithme demande de faire une parenthèse dans le fonctionnement normal du BTB. Quand un branchement indirect est détecté, il lance une succession de prédiction pour prédire un branchement. L'incrémentation du ''program counter'' normale cesse pendant ce processus. Et cela ne doit être fait que quand un branchement indirect est détecté. Pour cela, le BTB mémorise le type de chaque branchement, sans l'entrée du BTBT : est-ce qu'il est conditionnel, inconditionnel, mais aussi direct ou indirect. Et le type est accesible en sortie du BTB, sur une sortie de quelques bit. Quand l'unité de prédiction remarque qu'un branchement indirect vient de sortir du BTB, alors elle lance le processus. [[File:Virtual program counter.png|centre|vignette|upright=2.5|Virtual program counter]] ===La prédiction de l'adresse de retour d'une fonction=== Presque tous les processeurs conçus après les années 2000 peuvent prévoir l'adresse à laquelle il faudra reprendre lorsqu'une fonction/sous-programme a fini de s’exécuter. Ils font de la '''prédiction de retour de fonction'''. Pour cela, ils possèdent pour une unité de prédiction de branchement spécialisée, appelée le ''return stack predictor'', ou '''''pile de prédiction de retour de fonction'''''. Le terme ''return stack predictor'' étant plus court, nous l'utiliserons dans la suite du cours. Le ''return stack predictor'' est capable de reconnaitre les instructions de retour de fonction, et de prédire l'adresse de retour. Pour comprendre comment elle fait, rappelez-vous le chapitre sur la pile d'appel. Dans ce chapitre, j'avais introduit le concept de ''pile d'adresse de retour'', à savoir une pile d'appel très simplifiée, qui ne contient que les adresses de retour des fonctions en cours d'exécution. Le ''return stack predictor'' est essentiellement composé d'une telle pile d'adresse de retour, avec des circuits pour empiler et dépiler les adresses de retour. Le ''return stack predictor'' doit reconstruire une pile d'adresses de retour, à partir du programme exécuté. La pile d'adresse est mémorisée dans le ''return stack predictor'', elle est totalement séparée de la pile d'appel normale. Le programme exécuté continue d'utiliser la même pile d'appel qu'avant, ce qui fait que les adresses de retour sont dispersées dans la pile d'appel, parfois dans des registres spéciaux du processeur. Mais cela ne pose pas de problèmes pour le processeur, qui reconstruit la pile d'adresse de retour avec l'aide du décodeur. La reconstruction se fait à partir des instructions d'appel et de retour de fonction. Une instruction d'appel de fonction empile une adresse de retour, une instruction de retour de fonction la dépile. Lorsque le décodeur d'instruction reconnait une instruction d'appel de fonction, il extrait l'adresse de retour et l'envoie au ''return stack predictor''. L'adresse de retour est alors empilée, ce qui fait qu'elle se retrouve au sommet de la pile d'adresse de retour. Dépiler une adresse de retour revient à prédire un branchement. Lorsque le ''return stack predictor'' détecte une instruction de retour de fonction, il dépile l'adresse au sommet de la pile. L'adresse est alors envoyée du cache d'instruction, pour l'ire l'instruction adéquate, et le ''program counter'' est mis à jour avec cette adresse. Toute la difficulté revient à détecter les instructions de retour de fonction, sans quoi on ne sait pas quand dépiler l'adresse. La documentation là-dessus n'est pas très fournie, il est difficile de savoir ce qui est fait dans les processeurs modernes. Mais on peut faire quelques suppositions. La première possibilité est l'usage du ''prédécodage'', une technique qui décode partiellement les instructions lors de leur chargement dans le cache d'instruction. Elle sera vue dans le chapitre suivant, mais parlons-en rapidement. Le prédécodage ajoute quelques bits au début de chaque instruction pour préciser si c'est un branchement conditionnel, inconditionnel, un retour de fonction. On peut ainsi savoir rapidement quand dépiler : dès qu'une instruction est lue dans le cache d'instruction, le ''return stack predictor'' vérifie les bits ajoutés et agit en fonction. La seconde possibilité est que le BTB reconnait l'instruction de retour de fonction. Pour chaque branchement, il mémorise non seulement son adresse de retour, mais aussi son type. Par type, on veut dire : si c'est un branchement conditionnel, inconditionnel, un retour de fonction. Les retours de fonction sont donc reconnus à condition qu'ils aient été exécutés auparavant. Il est aussi possible d'avoir un BTB spécialisé dans les retours de fonction, qui est alimenté par les décodeurs. Dès que le décodeur reconnait une instruction de retour de fonction, il ajoute son ''program counter'' dans ce BTB spécialisé. Une dernière possibilité est que le ''return stack predictor'' agit une fois l'instruction partiellement décodée. Le décodeur d'instruction reconnait les instructions de retour de fonction rapidement, en un cycle, et consulte alors le ''return stack predictor''. L'adresse est dépilée, et le décodeur sait où brancher. Il n'a pas besoin de devoir lire l'adresse de retour en mémoire, il la lit depuis le ''return stack predictor'', directement. La prédiction se fait donc assez tard dans le pipeline, quelques instructions ont été chargées à tort. Mais le décodeur peut les annuler, comme il le ferait avec un branchement inconditionnel mal prédit. ==La prédiction de branchement locale== La prédiction de branchement tente de prédire si un branchement sera pris ou non-pris, et décide quelle adresse envoyer au cache en fonction du résultat. Si on prédit qu'un branchement est non pris, on laisse le ''program counter'' faire son travail. À l'inverse si le branchement est prédit comme étant pris, l'adresse de destination est envoyée au cache d'instruction et au ''program counter''. La prédiction de branchement assigne une probabilité qu'un soit branchement soit pris en fonction de ces informations : si la probabilité est de plus de 50%, le branchement est considéré comme pris. La prédiction de branchement se base avant tout sur des statistiques, c’est-à-dire qu'elle détermine la probabilité d’exécution d'un branchement en fonction d'informations assez diverses. La prédiction de branchement n'a d'intérêt que si les prédictions sont suffisamment fiables pour valoir le coup. Rappelons que la pénalité lors d'une mauvaise prédiction est importante, d'autant plus coûteuse que le pipeline est long. Il est généralement admis qu'il faut au minimum un taux de réussite proche de 90%, voire plus sur les processeurs modernes. Heureusement, la plupart des branchements sont des '''branchements biaisés''', c’est-à-dire qu'ils sont presque toujours pris ou presque toujours non-pris, sauf en de rares occasions. De tels branchements font que la prédiction des branchements est facile, bien qu'ils posent paradoxalement quelques problèmes, comme nous le verrons plus bas. Un autre point important est que les unités de prédiction de branchement doivent être très rapides. Idéalement, elles doivent fournir leur prédiction en un seul cycle d'horloge. Un seul cycle d'horloge est un temps très court, surtout sur les ordinateurs avec une fréquence élevée. Les techniques de prédiction dynamique ne peuvent donc pas utiliser des méthodes statistiques extraordinairement complexes. Cela va à l'encontre du fait que le taux de mauvaises prédictions doit être très faible, idéalement inférieur à 1% sur les processeurs modernes. Vous comprenez donc aisément que concevoir une unité de prédiction de branchement est un véritable défi d’ingénierie électronique qui requiert des prouesses technologiques sans précédent. Suivant la nature des informations utilisées, on peut distinguer plusieurs types de prédiction de branchement : locale, globale, dynamique, statique, etc. Nous allons d'abord voir les méthodes statiques avant de voir les méthodes dynamiques. La '''prédiction locale''' prédit chaque branchement indépendamment les uns des autres. Elle sépare bien les statistiques/moyennes pour chaque branchement. Et c'est autant un point fort qu'un point faible. En effet, elle n'exploite pas les corrélations entre branchements, à savoir le fait que l'exécution d'un branchement a tendance à déborder sur les suivants. Dans ce qui suit, nous allons voir les techniques de prédiction locales. ===Les ''branch hints''=== Dans son implémentation la plus explicite, la prédiction est inscrite dans le branchement lui-même. Quelques bits de l'opcode du branchement précisent si le branchement est majoritairement pris ou non pris. Ils permettent d'influencer les règles de prédiction de branchement et de passer outre les réglages par défaut. Ces bits sont appelés des '''suggestions de branchement''' (''branch hint''). Mais tous les processeurs ne gèrent pas cette fonctionnalité. Et de plus, cette solution marche assez mal. La raison est que le programmeur ou le compilateur doit déterminer si le branchement est souvent pris ou non-pris, mais qu'il n'a pas de moyen réellement fiable pour cela. Une solution possible est d’exécuter le programme sur un ensemble de données réalistes et d'analyser le comportement de chaque branchement, afin de déterminer les suggestions de branchement adéquates, mais c'est une solution lourde et peu pratique, qui a de bonnes chances de donner des résultats peu reproductibles d'une exécution à l'autre. ===La prédiction statique de branchement=== La '''prédiction statique''' prédit le résultat du branchement en fonction de caractéristiques fixes du branchement lui-même, comme son adresse, son mode d'adressage, l'adresse de destination, la position du branchement en mémoire, si le branchement est direct ou indirect, etc. Les informations en question sont connues une fois le branchement décodé, il n'y a pas besoin de mémoriser la moindre information supplémentaire. Une première implémentation, assez évidente, fait la différence entre les branchements inconditionnels toujours pris, et les branchements conditionnels au résultat variable. L'idée est de prédire les branchements inconditionnels comme toujours pris alors que les branchements conditionnels sont prédits comme non-pris (ce qui est une approximation). L'implémentation est relativement simple : on utilise un BTB qui ne mémorise que les branchements inconditionnels, et basta ! Les branchements conditionnels sont simplement ignorés, traités comme n'importe quelle autre instruction. Cette méthode est particulièrement inefficace pour les branchements de boucle, où la condition est toujours vraie, sauf en sortie de boucle ! Il a donc fallu affiner légèrement l'algorithme de prédiction statique. Une implémentation plus subtile fait la différence entre les branchements des boucles et les autres. Reste à identifier ceux-ci d'une manière ou d'une autre. Et un bon moyen est de regarder si le branchement revient en arrière ou au contraire saute une portion de code. Un programme s'exécute des adresses basses vers les adresses hautes, les instructions vont naturellement dans le sens croissant des adresses. Un branchement de boucle fait revenir en arrière, histoire de répéter un morceau de code, pas les autres branchements (du moins pas forcément). Et cela nous amène à faire la distinction entre les branchements conditionnels ascendants et descendants. Un '''branchement conditionnel ascendant''' est un branchement qui demande au processeur de reprendre plus loin dans la mémoire : l'adresse de destination est supérieure à l'adresse du branchement. Un branchement '''conditionnel descendant''' a une adresse de destination inférieure à l'adresse du branchement : le branchement demande au processeur de reprendre en arrière dans la mémoire, avant le branchement. Les branchements ascendants sont rarement pris (ils servent dans les conditions de type SI…ALORS), contrairement aux branchements descendants (qui servent à fabriquer des boucles). On peut ainsi modifier l’algorithme de prédiction statique comme suit : * les branchements inconditionnels sont toujours pris ; * les branchements descendants sont toujours pris ; * les branchements ascendants ne sont jamais pris. L'implémentation demande d'élargir le BTB. Le BTB n'a pas seulement à mémoriser l'adresse du branchement et son adresse de destination, mais aussi le type du branchement. En général, il se contente de deux bits, qui indiquent si le branchement est conditionnel, inconditionnel, ou un retour de fonction. Les retours de fonction sont traités à part, histoire de profiter du ''return stack predictor''. {|class="wikitable" |+ BTB avec prédiction de branchement statique évoluée |- ! Adresse du branchement !! Adresse de destination !! Type du branchement |- | 0x4165485 || 0x14657445541 | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Retour de fonction |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... || ... |} ===La prédiction avec des compteurs à saturation=== Les méthodes de prédiction de branchement statique sont intéressantes, mais elles montrent rapidement leurs limites. Pour plus de performances, il faut utiliser la '''prédiction dynamique''', qui extrait des statistiques sur l’exécution des branchements. Pour le reste du chapitre, toutes les techniques utilisent la prédiction dynamique, qu'elles soient locales ou globales. Sa version la plus simple se contente de mémoriser ce qui s'est passé lors de l’exécution précédente du branchement. Si le branchement a été pris, alors on suppose qu'il sera pris la prochaine fois. De même, s'il n'a pas été pris, on suppose qu'il ne le sera pas lors de sa prochaine exécution. Pour cela, chaque entrée du BTB contient un bit qui mémorise la dernière exécution du branchement : 1 s'il a été pris, 0 s'il n'a pas été pris. C'est ce qu'on appelle la '''prédiction de branchements sur 1 bit'''. Au vu de sa simplicité, il ne faut pas s'attendre à des miracles. La méthode fonctionne bien pour les branchements des boucles et les branchements inconditionnels, mais elle n'est pas assez performante. Une forme plus élaborée de prédiction utilise des '''compteurs à saturation'''. Un compteur à saturation est un compteur qui est incrémenté à chaque fois qu'un branchement est pris, décrémenté s'il est non-pris. Le compteur est initialisé à la moitié de sa valeur maximale, ce qui correspond à une probabilité proche de 50%. Les compteurs à saturation sont très utilisés et pas seulement pour de la prédiction locale, les méthodes globales en utilisent aussi. Pour faire la prédiction, l'unité de prédiction regarde le bit de poids fort du compteur. Le branchement est considéré comme pris si ce bit de poids fort est à 1, et non pris s'il vaut 0. En clair : on considère que le branchement est pris si le compteur indique une probabilité supérieure à 50%. [[File:Compteur à saturation.png|centre|vignette|upright=2|Compteur à saturation.]] : Pour la culture générale : le compteur à saturation du Pentium 1 était légèrement bogué, ce qui fait que sa prédiction de branchement était sous-optimale. La plupart des processeurs utilisent un compteur à saturation par branchement, ce qui veut dire un compteur à saturation par entrée dans le BTB. Vu chaque compteur est associé à une entrée du BTB, le phénomène d’''aliasing'' mentionné plus haut peut perturber les prédictions de branchement. Deux branchements peuvent se voir associés à la même entrée du BTB, et agir sur le même compteur à saturation, ce qui entraine l'apparition d''''interférences entre branchements'''. L'interférence peut avoir des effets positifs, neutres ou négatifs, suivant la corrélation entre les deux branchements. L’''aliasing'' n'est pas un problème si les deux branchements sont fortement corrélés, par exemple si les deux sont tout le temps pris ou tout le temps non-pris. Si les deux branchements vont dans le même sens, l'interférence est neutre, voire légèrement positive. Mais si l'un est souvent pris et l’autre souvent non-pris, l'interférence est négative. Les deux branchements vont se marcher dessus sans vergogne, chacun interférant sur les prédictions de l'autre ! Il est rare que l’''aliasing'' ait un impact significatif sur les unités de prédiction des deux paragraphes précédents. Il se manifeste surtout quand la BTB est très petite et la seule solution est d'augmenter la taille du BTB. ===La prédiction avec un historique local=== La prédiction avec des compteurs à saturation est très simple, mais il est possible de créer une implémentation alternative, très proche, avec des avantages et désavantages différents. Un compteur à saturation enregistre l'historique d'un branchement, à savoir ses dernières exécutions. L''''historique''' d'un branchement dit quel a été son résultat dans le passé, pour ses exécutions précédentes. Et il est possible d'enregistrer cet historique et l'utiliser pour faire des prédictions. Par exemple, prenons un branchement sont ses 6 dernières exécutions sont "pris, non-pris, pris, pris, pris, non-pris". Le processeur peut encoder cet historique sur 6 bits. Un branchement pris correspond à un bit à 1, un branchement non-pris correspond à un 0. Le bit de poids fort correspond à l'exécution la plus ancienne, le bit de poids faible à l'exécution la plus récente. Par exemple, si l'historique contient 010, cela veut dire que le branchement a été non pris, puis pris, puis non pris. L'historique d'un branchement est appelé son '''historique local''', mais nous parlerons simplement l'historique. Le processeur peut mémoriser l'historique de chaque branchement. Pour cela, il réserve n bits par branchements, pour mémoriser ses n exécutions précédentes. L'historique d'un branchement est mémorisé dans son entrée du BTB. Et plus précisément dans un champ séparé qui est alimentée comme un registre à décalage. Reste à exploiter cet historique local pour faire des prédictions. Une idée assez simple serait de l'utiliser pour calculer une moyenne des N exécutions précédentes. Si la probabilité d'être pris est supérieure à 50 %, le branchement est considéré comme pris. Dans le cas contraire, il est considéré comme non pris. Le calcul de la moyenne utilise une simple porte à majorité, qui prend en entrée tous les bits de l'historique. Cependant, cette méthode est équivalente à utiliser un compteur à saturation par branchement. La sortie de la porte à majorité est équivalente à ce qu'on aurait dans un compteur à saturation. La seule différence : le cout en circuit et la simplicité de conception. Prenons l'exemple des premiers processeurs PA RISC. Ils mémorisaient, pour chaque branchement, les trois dernières exécutions du branchement, avec trois bits par entrée du BTB. La prédiction était réalisée avec une porte à majorité, dont le résultat était utilisé comme prédiction. Ces processeurs ont préféré utiliser un historique de 3 bits, car il était plus simple à concevoir qu'un compteur à saturation équivalent de deux bits. Il suffisait d'alimenter un registre à décalage avec un bit, pas besoin d'incrémenter ou de décrémenter un compteur, pas besoin de signaux d'incrémentation/décrémentation séparés, etc. Mais pour des historiques plus longs, la donne s'inverse et des compteurs à saturation de deux-trois bits l’emportent. Un défaut de la méthode précédente, qui est partagée avec les compteurs à saturation, est qu'elle marche mal pour les branchements qui suivent un ''pattern''. Je veux dire par là que l'exécution d'un branchement suit un cycle, par exemple pris, pris, non-pris, pris, pris, non-pris, et ainsi de suite en boucle. Notons que l'apparition de tels motifs correspond le plus souvent à la présence de boucles dans le programme. Quand une boucle s’exécute, les branchements qui sont à l'intérieur tendent à exprimer de tels motifs. Avec de tels motifs, les compteurs à saturation feront des prédictions incorrectes, là où les prédicteurs qu'on va voir font des prédictions parfaites. Par exemple, prenons un branchement qui suit le ''pattern'' pris, pris, pris, non-pris. Si l'historique local vaut 1110, le branchement est censé refaire une boucle et être pris. Et certes, on a supposé que l'historique faisait 4 bits. Mais le''pattern'' sera visible tant que l'historique fait plus de 4 bits. Par exemple, le ''pattern'' serait 01110 pour un historique local de 5 bits, 101110 pour un historique local de 6 bits, 1101110 pour un historique local de 7 bits, etc. Un ''pattern'' correspond donc à une valeur bien précise de l'historique local. L'historique local permet de voir ces cycles, reste à les exploiter. Prédire correctement les ''pattern'' demande simplement d'associer un compteur à saturation à chaque valeur possible de l'historique local. Le compteur à saturation dira, quand tel pattern est rencontré, si le branchement sera pris ou non-pris par la suite. Au total, il faut donc un registre d'historique local de n bits couplé à 2^n compteurs à saturation. Et ce pour chaque branchement possible. Les compteurs à saturation sont regroupés dans une '''''Pattern History Table''''', que nous noterons PHT dans ce qui suit. Le résultat est une '''unité de prédiction à deux niveaux locale''', qui contient plusieurs registres d'historique et tout autant de PHT locales. En tout, il y a une PHT et un historique par branchement, donc par entrée du BTB. Quand un branchement est exécuté, son historique local indique quel compteur à saturation choisir dans la PHT locale. En général, l'historique local est mémorisé dans le BTB, reste à gérer la PHT. [[File:Unité de prédiction à deux niveaux purement locale.png|centre|vignette|upright=2.5|Unité de prédiction à deux niveaux avec des historiques et des PHT locales.]] Une unité de prédiction locale à deux niveaux contient plusieurs PHT, reste à savoir quelle PHT correspond à tel branchement. La correspondance se fait idéalement à partir de l'adresse du branchement, mais c'est là une implémentation idéale, qui demande beaucoup de circuits. Généralement, l'unité de prédiction n'utilise que les bits de poids faible de l'adresse du branchement, ce qui permet de faire un choix correct, mais avec une possibilité d'''aliasing''. Il est aussi possible de séparer l'historique local du BTB. Les historiques locaux sont alors mémorisés dans une mémoire RAM dédiée, appelé la '''''Branch History Table''''', ou encore la '''table des historiques locaux''', que nous noterons BHT dans ce qui suivra. Chaque byte de la BHT contient l'historique local associé à un branchement. Pour récupérer l'historique local voulu, l'adresse du branchement est envoyée à la BHT, sur les entrées d'adresse de la BHT, directement. Seuls les bits de poids faible de l'adresse sont utilisés. L'historique local est ensuite envoyé à la PHT adéquate, choisie en fonction des mêmes bits de poids faible du PC. Un bon moyen pour cela est d'accéder à toutes les PHT en parallèle, mais de sélectionner la bonne avec un multiplexeur, ce dernier étant commandé par les bits de poids faible du PC. [[File:Table des historiques locaux.jpg|centre|vignette|upright=2|Table des historiques locaux]] Un défaut des unités de prédiction purement locales de ce type est leur cout en termes de circuits. Utiliser un registre d'historique et une PHT par branchement a un cout en transistor élevé, avec tous les défauts que ca implique en termes de consommation électrique et thermique. Elles ont aussi des temps de calcul un peu plus important que les unités purement globales, mais pas de manière flagrante. Les gains en performance avec un historique local s'observent surtout pour les branchements qui sont dans des boucles ou pour les branchements utilisés pour concevoir des boucles, alors que l'implémentation globale marche bien pour les if..else (surtout quand ils sont imbriqués). Les deux sont donc complémentaires. Dans les faits, elles sont rarement utilisées seules du fait de leurs défauts, au profit d'unités de prédiction hybrides, qui mélangent historiques et PHT locaux et globaux. ==La prédiction de branchement globale/mixte== Utiliser un historique local avec une PHT est très puissant. Mais le cout en circuit est très important. Cependant, on peut adapter l'idée pour faire de la prédiction globale. La '''prédiction globale''' fait des prédictions holistiques, qui tiennent compte de tous les branchements, sans les traiter séparément. Elle ne sépare pas les branchements, tous sont mis dans le même panier. La prédiction globale fait donc le compromis inverse : elle exploite les corrélations entre branchements, mais perd les informations statistiques précise pour chaque branchement. L'idée est qu'au lieu d'utiliser un historique par branchement, on utilise un '''historique global''' qui mémorise les exécutions de tous les branchements conditionnels. L'historique enregistre les N exécutions de branchements conditionnels. L'historique global est mémorisé dans un registre unique, appelé le '''registre d'historique global'''. C'est un registre à décalage mis à jour à chaque fois que le processeur exécute un branchement : on fait rentrer un 1 si le branchement est pris, et un 0 sinon. Une unité de prédiction globale utilise cet historique pour faire sa prédiction. L'unité de prédiction globale utilise un compteur à saturation pour chaque valeur possible du registre d'historique. En clair, pour un registre de N bits, elle utilise 2^n compteurs à saturation. Là encore, les compteurs à saturation sont regroupés dans une ''Pattern History Table''. Nous parlerons de '''PHT globale''' pour désigner une PHT commandée par un historique global, de ''PHT locale'' si elle est adressée par un historique local. [[File:Prédiction dynamique à deux niveaux.png|centre|vignette|upright=2|Prédiction dynamique à deux niveaux.]] Il est admis que l'historique global donne de meilleures prédictions que l'historique local. Mais les deux sont complémentaires, ce qui fait qu'il existe des approches hybrides. Les approches hybrides exploitent à la fois les statistiques pour chaque branchement et les corrélations entre branchements. Les prédicteurs hybrides ont de meilleures performances que les prédicteurs purement globaux ou locaux. Avec un historique global, la prédiction tient compte d'éventuelles corrélations entre branchements consécutifs/proches pour faire les prédictions. C'est un avantage pour les applications qui enchaînent des if...else ou qui contiennent beaucoup de if...else imbriqués les uns dans les autres, qui s’exécutent plusieurs fois de suite. Le résultat de chaque if...else dépend généralement des précédents, ce qui rend l'utilisation d'un historique global intéressant. Par contre, ils sont assez mauvais pour le reste des branchements. Mais cette qualité devient un défaut quand elle détecte des corrélations fortuites ou parasites, inutiles pour la prédiction (corrélation n'est pas causalité, même pour prédire des branchements). Du fait de ce défaut, la prédiction globale a besoin de registres d'historiques globaux très larges, de plusieurs centaines de bits au mieux. [[File:2 level branch predictor.svg|centre|vignette|upright=2|Unités de prédiction de branchement à deux niveaux avec une seule PHT.]] Le circuit précédent a cependant un problème : si deux branchements différents s'exécutent et que l'historique est le même, le processeur n'y verra que du feu. Il y a alors une interférence, à savoir que les deux branchements vont agir sur le même compteur à saturation. On se retrouve alors dans une situation conceptuellement identique à l’''aliasing'' dans le BTB ou avec des compteurs à saturation. Sauf que les interférences sont alors beaucoup plus nombreuses et qu'elles sont clairement un problème pour les performances ! Mitiger les interférences devient alors bien plus important qu'avec les unités de prédiction précédentes. Heureusement, si augmenter la taille de la PHT peut mitiger le problème, d'autres solutions sont possibles sur les unités de prédiction globales. Elles sont au nombre de deux : mitiger les interférences liées aux branchements biaisés, ajouter des informations qui discriminent les branchements. Voyons ces solutions dans le détail. ===La mitigation des interférences par usage de l'adresse de branchement=== La première solution pour réduire l'impact de l’''aliasing'' est d'augmenter la taille de la PHT et de l'historique. Plus l'historique et la PHT sont grands, moins l’''aliasing'' est fréquent. Mais avoir des historiques et des PHT de très grande taille n'est pas une mince affaire et le cout en termes de performances et de portes logiques est souvent trop lourd. Une autre solution consiste à utiliser, en plus de l'historique, des informations sur le branchement lui-même pour choisir le compteur à saturation à utiliser. Typiquement, on peut utiliser l'adresse du branchement en plus de l'historique, pour choisir le compteur à saturation adéquat. Pour être précis, on n'utilise que rarement l'adresse complète du branchement, mais seulement les bits de poids faible. La raison est que cela fait moins de bits à utiliser, donc moins de circuits, mais l’''aliasing'' est juste atténué, pas éliminé. Des branchements différents ont beau avoir des adresses différentes, les bits de poids faible de leurs adresses peuvent être identiques. Des confusions sont donc possibles, mais sont assez rares pour que cela suffise en pratique. Une unité de prédiction de ce type est illustrée ci-dessous. Il y a plusieurs PHT séparées, donc plusieurs ensembles de 2^n compteurs à saturation. Le choix de la PHT est réalisé par l'adresse du branchement, alors que le choix du compteur à saturation est réalisé par l'historique. Les compteurs à saturation d'un branchement sont mis à jour seulement quand le branchement s'est chargé pendant que l'historique associé était dans le registre d'historique. Cette méthode a cependant le défaut de gâcher énormément de transistors. [[File:Unité de prédiction de branchement basée sur des compteurs à saturation sélectionnés par l'historique global.jpg|centre|vignette|upright=2|Unité de prédiction de branchement basée sur des compteurs à saturation sélectionnés par l'historique global]] D'autres unités de prédiction fonctionnent sur le même principe que l'unité précédente, sauf que les différentes PHTs sont fusionnées en une seule grosse PHT. C'est ce que font les unités de prédiction '''''gshare''''' et '''''gselect''''', ainsi que leurs dérivées. Avec les unités de prédiction '''''gselect''''', on concatène les bits de poids faible de l'adresse du branchement et l'historique pour obtenir le numéro du compteur à saturation à utiliser. [[File:Prédiction « gselect ».png|centre|vignette|upright=2|Prédiction « gselect ».]] Avec les unités de prédiction '''''gshare''''', on fait un XOR entre le registre d'historique et les bits de poids faible de l'adresse du branchement. Le résultat de ce XOR donne le numéro du compteur à utiliser. Avec cette technique, on utilise une PHT globale et un historique global, mais le registre d'historique est manipulé de manière à limiter l'''aliasing''. [[File:Prédiction « gshare ».png|centre|vignette|upright=2|Prédiction « gshare ».]] Intuitivement, on pourrait croire que les unités de prédiction gselect ont de meilleures performances. C'est vrai que les unités gshare combinent l'adresse du branchement et l'historique d'une manière qui fait perdre un peu d'information (impossible de retrouver l'adresse du branchement et l'historique à partir du résultat du XOR), alors que les unités gselect conservent ces informations. Mais il faut prendre en compte le fait que les deux unités doivent se comparer à PHT identique, de même taille. Prenons par exemple une PHT de 256 compteurs, adressée par 8 bits. Avec une unité gselect, les 8 bits sont utilisés à la fois pour l'historique et pour les bits de poids faible de l'adresse du branchement. Alors qu'avec une unité gshare, on peut utiliser un historique de 8 bits et les 8 bits de poids faible de l'adresse du branchement. L'augmentation de la taille de l'historique et du nombre de bits utilisés fait que l’''aliasing'' est réduit, et cela compense le fait que l'on fait un XOR au lieu d'une concaténation. Les unités précédentes réduisent l'''aliasing'' en segmentant la PHT : telles entrées sont allouées à un branchement, telles entrées à tel autre branchement, etc. Cependant, l'historique local n'est pas utilisé. Mais quitte à allouer des entrées à un branchement, autant en profiter pour leur associer son historique local. Il s'agit alors d'approches hybrides. Par exemple, on peut citer la '''prédiction mixte''' (''alloyed predictor''). Avec celle-ci, la table des compteurs à saturation est adressée avec la concaténation : de bits de l'adresse du branchement, du registre d'historique global et du registre d'historique local adressé par l'adresse du branchement. [[File:Prédiction mixte.jpg|centre|vignette|upright=2|Prédiction mixte.]] Une version optimisée de ce circuit permet d’accéder à la PHT et à la BHT en parallèle. Avec elle les bits de poids faible de l'adresse du branchement sont concaténés avec l'historique global. Le résultat est ensuite envoyé à plusieurs PHT locales distinctes, en parallèle. Les différences PHT envoient leurs résultats à un multiplexeur, commandé par l'historique local, qui sélectionne le résultat adéquat. L'inconvénient de cette mise en œuvre est que le circuit est assez gros, notamment en raison de la présence de plusieurs PHT séparées. [[File:Alloyed predictor optimisé.jpg|centre|vignette|upright=2|''Alloyed predictor'' optimisé]] ===La mitigation des interférences par la structuration en caches des PHTs=== Les techniques précédentes utilisent les bits de poids faible de l'adresse pour sélectionner la PHT adéquate, ou pour sélectionner le compteur dans la PHT. Mais le reste de l'adresse n'est pas utilisé. Cependant, il est théoriquement possible de conserver les bits de poids fort, afin d'identifier le branchement associé à un compteur. Pour cela, chaque compteur à saturation se voit associé à un ''tag'', qui mémorise le reste de l'adresse du branchement associé au compteur, comme pour les mémoires caches. Lorsque l'unité de prédiction démarre une prédiction, elle récupère les bits de poids fort de l'adresse du branchement et compare ceux-ci avec le ''tag''. S’il y a un ''match'', c'est signe que le compteur est bien associé au branchement à prédire. Dans le cas contraire, c'est signe qu'on fait face à une situation d’''aliasing'' et le compteur n'est pas utilisé pour la prédiction. Le résultat transforme la PHT en une sorte de cache assez particulier, qui associe un compteur à saturation à chaque couple adresse-historique. La technique du paragraphe précédent peut être encore améliorée afin de réduire l’''aliasing''. L’''aliasing'' sur une unité de prédiction de branchement est similaire aux conflits d'accès au cache, où deux données/adresses atterrissent dans la même ligne de cache. Une PHT peut formellement être vue comme un cache directement adressé. Une solution pour limiter ces conflits d'accès à de tels, est de les transformer en un cache associatif à n voies. On peut faire la même chose avec les unités de prédiction de branchement, ce qui revient à dupliquer les PHT, de manière à ce que les bits de poids faible de l'adresse se voient attribuer à plusieurs compteurs à saturation, un par branchement possible. Ce faisant, on réduit les interférences entre branchements. Mais cette technique augmente la quantité de circuits utilisés et la consommation d'énergie, ainsi que le temps d'accès, sans compter qu'elle requiert d'utilisation de ''tags'' pour fonctionner à la perfection. Pour les unités de prédiction de branchement, les mesures à ce sujet ne semblent pas montrer un impact réellement important sur les performances, comparé à une organisation de type directement adressé. Il est possible de pousser la logique encore plus loin en ajoutant des caches de victime et d'autres mécanismes courant sur les caches usuels. ===La mitigation des interférences liées aux branchements biaisés=== L’''aliasing'' a un impact négatif quand deux branchements aliasés (qui sont attribués au même compteur à saturation) vont dans des sens différents : si l'un est non-pris, l'autre a de bonnes chances d'être pris (ou inversement). Or, la plupart des branchements sont biaisés, à savoir très fortement orientés dans une direction qu'une autre (presque toujours pris ou presque toujours non-pris, à plus de 90/99% de chances). Plus un branchement a une probabilité élevée d' être pris ou non-pris, plus sa capacité à interférer avec les autres est forte. Le pire des cas est quand deux branchements sont biaisés mais dans des directions inverses : l'interférence est maximale. En conséquence, les branchements biaisés ou quasi biaisés sont les plus problématiques pour l’''aliasing''. Or, il est possible de concevoir des prédicteurs de branchements qui atténuent fortement les interférences des branchements biaisés ou quasi biaisés. C’est le cas de l’''agree predictor'' et de l'unité de prédiction bimodale, que nous allons voir dans ce qui suit. Les branchements très souvent pris vont interférer très fortement avec les branchements très souvent non-pris, et inversement. Par contre, les branchements très souvent pris n'interféreront pas entre eux, de même que les branchements non-pris iront bien ensemble. D'où l'idée de séparer ces deux types de branchements dans des PHT séparées, pour éviter le mauvais ''aliasing''. L''''unité de prédiction bimodale''' part d'une unité gshare, et sépare la PHT globale en deux : une PHT dédiée aux branchements qui sont très souvent pris et une autre pour les branchements très peu pris. Les deux PHT fournissent chacune une prédiction. La bonne prédiction est choisie par un multiplexeur commandé par une unité de sélection, qui se charge de déduire quelle unité a raison. Cette unité de sélection est une unité de prédiction basée sur des compteurs à saturation de 2 bits. On peut encore améliorer l'unité de sélection de prédiction en n'utilisant non pas des compteurs à saturation, mais une unité de prédiction dynamique à deux niveaux, ou toute autre unité vue auparavant. [[File:Prédiction bimodale.jpg|centre|vignette|upright=2|Prédiction bimodale.]] La '''prédiction par consensus''' (''agree predictor'') combine une unité de prédiction à historique global (idéalement de type gshare ou gselect, mais une unité normale marche aussi) et des compteurs à saturation. Il s'agit techniquement d'une technique de prédiction mixte, à la fois globale et locale. Le BTB intègre un compteur à saturation de un bit, qui indique la direction privilégiée du branchement. L'unité à historique global a un fonctionnement changé : elle calcule non pas la probabilité que le branchement soit pris ou non-pris, mais la probabilité que le résultat du branchement soit compatible avec le résultat de l'unité de prédiction de 1 bit. La prédiction finale vérifie que ces deux circuits sont d'accord, en faisant un NXOR entre les résultats des deux unités de prédiction de branchement. [[File:Prédiction par consensus.png|centre|vignette|upright=2|Prédiction par consensus.]] Des calculs simples de probabilités montrent que l'''agree predictor'' a des résultats assez importants. Ses résultats sont d'autant meilleurs que les branchements sont biaisés et penchent fortement vers le côté pris ou non-pris. ==La prédiction de branchement avec des perceptrons== Les méthodes précédentes utilisent au moins un compteur à saturation par historique possible. Sachant qu'il y a 2^n valeurs possibles pour un historique de n bits, le nombre de compteurs à saturation augmente exponentiellement avec la taille de l'historique. Ce qui limite grandement la taille de l'historique, alors qu'on aurait au contraire besoin d'un historique assez large pour faire des prédictions excellentes. Pour contourner le problème, des alternatives basées sur des techniques d'apprentissage artificiel (''machine learning'') existent. Un exemple est celui des unités de prédiction de branchement dites de ''neural branch prediction'', basée sur des perceptrons. Dans ce qui suit, nous les appelerons des '''unités perceptron'''. Les perceptrons sont un algorithme d'apprentissage automatique très simple, ce qui fait que les explications qui vont suivre devraient être simples. En théorie, il est possible d'utiliser des techniques d'apprentissage automatique plus élaborées, mais cela n'est pas possible en pratique. Rappelons qu'une unité de prédiction de branchement doit idéalement fournir sa prédiction en un cycle d'horloge. L'implémentation d'un perceptron est déjà problématique de ce point de vue, car elle demande d'utiliser beaucoup de circuits arithmétiques complexes (multiplication et addition). Autant dire que les autres techniques de ''machine learning'', encore plus gourmandes en calculs, ne sont pas praticables. ===Les perceptrons et leur usage en tant qu'unité de prédiction de branchements=== L'idée derrière les unités perceptron est que certains bits de l'historique sont plus importants que d'autres. En soi, ce genre de situation est réaliste. Il arrive que certains branchements soient pris uniquement si les deux branchements précédents ne le sont pas, ou que si l'avant-dernier branchement est lui aussi pris. Mais les autres résultats de branchement présents dans l'historique ne sont pas utiles. En conséquence, deux historiques semblables, qui ne sont différents que d'un ou deux bits, peuvent donner des prédictions presque identiques. Dans ce cas, on peut faire la prédiction en n'utilisant que les bits identiques entre ces historiques, ou du moins en leur donnant plus d'importance qu'aux autres. On exploite alors des corrélations avec certains bits de l'historique, plutôt qu'avec l'historique entier lui-même. Pour coder ces corrélations, on associe un coefficient à chaque bit de l'historique, qui indique à quel point ce bit est important pour déterminer le résultat final. Ce coefficient est proportionnel à la corrélation entre le bit de l'historique associé, et le résultat du branchement à prédire. Les coefficients sont des nombres entiers qui peuvent être positifs, nuls ou négatifs. Un coefficient positif signifie que si le branchement associé a été pris, alors le branchement à prédire a de bonnes chances de l'être aussi (et inversement). Un coefficient nul signifie que les deux branchements sont indépendants et que le bit de l'historique associé n'est pas à prendre en compte. Pour un coefficient négatif, cela signifie que si le branchement associé a été pris, alors le branchement à prédire a de bonnes chances d'être non-pris (et inversement). Les coefficients en question sont généralement compris entre -1 et 1. Une fois qu'on connait ces coefficients de corrélations, on peut calculer la probabilité que le branchement à prédire soit pris, avec des calculs arithmétiques simples. Dans les calculs qui vont suivre, un branchement pris est codé par un 1, alors qu'un branchement non-pris est codé par un -1 ! Ce détail permet de simplifier grandement les calculs. Dans sa version la plus simple, le perceptron calcule un nombre Z qui est positif ou nul si le branchement est pris, négatif s'il ne l'est pas. Tous les coefficients <math>p_i</math> sont alors des nombres relatifs, pouvant être positifs, nuls ou négatifs. Ils sont généralement compris entre -1 et 1. La formule devient donc celle-ci : : <math>Z = w_0 + \sum_i (h_i \times w_i)</math> [[File:ArtificialNeuronModel francais.png|centre|vignette|upright=2|Perceptron.]] : Sur le plan mathématique, le perceptron effectue un produit scalaire entre deux vecteurs : l'historique et le vecteur des poids. Choisir les coefficients adéquats est le but de l'algorithme du perceptron. L'unité de prédiction part de poids par défaut, qui sont mis à jour à chaque bonne ou mauvaise prédiction. Toute la magie de cet algorithme tient dans la manière dont sont mis à jour les poids. L'idée est de prendre le vecteur des poids, puis d'ajouter ou soustraire l'historique suivant le résultat du branchement. Les poids évoluent ainsi à chaque branchement, améliorant leurs prédictions d'une exécution à l'autre. La règle de mise à jour des poids est donc la suivante : : <math>w_i \leftarrow w_i + t \times h_i</math>, avec t = 1 pour un branchement pris, -1 pour un branchement non-pris. ===L'implémentation matérielle des perceptrons et ses optimisations=== Les calculs réalisés par un perceptrons sont simples, juste des multiplications et des additions et cela se sent dans la conception du circuit. L'unité perceptron utilise un perceptron par entrée du BTB, un par branchement pour simplifier. Pour cela, on utilise une mémoire SRAM qui mémorise les poids de chaque perceptron. La SRAM est adressée par les bits de poids faible du ''program counter'', de l'adresse du branchement. Une fois les poids récupérés, ils sont envoyés à un circuit de calcul de la prédiction, composé de circuits multiplieurs suivis par un additionneur multi-opérande. Le circuit de mise à jour des poids récupère la prédiction, les poids du perceptron, mais aussi le résultat du branchement. La mise à jour étant une simple addition, le circuit est composé d'additionneurs/soustracteurs, couplés à des registres pour mémoriser la prédiction et les poids du perceptron en attendant que le résultat du branchement soit disponible. Vu qu'il y a un délai de quelques cycles avant que le résultat du branchement soit disponible et que les prédictions doivent continuer pendant ce temps, les poids du perceptron et la prédiction sont stockés dans des FIFOs lues/écrites à chaque cycle. [[File:Unité de prédiction de branchement basée sur des perceptrons.png|centre|vignette|upright=2|Unité de prédiction de branchement basée sur des perceptrons]] Rien de bien compliqué sur le principe, mais rappelons que le perceptron doit fournir un résultat en moins d'un cycle d'horloge, et cela tient compte de l'accès au BTB. C'est un temps très court, surtout pour un circuit qui implique des multiplications. Les multiplications prennent facilement deux à trois cycles d'horloge. Autant dire que si on ajoute le temps d'accès à une petite SRAM, pour récupérer le perceptron, c'est presque impossible. Mais quelques optimisations simples permettent de rendre le calcul plus rapide. Premièrement, la taille de la SRAM est réduite grâce à quelques économies assez simples. Notamment, le perceptron utilise des poids codés sur quelques bits, généralement un octet, parfois 7 ou 5 bits, guère plus. Les poids sont encodés en complément à 1 ou en complément à 2, là où les perceptrons normaux utilisent des nombres flottants. Rien que ces deux choix simplifient les circuits et les rendent plus rapides. Le désavantage d'utiliser peu de bits par poids est une perte mineure en termes de taux de prédiction, qui est plus que compensée par l'économie en portes logiques, qui permet d'augmenter la taille de la SRAM. D'autres optimisations portent sur le circuit de calcul. Par exemple, la multiplication <math>h_i \times w_i</math> est facultative. Vu que l'historique contient les valeurs 1 et -1, il suffit d'additionner le poids associé si la valeur est 1 et de le soustraire s'il vaut -1. On peut même simplifier la soustraction et se limiter à une complémentation à un (une inversion des bits du poids). En faisant cela, les circuits multiplieurs disparaissent et le circuit de prédiction se résume à un simple additionneur multi-opérande couplé à quelques inverseurs commandables. Certains perceptrons prennent en compte à la fois l'historique global et l'historique local pour faire leur prédiction. Les deux historiques sont simplement concaténés et envoyés en entrée du perceptron. Cela marche assez bien, car les perceptrons peuvent utiliser des historiques de grande taille sans problèmes. Par contre, il y a besoin d'ajouter une ''branch history table'' au circuit, ce qui demande des portes logiques en plus, mais aussi un temps de calcul supplémentaire (l'accès à cette table n'est pas gratuit). ===Les avantages et inconvénients des perceptrons pour la prédiction de branchements=== L'avantage des unités perceptron est qu'elles utilisent un nombre de portes logiques qui est proportionnel à la taille de l'historique, et non pas exponentiel. Cela permet d'utiliser un historique de grande taille, et donc d'obtenir un taux de bonnes prédictions très élevé. Leur désavantage est que leurs performances de prédiction sont moins bonnes à historique équivalent. Les unités perceptrons ont des taux de prédiction élevés, mais seulement quand certaines conditions mathématiques sont respectées par les historiques. La condition en question est que les historiques correspondants à un branchement pris et les historiques où ce même branchement est non-pris doivent être linéairement séparables. Heureusement, les branchements des programmes informatiques sont une majorité à respecter la condition de séparation linéaire, ce qui rend les perceptrons parfaitement adaptés à la prédiction de branchements. La notion de linéairement séparable est un peu difficile à comprendre. Pour l'expliquer, imaginez que les N bits de l'historique forme un espace à N-dimensions discrets. Chaque historique est un point dans cet espace N-dimensionnel et chaque bit sert de coordonnée pour se repérer dans cet espace. Une fonction est linéairement séparable si l'espace N-dimensionnel peut être coupé en deux par un hyperplan : d'un côté de ce plan se trouvent les historiques des branchements pris, de l'autre se trouvent les historiques des branchements non-pris. Cet hyperplan est défini par l'ensemble des points qui respecte l'équation suivante : : <math>w_0 + \sum_i (h_i \times w_i) = 0</math> Un exemple où cette condition est respectée est quand le résultat d'une prédiction se calcule avec un ET entre plusieurs branchements de l'historique. Si un branchement est pris quand plusieurs branchements de l’historique sont tous pris ou tous non-pris, la condition est respectée et le perceptron donnera des prédictions correctes. Un exemple où cette condition n'est pas respectée est un banal XOR. Prenons l'exemple d'un historique global de 2 bits. Imaginons que le branchement à prédire soit pris : soit quand le premier branchement de l'historique est pris et le second non-pris, soit quand le premier est non-pris et le second pris. Concrètement, la prédiction exacte se calcule en faisant un XOR entre les deux bits de l'historique. Dans ce cas, la condition "linéairement séparable" n'est pas respectée et le perceptron n'aura pas des performances optimales. Les unités perceptron utilisent idéalement un perceptron par branchement. Cela demande d'associer, l'adresse du branchement pour chaque perceptron, généralement en donnant un perceptron par entrée du BTB. Mais une telle organisation n'est pas possible en pratique, car elle demanderait d'ajouter un ''tag'' à chaque perceptron/entrée du BTB, ce qui demanderait beaucoup de circuits et de ressources matérielles. Dans les faits l'association entre un branchement et un perceptron se fait en utilisant les bits de poids faible de l'adresse de branchement. Mais faire cela entrainera l'apparition de phénomènes d'''aliasing'', comme pour les unités de prédiction à deux niveaux. Les techniques vues précédemment peuvent en théorie être adaptées sur les unités à perceptrons, dans une certaine mesure. Notons que les perceptrons sont des algorithmes ou des circuits qui permettent de classer des données d'entrée en deux types. Ici, la donnée d'entrée est l'historique global et la sortie du perceptron indique si le branchement prédit est pris ou non-pris. Ils ne sont donc pas adaptés à d'autres tâches de prédiction, comme pour le préchargement des lignes de cache ou la prédiction de l'adresse de destination d'un branchement, ou toute autre tâche d'exécution spéculative un tant soit peu complexe. Leur utilisation reste cantonnée à la prédiction de branchement proprement dit, guère plus. Par contre, les autres techniques vues plus haut peuvent être utilisées pour le préchargement des lignes de cache, ou d'autres mécanismes de prédiction plus complexes, que nous aborderons dans la suite du cours. ==La prédiction avec plusieurs unités de branchement distinctes== Il est possible de combiner plusieurs unités de prédiction de branchement différentes et de combiner leurs résultats en une seule prédiction. On peut par exemple combiner une unité de prédiction spécialisée dans les boucles, une unité de prédiction 2 bits basée sur des compteurs à saturation, une unité de prédiction statique et une unité ''gshare''. Tout le problème est de décider quelle unité a fait la bonne prédiction, comment combiner les résultats des différentes unités de prédiction. Soit on choisit un résultat qui parait plus fiable que les autres, soit on combine les résultats pour faire une sorte de moyenne des prédictions. La méthode la plus simple de choisir le résultat d'une unité de prédiction parmi toutes les autres. Pour implémenter le tout, il suffit d'un multiplexeur qui effectue le choix de la prédiction. Reste à commander le multiplexeur, ce qui demande un '''circuit méta-prédicteur''' qui sélectionne la bonne unité de prédiction. Il arrive que le circuit méta-prédicteur soit conçu avec les mêmes techniques que les unités de prédiction de branchement elle-mêmes. Dans le cas le plus simple, il s'agit d'un simple compteur à saturation, mis à jour suivant la concordance des prédictions avec le résultat effectif du branchement. Mais c'est loin d'être systématique. Une autre solution combine le résultat des différentes unités de prédiction avec une fonction mathématique adéquate. Avec un nombre impair d'unités de prédiction, une méthode assez efficace est de simplement prendre le résultat majoritaire. Si une majorité d'unités pense que le branchement est pris, alors on le considère comme pris, et comme non pris dans le cas contraire. Par contre, avec un nombre pair d'unités, cette méthode simple ne marche pas, il y a un risque d'égalité. L'unité de prédiction de branchement « '''''e-gskew''''' » utilise trois unités gshare et combine leurs résultats avec un vote à majorité. Les trois unités gshare utilisent des XOR légèrement différents, dans le sens où les bits de l'adresse du branchement choisi ne sont pas les mêmes dans les trois unités, sans compter que le résultat du XOR peut subir une modification qui dépend de l'unité de prédiction choisie. Autrement dit, la fonction de hachage qui associe une entrée à un branchement dépend de l'unité. [[File:Unité de prédiction « e-gskew ».png|centre|vignette|upright=2|Unité de prédiction « e-gskew ».]] Enfin, une dernière solution est que certaines unités de prédiction ont la priorité sur toutes les autres. Par exemple, prenons un processeur avec un ''return stack predictor'', une unité qui détecte les boucles et une unité de prédiction avec un historique. Si le ''return stack predictor'' détecte une instruction de retour de fonction, sa prédiction passera avant toutes les autres unités de prédiction. Et dans ce qui va suivre, c'est cette solution qui sera utilisé pour les unités de prédiction qu'on va étudier. ===La prédiction des branchements des boucles=== Les boucles sont à part, pour ce qui est de la prédiction de branchement. Rappelons qu'une boucle FOR/WHILE répète une série d'instruction N fois, le nombre N étant appelé le '''compteur de boucle'''. Ce qui fait que les branchements d'une boucle FOR sont pris n − 1 fois, la dernière exécution étant non prise. La conséquence est que les unités de prédiction usuelles sont assez mauvaises pour prédire les branchements des boucles. Par contre, la prédiction statique marche particulièrement bien comparé au reste. La prédiction statique prédit que les branchements des boucles sont toujours pris, ce qui donne des prédictions qui sont d'autant meilleures que la boucle est répétée un grand nombre de fois. Reste à gérer les branchements des boucles au mieux, en sachant cela. L'idée est d'utiliser plusieurs unités de prédiction séparées : une spécialisée dans les boucles et d'autres unités de prédiction. Uen unité de méta-prédiction se charge d'activer l'unité de prédiction des boucles dès qu'une boucle est détéctée. Les unités de prédiction modernes essayent de détecter les branchements des boucles afin de les mettre à part. En soi, ce n'est pas compliqué les branchements des boucles sont presque toujours des branchements descendants et réciproquement. De plus, ces branchements sont pris à chaque répétition de la boucle, et ne sont non-pris que quand on quitte la boucle. Le compteur de boucle analyse l'exécution des branchements, pour détecter les branchements des boucles. Un branchement est considéré comme un branchement de boucle si : il est pris N fois, avant de ne pas être pris une fois. Au moment où le branchement n'est plus pris, le ''Loop Predictor'' lui attribue une entrée dédié. L'entrée mémorise mémorise le nombre de fois que ce branchement a été pris, à savoir N, et est associée à un compteur. Le compteur est incrémenté/décrémenté à chaque exécution du branchement, et sa valeur est comparée à la valeur N. Quand le compteur atteint N+1, il est réinitialisé. Il s'agit d'une implémentation simple, mais est visiblement proche de celle utilisée sur le Pentium M, si j'en crois l'article "The Intel Pentium M Processor : Microarchitecture and Performance". Il faut noter que la boucle doit s'exécuter au moins une fois pour être détectée, et seules les exécutions suivantes sont prédites correctement. La boucle doit donc s'exécuter plusieurs fois, ce qui implique qu'elle est elle-même dans une boucle plus grande qu'elle. Une telle unité est donc capable de prédire spécifiquement les branchements de boucles FOR imbriquées, à savoir où une boucle FOR est dans une autre boucle FOR. Concrètement, cela permet de répéter la première boucle FOR plusieurs fois de suite, souvent un grand nombre de fois. Avec les boucles imbriquées, le compteur de boucle peut changer d'une répétition à l'autre, mais ce n'est que rarement le cas. Dans de nombreux cas, le compteur de boucle reste le même d'une répétition à l'autre. Ce qui fait qu'une fois la boucle exécutée une première fois, on peut prédire à la perfection les exécutions suivantes. Pour cela, il suffit de déterminer le compteur de boucle après la première exécution de la boucle, puis de le mémoriser dans un compteur. Lors des exécutions suivantes de la boucle, on compte le nombre de fois que le branchement de la boucle s'exécute : il est prédit comme pris tant que le compteur est différent du compteur de boucle, mais non-pris en cas d'égalité. ===Le prédicteur L-TAGE=== Il est possible d'utiliser plusieurs unités de prédiction, chacune utilisant des historiques de taille différente. Pour donner un exemple simple, on pourrait avoir une unité avec un historique de 2 bits, une autre avec un historique de 3 bits, une autre de 4 bits, etc. Une méta-unité de branchement prédit alors quelle taille d'historique donne les meilleurs résultats. Il est fréquent que les tailles d'historiques utilisées suivent une formule mathématique précise, appelée suite géométrique, ce qui est un mot bien barbare pour dire que l'on passe d'un historique au suivant en multipliant sa taille par une constante bien précise. Par exemple, on pourrait avoir des historiques de taille 2, 4, 8, 16, 32. C'est d'ailleurs le principe derrière les '''unités de branchement TAGE (''TAgged GEometric length predictor'')'''. Elles utilisent plusieurs registres d'historiques, chacun avec sa propre PHT, dont la taille suit une suite géométrique. Avec cette méthode, les branchements qui ont un motif répétitif sont alors parfaitement détectés, peu importe sa taille. Par exemple, un branchement avec un historique dont le cycle est pris, non-pris, non-pris, sera parfaitement prédit avec un historique de 3 bits, 6 bits, ou de 3*n bits, mais pas forcément avec un historique de 4 ou 8 bits. De ce fait, utiliser plusieurs unités de branchement avec plusieurs tailles d'historique fonctionne plutôt bien. Le prédicteur TAGE a été raffiné avec le temps, sur plusieurs points, que je ne vais pas détailler dans ce qui suit. Précisons cependant que les PHT sont structurées en caches, afin d'éliminer les phénomènes d'''aliasing''. L'ajout d'une unité de prédiction pour les boucles, a donné le '''prédicteur L-TAGE''', qui a lui-même été amélioré par la suite. Les unités de branchement TAGE sont les plus utilisées au moment où j'écris ces lignes (2026), et la majorité des processeurs x86 commerciaux utilisent une variante de ce prédicteur, à quelques exceptions près. ===La prédiction de branchement à deux niveaux=== Les processeurs modernes utilisent une prédiction de branchement à deux niveaux. Une première unité de branchement fournit des résultats rapides et peu précis, la seconde a des résultats plus précis mais fournit ses résultats après quelques cycles d'horloge. La première unité envoie les adresses prédites au cache d'instruction, pour charger les instructions prédites et met à jour le ''program counter'' en conséquence. La seconde envoie les adresses prédites à l'étage de décodage d'instruction, histoire d'annuler des branchements considérés à tort comme pris/non-pris. Il y a donc une unité de prédiction rapide, dont le résultat est corrigé par une seconde unité. La seconde unité a le dernier mot, elle corrige ou annule les prédictions de la première. Ce qui lui vaut le nom, en anglais, d'''overiding predictor''. La correction se fait simplement en corrigeant le ''program counter'' et en forçant le décodeur à émettre des NOPs pendant quelques cycles, le temps d'éliminer les instructions chargées à tort. [[File:Double BTB sur les CPU modernes.png|centre|vignette|upright=2|Double BTB sur les CPU modernes.]] Les deux unités de prédiction n'utilisent pas le même algorithme. Par exemple, la microarchitecture Silvermont d'Intel utilisait deux unités de prédiction : une unité rapide basée sur des compteurs à saturation, et un prédicteur ''gshare'' lent. Les deux unités de prédiction utilisent des BTB séparés, de taille différente, au moins deux. Vu que les BTB sont des caches, on les appelle des '''BTB de niveau L1 et L2'''. Par exemple, les processeurs Bulldozer d'AMD disposent de deux BTBs : un BTB L1 de 512 entrées, et un BTB L2 de 5120 entrées. Le BTB de niveau L2 est souvent un cache de victime, qui mémorise les branchements retirés de la BTB de niveau L1. La BTB pour les branchements indirects vient en plus de ces deux BTBs, elle complète souvent la BTB de niveau L2. Le ''return stack predictor'' vient en plus des BTB L1 et L2, mais sa petite taille et sa rapidité font qu'il complète l'unité/BTB de niveau L1. Quelques processeurs ont trois niveaux de BTB, avec un niveau L0, L1 et L2. La BTB L0 aliment le cache d'instruction, alors que la L1 et la L2 préviennent les décodeurs d'instruction. La différence entre la L1 et la L2 est que la L2 met plus de temps à prévenir les décodeurs. Les branchements peuvent donc être annulés/forcés à deux étages de décodage différents. Pour donner quelques exemples, les processeurs Zen d'AMD disposent de plusieurs BTBs, avec des niveaux L0, L1 et L2. Le tableau ci-dessous vous donne la taille et la latence de chaque niveau de BTB. La BTB de niveau L0 alimente le cache d'instruction, la L1 permet d'annuler les instructions au premier cycle de décodage, la BTB L2 annule les instructions vers le milieu/fin du décodage. {|class="wikitable" |- ! rowspan="3" | Zen 2 | 16 || 0 |- | 512 || 1 |- | 7168 || 4 |- ! rowspan="3" | Zen 3 | 16 || 0 |- | 1024 || 1 |- | 6656 + 1536 (branchements indirects)|| 4 |} ==Les optimisations de la prédiction de branchements== La prédiction de branchement est complexe et toute économie est bonne à prendre. Pour améliorer les performances, ou simplement améliorer l'utilisation du BTB et des PHT, diverses techniques ont été inventées. Ces techniques marchent quel que soit l'unité de prédiction prise en compte. Elles peuvent s'appliquer aussi bien aux unités basées sur des perceptrons, que sur des unités à deux niveaux ou des unités de prédiction dynamique en général. Ces techniques sont assez variés : certaines profitent du fait que de nombreux branchements sont biaisés, d'autres tentent de réduire les interférences entre branchements sans utiliser l'historique global/local, etc. ===Le filtrage de branchements=== Une première optimisation permet d'économiser l'usage des différentes ressources matérielles, comme la BTB ou les PHT, en les réservant aux branchements non-biaisés. Idéalement, les branchements biaisés peuvent être prédits en utilisant des techniques très simples. D'où l'idée d'utiliser deux unités de prédiction spécialisées : une pour les branchements biaisés et une autre plus complexe. L'unité de prédiction pour les branchements biaisés est une unité de prédiction à 1 bit qui indique si le branchement est pris ou non, ce qui suffit largement pour de tels branchements. Cette technique s'appelle le '''filtrage de branchement''' et son nom est assez parlant. On filtre les branchements biaisés pour éviter qu'ils utilisent des PHT et d'autres ressources qui gagneraient à être utilisées pour des branchements plus difficiles à prédire. Appliquer cette idée demande de reconnaître les branchements fortement biaisés d'une manière ou d'une autre, ce qui est plus facile à dire qu'à faire. Une solution similaire enregistre quels branchements sont biaisés dans le BTB, et utilise cette information pour faire des prédictions. ===L'usage de fonctions de hashage pour indexer les diverses SRAM/tables=== Plus haut, nous avons vu que les unités de prédiction de branchement contiennent des structures qui sont adressées par l'adresse de branchement. Tel est le cas du ''Branch Target Buffer'', de certaines PHT globales ou locales, de la ''Branch History Table'' qui stocke les historiques locaux, ou encore de la SRAM des poids des unités à base de perceptrons. En pratique, ces structures sont adressées non pas par l'adresse de branchement complète, mais pas les bits de poids faible de cette adresse. Cela a pour conséquence l'apparition d'un ''aliasing'' lié au fait que ces structures vont confondre deux branchements pour lesquels les bits de poids faible de l'adresse sont identiques. Il est possible d'utiliser autre chose que les bits de poids faible de l'adresse du branchement, afin de limiter les interférences. Et les possibilités sont multiples. Les possibilités en question s'inspirent des traitements effectués sur les adresses des banques dans les mémoires évoluées. Une première solution serait de faire un XOR entre les bits de poids faible de l'adresse du branchement, et d'autres bits de cette même adresse. Ainsi, deux branchements éloignés en mémoire donneraient des résultats différents, même si leurs bits de poids faible sont identiques. Une autre possibilité serait de diviser l'adresse du branchement par un nombre et de garder le reste. Ce calcul de modulo n'est en soi pas très différent du fait de conserver seulement les bits de poids faible. Conserver les N bits de poids faible consiste en effet à prendre le modulo par 2^N de l'adresse. Ici, l'idée serait de faire un modulo par un nombre P, qui serait un nombre premier proche de 2^N. Le fait que le nombre soit premier limite les cas où deux adresses différentes donneraient le même reste, ce qui réduit l'''aliasing''. Le fait de prendre un nombre proche de 2^N pour une entrée de N bits est que le résultat reste assez proche de ce qu'on obtiendrait en gardant les bits de poids faible, cette dernière étant une solution pas trop mauvaise. D'autres possibilités similaires se basent sur des réductions polynomiales ou d'autres astuces impliquant des nombres premiers. L'efficacité de ces méthodes dépend grandement de la taille de la SRAM/table considérée, des adresses des branchements et de beaucoup d'autres paramètres. La réduction des interférences par telle ou telle méthode dépend aussi de l'unité de prédiction considérée. Les résultats ne sont pas les mêmes selon que l'on parle d'une unité à perceptrons ou d'une unité à deux niveaux ou d'unités à base de compteurs à saturation, ou que l'on parle du BTB. Par contre, toutes les méthodes ne sont pas équivalentes en termes de temps de calcul ou de portes logiques. Autant la première solution avec un XOR ajoute un temps de calcul négligeable et quelques portes logiques, autant les autres méthodes requièrent l'ajout d'un diviseur très lent et gourmand en portes logiques pour calculer des modulos. Elles ne sont généralement pas praticables pour ces raisons, le temps de calcul serait trop élevé. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Le pipeline | prevText=Le pipeline | next=Les optimisations du chargement des instructions | nextText=Les optimisations du chargement des instructions }} </noinclude> o1v61n26npqqfkshgmwai8vqrp3jc50 774407 774406 2026-10-07T22:11:51Z Mewtow 31375 /* La prédiction de destination pour les branchements indirects */ 774407 wikitext text/x-wiki Les branchements sont pris en charge par plusieurs étages du pipeline. Exécuter un branchement demande de savoir deux choses : s'il est pris ou non, quelle est l'adresse de destination. L'adresse de destination est localisée grâce au mode d'adressage du branchement. Avec le mode d'adressage direct, elle est extraite de l'instruction par le séquenceur. Avec l'adressage indirect, elle est lue dans un registre. Avec les branchements relatifs, elle est calculée via un additionneur, qui additionne un décalage au ''program counter''. L'additionneur peut être soit dans l'étage de ''PC Update'', soit dans l'unité de calcul de l'étage d'exécution. Passons maintenant au résultat du branchement, à savoir s'il est pris ou non. Pour savoir si le branchement est pris, il faut distinguer les branchements conditionnels et inconditionnels. Pour rappel, les branchements inconditionnels regroupent les appels de fonction, les simples instructions de saut, et quelques autres. Ils sont toujours pris, ils s'exécutent toujours. À partir du moment où le décodeur a décodé un branchement inconditionnel, il sait que le branchement est pris. Pour les branchements conditionnels, ils sont pris seulement si une condition précise est respectée. On sait s'ils sont exécutés une fois que la condition est calculée par l'unité de calcul, ou par une unité de branchement spécialisée. [[File:Gestion des branchements sur un pipeline.png|centre|vignette|upright=2.0|Gestion des branchements conditionnels sur un pipeline.]] Sur le schéma précédent, les branchements inconditionnels ne sont pas montrés. Pour cela, il faudrait rajouter une connexion du décodeur vers le multiplexeur de branchement. De plus, il faudrait aussi rajouter de quoi retarder l'exécution du branchement seulement une fois que l'adresse est connue. Par exemple, elle est connue un cycle d'horloge plus si l'adresse est lue depuis les registres, comparé à un branchement direct. ==Les branchements et les exceptions imprécises/précises== Un branchement est censé altérer le ''program counter'' immédiatement, en un cycle d'horloge. Mais sur un pipeline, Le branchement fait bien reprendre le processeur au bon endroit, mais avec du retard. La raison est qu'un branchement met plusieurs cycles pour s'exécuter. Il faut plusieurs cycles pour déterminer quelle est l'adresse de destination, temps qui dépend de là où est lue l'adresse. Et on sait si le branchement est pris seulement au niveau du décodeur, voire au niveau de l'unité de calcul. Le branchement doit traverser plusieurs étages avant qu'on connaisse son résultat. Il y a bien 3 à 4 cycles avant cela. Et pendant ce temps de trajet, des instructions seront chargées dans le pipeline, potentiellement à tort. Si le branchement n'est pas pris, ce n'est pas grave : les instructions n'ont pas été chargées à tort. Mais dans le cas contraire, les instructions n'auraient pas dû rentrer dans le pipeline. Le problème est similaire à celui observé avec les exceptions précises/imprécises. La différence est que les branchements sont des instructions machines, pas les exceptions matérielles. Mais le problème des instructions chargées à tort est le même dans les grandes lignes. Une différence est que les branchements sont résolus plus ou moins au même étage, alors que les exceptions peuvent survenir dans tout le pipeline. Une autre différence, bien plus importante, est que les branchements non-pris ne chargent pas d'instructions à tort et qu'il faut faire la différence entre branchement pris et non-pris. Dans la suite, nous parlerons d'instructions fautives pour parler des instructions chargées à tort. Pour corriger ce problème, il existe une solution logicielle. Le compilateur fait suivre les branchements par des instructions qui ne font rien, des NOP (No OPeration) : c'est ce qu'on appelle un '''délai de branchement'''. L'idée est que toutes les instructions chargées à tort sont des NOPs, qui ne font rien. Mais le nombre de NOP à ajouter dépend du pipeline du processeur, ce qui peut poser des problèmes de compatibilité. Mais d'autres solutions, matérielles cette fois, sont utilisées de nos jours. ===L'annulation des instructions au décodage=== Les branchements inconditionnels sont dans un cas plus favorable : ils sont identifiés dans l'unité de décodage et sont toujours pris. D'autres branchements sont dans ce cas : les appels de fonctions, les interruptions logicielles. Avec eux, le décodeur annule les instructions fautives, il les remplace par des instructions NOP. L'étage de décodage sait que les instructions suivant un branchement inconditionnel ont été chargées à tort. Précisément N instructions, avec N le nombre d'étages entre le chargement et le décodage (inclus). Les N instructions suivantes sont tout simplement annulés, elles sont remplacées par des NOPs. L'implémentation demande juste un simple compteur et quelques circuits annexes. Cependant, la technique ne marche pas du tout pour les branchements conditionnels. En effet, on ne sait pas s'ils sont pris ou non à l'étage de décodage. Du moins pas avec un pipeline normal. Mais certains processeurs, basés sur le pipeline RISC classique, calculaient les branchements pendant l'étape de décodage, dans une ALU spécialisée qui travaille en parallèle de l'unité de décodage. Le défaut de cette technique est que l'ALU en question doit lire les registres. Pour rappel, sur ce pipeline, l'étape de décodage effectue la lecture des opérandes dans les registres, en parallèle de l'unité de décodage. Mais ce qu'on n'a pas dit dans le chapitre précédent, c'est qu'elle effectue aussi le calcul des branchements dans une mini-ALU séparée ! Ce faisant, le résultat du branchement est connu dès la fin de l'étape de décodage. ===L'usage du système d'exceptions précises pour les branchements conditionnels=== La solution gère le problème en empêchant les instructions chargées à tort d'enregistrer leurs résultats, afin qu'elles n'aient pas le moindre effet sur l'état du processeur. Le hardware pour les exceptions précises, décrit quelques paragraphes avant, est réutilisé pour cela. Les branchements conditionnels peuvent être pris ou non-pris et on doit tenir compte de ces deux cas. Si le branchement n'est pas pris, les instructions n'ont pas été chargées à tort, elles peuvent enregistrer leur résultat dans les registres. Par contre, ce n'est pas le cas pour les branchements pris, les mécanismes d'exceptions précise doivent alors d'activer. Pour cela, la meilleure solution est que l'unité de calcul génère un indicateur d'exception si le branchement n'est pas pris. L'ALU effectue la comparaison et génère ou non l'indicateur d'exception adéquat suivant le résultat de celle-ci. L'idée est d'utiliser un indicateur d'exception particulier, qui précise qu'un branchement a été pris, signe que des instructions ont été chargées à tort. L'indicateur est propagé dans le pipeline, jusqu'à atteindre l'étage d'enregistrement dans les registres. Cet étage décide s'il faut autoriser ou non les écritures qu'il recoit, pour annuler les instructions chargées à tort. Le ''program counter'' est corrigé immédiatement, simplement en exécutant le branchement. Reste à savoir qui génère cet indicateur d'exception de branchement pris. Diverses optimisations visent à éliminer les instructions chargées à tort, à faire en sorte que des instructions utiles soient chargées. Le chapitre suivant va détailler ces optimisations dans le détail, qui sont regroupées sous le terme de prédiction de branchement. Elles ne fonctionnent pas pour les exceptions précises, seulement les branchements. ===La prédiction de branchement=== Les techniques précédentes ont pour défaut que de nombreux cycles d'horloge sont gâchés à cause des instructions fautives. Pour éviter ce gâchis, les concepteurs de processeurs ont inventé l'''exécution spéculative de branchement'', qui consiste à deviner l'adresse de destination du branchement et charger les bonnes instructions dès le départ. Le but est de ne pas charger d'instruction à tort, en prédisant quelles seront les bonnes instructions à charger. Et cela demande de résoudre trois problèmes : * reconnaître les branchements ; * savoir si un branchement sera exécuté ou non : c'est la '''prédiction de branchement''' ; * dans le cas où un branchement serait exécuté, il faut aussi savoir quelle est l'adresse de destination : c'est la '''prédiction de l'adresse de destination''' d'un branchement, aussi appelée ''branch target prediction''. Pour résoudre le second problème, le processeur contient un circuit qui déterminer si le branchement est pris (on doit brancher vers l'adresse de destination) ou non pris (on poursuit l’exécution du programme immédiatement après le branchement) : c'est l''''unité de prédiction de branchement'''. La prédiction de direction de branchement est elle aussi déléguée à un circuit spécialisé : l’'''unité de prédiction de direction de branchement'''. C'est l'unité de prédiction de branchement qui autorise l'unité de prédiction de destination de branchement à modifier le ''program counter''. Dans certains processeurs, les deux unités sont regroupées dans le même circuit. [[File:Relation entre prédiction de branchement et de direction de branchement.png|centre|vignette|upright=2|Relation entre prédiction de branchement et de direction de branchement.]] ==Les erreurs de prédiction de branchement== Le processeur peut parfaitement se tromper en faisant ses prédictions, ce qui charge des instructions par erreur dans le pipeline. Dans ce cas, on parle d''''erreur de prédiction'''. Les erreurs de prédiction sont corrigées par le système d'exception précises, ou un équivalent. En clair, les instructions chargées à tort se propagent jusqu'à la sortie du pipeline, où leurs résultats sont alors annulés. N'importe quelle technique de gestion des exceptions précise fait le travail, mais les processeurs modernes utilisent un tampon de réordonnancement (''reorder buffer'') qu'on détaillera dans quelques chapitres. Détecter les erreurs de prédiction demande d'ajouter un circuit dans le processeur : l’''unité de vérification de branchement'' (''branch verification unit''). Elle génère un indicateur d'exception, qui est géré par le système d'exception précise. Pour générer l'indicateur, elle compare l'adresse de destination prédite et celle calculée en exécutant le branchement. Pour cela, l'adresse prédite doit être propagée dans le pipeline jusqu’à ce que l'adresse de destination du branchement soit calculée. [[File:Unité de vérification de branchement.png|centre|vignette|upright=2|Unité de vérification de branchement]] ===La pénalité de mauvaise prédiction=== Le temps pour vider le pipeline des instructions fautives dépend du pipeline. La dernière instruction à être chargée dans le pipeline le sera en même temps qu'on détecte l'erreur de prédiction. Il faudra attendre que cette instruction quitte le pipeline, et donc qu'elle traverse tous les étages. Il y a donc un temps d'attente pour collecter les résultats de ces instructions et la décision de ne pas enregistrer leurs résultats dans les registres Le nombre d'instructions chargées à tort dépend de où est détectée l'erreur de prédiction. Plus le résultat du branchement est connu tard, plus le nombre d'instructions chargées à tort sera grand, et plus le nombre de cycles d'horloge gâchés le sera aussi. Le nombre de cycles gâchés s'appelle la '''pénalité de mauvaise prédiction'''. Elle correspond précisément au nombre de cycles/étages entre le premier étage du pipeline, et l'étage où le branchement est résolu (on connait l'adresse à laquelle brancher). Plus celui-ci est éloigné du début du pipeline, plus la pénalité est importante. [[File:MIT 6.823 Computer System Architecture, Fall 2005 - P-3 vs. P-4 Pipelines - 4473425702.jpg|centre|vignette|upright=2.5|Comparaison de la pénalité de mauvaise prédiction entre le Pentium 3 et le Pentium 4 d'Intel.]] Elle est généralement d'autant plus longue que le pipeline lui-même est long. Les concepteurs de processeurs font donc face à un compromis : un pipeline long permet d'exécuter plus d'instructions en parallèle, mais cela augmente aussi la pénalité de mauvaise prédiction. Un pipeline de 10-15 étapes est un bon compromis. Les pipelines plus longs ont besoin d'une prédiction de branchement élaborée pour fonctionner correctement. Plus la prédiction de branchement est performante, plus le pipeline peut être long. Les pénalités de mauvaise prédiction seront plus importantes, mais les mauvaises prédictions seront plus rares, ce qui compense complétement. ===La ''minimal control dependency''=== En utilisant une technique du nom de ''minimal control dependency'', seules les instructions qui dépendent du résultat du branchement sont supprimées du pipeline en cas de mauvaise prédiction, les autres n'étant pas annulées. Sans ''minimal control dependency'', toutes les instructions qui suivent un branchement dans notre pipeline sont annulées. Pourtant, certaines d'entre elles pourraient être utiles. Prenons un exemple : supposons que l'on dispose d'un processeur de 31 étages (un Pentium 4 par exemple). Supposons que l'adresse du branchement est connue au 9éme étage. On fait face à un branchement qui envoie le processeur seulement 6 instructions plus loin. Si toutes les instructions qui suivent le branchement sont supprimées, les instructions en rouge sont celles qui sont chargées incorrectement. On remarque pourtant que certaines instructions chargées sont potentiellement correctes : celles qui suivent le point d'arrivée du branchement. Elles ne le sont pas forcément : il se peut qu'elles aient des dépendances avec les instructions supprimées. Mais si elles n'en ont pas, alors ces instructions auraient dûes être exécutées. Il serait donc plus efficace de les laisser enregistrer leurs résultats au lieu de les ré-exécuter à l’identique un peu plus tard. Ce genre de choses est possible sur les processeurs qui implémentent une technique du nom de ''Minimal Control Dependancy''. En gros, cette technique fait en sorte que seules les instructions qui dépendent du résultat du branchement soient supprimées du pipeline en cas de mauvaise prédiction. [[File:Minimal cntrol dependency.png|centre|vignette|upright=2|Minimal control dependency]] ==La reconnaissance des branchements== Pour prédire des branchements, le processeur doit faire la différence entre branchements et autres instructions, dès l'étage de chargement. Or, un processeur « normal », sans prédiction de branchement, ne peut faire cette différence qu'à l'étage de décodage, et pas avant. Les chercheurs ont donc dû trouver une solution. La première solution se base sur les techniques de prédécodage vues dans le chapitre sur le cache. Pour rappel, ce prédécodage consiste à décoder partiellement les instructions lors de leur chargement dans le cache d'instructions et à mémoriser des informations utiles dans la ligne de cache. Dans le cas des branchements, les circuits de prédécodage peuvent identifier les branchements et mémoriser cette information dans la ligne de cache. Une autre solution mémorise les branchements déjà rencontrés, dans un cache dédié. Ce cache mémorise l'adresse (le ''program counter'') des branchements déjà rencontrés : il s'appelle le '''tampon d’adresse de branchement''' (''branch adress buffer''). Il est intégré à l'unité de prédiction de branchement, mais est alimenté par le décodeur d'instruction. Reconnaitre un branchement demande l'implication du décodeur d'instruction, qui sait si l'instruction qu'il vient de décoder est un branchement ou non. Il peut même savoir si le branchement est conditionnel, inconditionnel, un appel de retour de fonction, etc. Lorsque le décodeur d'instruction reconnait un branchement, il ajoute ce branchement dans le cache Le tapon d'adresse de branchement contient une adresse mémoire par branchement, et éventuellement d'autres informations. Et pour comprendre quelle adresse est mémorisée, il faut faire attention à un détail. Les explications qui vont suivre vont faire intervenir deux adresses. * La première est l''''adresse de destination''' du branchement, à savoir l'adresse à laquelle le processeur doit reprendre son exécution si le branchement est pris. * L'autre adresse est l''''adresse du branchement''' lui-même. Elle correspond à sa position en mémoire RAM. L'adresse du branchement est la valeur du ''program counter'' quand le branchement est chargé dans le cache, alors que l'adresse de destination est celle du ''program Counter'' une fois le branchement exécuté. Il faudra faire bien attention à ne pas confondre les deux adresses dans ce qui suit. L'adresse du branchement est celle qui est utilisée pour la reconnaissance du branchement. Le tampon d'adresse de branchement mémorise, pour chaque branchement, son adresse de branchement (d'où son nom). À chaque cycle d'horloge, l'unité de chargement envoie le ''program counter'' en entrée du tampon d’adresse de branchement. Un succès dans ce cache indique qu'un branchement déjà rencontré est ré-executé, on peut utiliser la prédiction de branchement. Dans le cas contraire, la prédiction de branchement n'est pas utilisée, et l'instruction est chargée normalement. ==La prédiction de l'adresse de destination== La '''prédiction de l'adresse de destination d'un branchement''' détermine l'adresse de destination d'un branchement. Rappelons qu'il existe plusieurs modes d'adressage pour les branchements et que chacun d'entre eux précise l'adresse de destination à sa manière. Suivant le mode d'adressage, l'adresse de destination est soit dans l'instruction elle-même (adressage direct), soit calculée à l’exécution en additionnant un ''offset'' au ''program counter'' (adressage relatif), soit dans un registre du processeur (branchement indirect), soit précisée de manière implicite (retour de fonction, adresse au sommet de la pile). : Histoire de simplifier la terminologie, je parlerais parfois de '''prédiction de destination''' pour parler de la ''prédiction de l'adresse de destination d'un branchement''. Pour rappel, les branchements se classent en trois types : * les branchements directs pour lequel l'adresse de destination est toujours la même ; * les branchements indirects où l'adresse de destination est dans un registre et peut changer ; * les instructions de retour de fonction. Les branchements directs sont facilement prévisibles, vu que l'adresse vers laquelle il faut brancher est toujours la même. La prédiction des branchements directs et relatifs se fait globalement de la même manière, ce qui fait que nous ferons la confusion dans ce qui suit. Pour les instructions de retour de fonction, une prédiction parfaite est possible. Et c'est ce qui explique qu'elles sont traitées à part, par une unité de branchement dédiée. Pour les branchements indirects, vu que cette adresse change, la prédire celle-ci est particulièrement compliqué (quand c'est possible). Les branchements indirects sont eux aussi traités par une unité de prédiction dédiée rien que pour eux. ===La prédiction de destination pour les branchements directs=== Lorsqu'un branchement est exécuté, on mémoriser l'adresse de destination et la réutiliser lors d'exécutions ultérieures du branchement. Cette technique marche à la perfection pour les branchements directs, pour lesquels cette adresse est toujours la même, mais donne des résultats mitigés pour les branchements indirects. Pour mémoriser l'adresse de destination, on utilise un cache qui mémorise les correspondances entre l'adresse du branchement et l'adresse de destination. Ce cache est appelé le '''''branch target buffer''''' en anglais, qui sera abrévié en BTB dans ce qui suit. Le BTB est une amélioration du tampon d'adresse de branchement vu plus haut, auquel on aurait ajouté les adresses de destination des branchements. Le BTB est utilisé comme suit : on envoie en entrée l'adresse du branchement, le BTB répond au cycle suivant en précisant : s’il reconnaît l'adresse d'entrée, et quelle est l'adresse de destination si c'est le cas. Le BTB peut être un cache totalement associatif, associatif par voie, ou directement adressé, mais ces derniers sont les plus fréquents. Les BTB sont généralement des caches de type directement adressés, où seuls les bits de poids faible de l'adresse du branchement adressent le cache, le reste de l'adresse est tout simplement ignoré. Il existe aussi des BTB qui sont construits comme les caches associatifs à plusieurs voies, mais ceux-ci impliquent généralement la présence de ''tags'', ce qui fait qu'ils sont un peu moins fréquents. [[File:Branch target buffer directement adressé.png|centre|vignette|upright=2|Branch target buffer directement adressé.]] Comme pour tous les caches, un accès au BTB peut entraîner un défaut de cache, c’est-à-dire qu'un branchement censé être dans le BTB n'y est pas. Comme pour les autres caches, les défauts de cache peuvent se classer en trois types : les défauts de cache à froid (''cold miss'') causés par la première exécution d'un branchement, les défauts liés à la capacité du BTB/cache, et les défauts par conflit d'accès au cache. Les défauts liés à la capacité du BTB sont les plus simples à comprendre. La capacité limitée du BTB fait que d'anciens branchements sont éliminées pour laisser la place à de nouveaux. En conséquence, ces branchements peuvent donner des erreurs de prédiction si leur correspondance a été éliminée du cache. On peut réduire des défauts en augmentant la taille du BTB. Les défauts liés aux conflits d'accès au BTB sont eux beaucoup plus intéressants, car la conception du BTB est assez spéciale dans la manière dont sont gérés ce genre de défauts. Le BTB est un cache partiellement associatif, ce qui fait que des défauts de cache par conflit d'accès peuvent avoir lieu. Pour rappel, les défauts par conflit d'accès ont lieu quand deux adresses mémoires se voient attribuer la même ligne de cache. Pour un BTB, cela correspond au cas où deux branchements se voient attribuer la même entrée dans le BTB, ce qui porte le nom d’''aliasing''. Ne pas détecter ce genre de situation sur un cache normal entraînerait des problèmes : la donnée lue/écrite ne serait pas forcément la bonne. Les caches normaux utilisent un système de tags pour éviter ce problème et on s'attendrait à ce que les BTB fassent de même. Mais pour un BTB, l'absence de ''tags'' n'est pas un problème : la seule conséquence est une augmentation du taux de mauvaises prédictions, dont les conséquences en termes de performance peuvent être facilement compensées. Les BTB peuvent donc se passer de tags, ce qui a de nombreux avantages : le circuit est plus rapide, prend moins de portes logiques, consomme moins d'énergie, etc. Ces économies de portes logiques et de performances peuvent être utilisées pour augmenter la taille du BTB, ce qui compense, voire surcompense les mauvaises prédictions induites par l’''aliasing''. Mais ce n'est pas systématique : d'autres BTBs utilisent des ''tags'' pour améliorer le taux de prédiction. Nous ferons donc la différence entre '''BTB tagués''' et '''BTB non-tagués'''. Des processeurs se passent de BTB. À la place, ils mémorisent les correspondances entre branchement et adresse de destination dans les bits de contrôle du cache d'instructions. Mais nous verrons cela en détail dans le chapitre suivant. ===La prédiction de destination pour les branchements indirects=== Un BTB classique peut fonctionner pour les branchements indirects. Son seul défaut est qu'il mémorisera la dernière adresse de destination prise par le branchement. Et cela peut fonctionner pour les branchements indirects fortement biaisés, qui ont presque toujours la même adresse de destination. Mais la majorité des branchements indirects change d'adresse de destination régulièrement. Tous les processeurs commerciaux datant d'avant le Pentium M utilisaient un BTB normal pour prédire les branchements indirects. Les processeurs qui les ont suivi sont capables de prédire l'adresse de destination d'un branchement indirect. , grâce à une '''unité de prédiction des branchements indirects''', séparée de l'unité de prédiction de branchement normale. Elle utilise un BTB amélioré, qui mémorise plusieurs adresses de destination pour un seul branchement, en compagnie d'informations pour prédire laquelle est la bonne. Il est possible d'utiliser un BTB unique, pour tous types de branchements, appelé un '''BTB unifié'''. Un BTB unifié a plusieurs adresse de destination par entrée du BTB. Mais une seule est utilisée pour les branchements directs. Vu que les branchements directs sont majoritaires, cela fait beaucoup d'entrées inutilisées. En conséquence, elle est tout sauf optimale, et n'est presque jamais utilisée en pratique. {|class="wikitable" |+ Branch Taarget Buffer unifié |- ! Adresse du branchement !! Informations de prédiction !! Adresse de destination !! Seconde adresse de destination |- | 0x54462146 || 11101011 || 0x52495674 || 0x24987461 |- | 0x54142446 || 11101101 || 0x15646956 || x0x4158746 |- | 0x25456542 || 10011101 || 0x52495674 || 0x24157461 |- | 0x15455855 || 11010101 || 0x52485754 || 0x24024511 |- | ... || ... || ... || ... |- | ... || ... || ... || ... |- | 0x54462146 || 1001101 || 0x52495674 || 0x24987461 |} Une seconde solution utilise un BTB séparé, distinct du BTB pour les branchements directs. En séparant les deux, on a un BTB simple qui ne mémorise qu'une adresse de destination par branchement, et un BTB pour branchements indirects avec plusieurs adresses de destination par branchement. [[File:BTB avec prédiction de branchements indirects.png|centre|vignette|upright=2.5|BTB avec prédiction de branchements indirects.]] L'idée est que l'on économise des circuits à nombre d'entrées égales. Par exemple, comparons ce que cela donne avec 64 entrées. D'un côté, on a un BTB à 64 entrées, capable de gérer deux adresse de destination par branchement. En pratique, la majorité des branchements sont des branchements directs, qui n'utiliseraient qu'une seule case mémoire. Les circuits du BTB seraient donc sous-utilisés. De l'autre, on a un BTB à 48 entrées pour branchements directs, et un second BTB avec 16 entrées, capable de mémoriser deux adresses par branchements. Le premier BTB utilise moins de circuits, et est utilisé à pleine capacité, il ne gaspille pas de circuits pour mémoriser des adresses de destination vides. Par contre, on perd un peu en flexibilité. L'organisation précédente marche bien si 1/4 des branchements rencontrés sont des branchements indirects. Mais si le ratio est différent, la performance est légèrement sous-optimale. Attention, cela ne veut pas dire qu'il y aura des entrées vides dans un BTB, non. Simplement, le premier BTB risque de manquer de place pour les branchements directs récents, alors que les branchements indirects seront eux bien prédits, même les anciens. À l'opposé, un BTB unique permet de mémoriser les 64 derniers branchements, peu importe qu'ils soient indirects ou non. En clair : un BTB unique est plus flexible, ce qui donne un gain de performance dans certaines situations. Cependant, cet avantage théorique ne tient pas compte de l'économie en circuits, qui peut être utilisée pour faire grossir le BTB pour branchements directs. Par exemple, à budget à transistor équivalent, on a le choix entre BTB à 64 entrées uniques, et un BTB pour branchements directs de 80 entrées avec un BTB indirect de 16 entrées. Le fait qu'un BTB soit plus gros permet des gains de performances qui compensent la perte de flexibilité. On peut ainsi avoir le meilleur des deux monde, à savoir une économie de circuits et les gains en performance qui en découlent, malgré une petite perte de flexibilité. Une troisième solution utilise un BTB normal, mais réserve plusieurs entrées pour un branchement indirect. Par exemple, un branchement indirect qui a trois adresses de destination prendra trois entrées : une par adresse de destination. Mais quelques difficultés surviennent alors. Comment placer trois copies d'un même branchement, alors que ces trois copies ont le même ''program counter'' ? Et que faire pour savoir quelle copie sera prise ? Que faire si l'unité de prédiction de branchement prédit les trois copies comme prises ? La technique du '''''Program Counter'' virtuel''' (''Virtual Program Counter'') résoud ces problèmes d'une manière assez élégante. L'idée est la même que précédemment, à savoir qu'il y a une copie du branchement par adresse de destination, dans le BTB. Mais ces copies n'ont pas exactement le même ''Program counter''. Chaque copie se voit attribuer un ''Program Counter'' virtuel. Et comprendre pourquoi demande de comprendre l'idée sous-jacente. La technique a été inspirée par une optimisation des compilateurs, qui transforme un branchement indirects en une séquence de branchements conditionnels. Par exemple, un branchement indirects qui peut brancher vers les adresses A, B et C est transformé en trois branchements conditionnels : un qui branche vers A, un autre qui branche vers B et un autre vers C. Elle ne marche pas pour tous les branchements indirects, elle demande des circonstances particulières pour fonctionner. Mais l'idée est adaptée pour le ''design'' de l'unité de prédiction de branchement. L'idée est qu'un branchement indirect est déroulé en plusieurs branchements dans le BTB. Les branchements sont traités en séquence : l'unité de prédiction prédit si le premier branchement est pris ou non. S'il est pris, son adresse de destination est envoyée au cache d'instruction. S'il n'est pas pris, l'unité de prédiction tester la seconde copie du branchement, et rebelote. Il teste toutes les copies, une par une, et s’arrête quand il tombe sur un branchement pris. Si aucune copie n'est prise, alors le branchement est prédit comme non-pris. Première remarque : il s'agit d'un processus itératif, où on test un branchement à la fois. Et oui, c'est une prédiction par cycle d'horloge. Prédire un branchement indirect peut donc demander plusieurs cycles d'horloge. Mais ce n'est pas un problème, car la prédiction peut être utilisée par l'unité de décodage, pour annuler les instructions chargées à tord. Deuxième remarque : comment passer d'un branchement à l'autre ? La première copie a un ''program counter'' normal, qui correspond à l'adresse du branchement. La seconde a un ''program counter'' qui est calculé à partir de cette première adresse, la troisième copie a une adresse calculée à partir de la seconde, etc. Concrètement, n'importe quelle fonction un tant soit peu simple suffit, tant qu'elle est déterministe. L'article qui a proposé cette techniques (''Virtual Program Counter (VPC) Prediction : Very Low Cost Indirect Branch Prediction Using Conditional Branch Prediction Hardware'') se contenait de faire un XOR entre l'adresse précédente et une constante, avec une constante différente par itération. Troisième remarque : l'algorithme demande de faire une parenthèse dans le fonctionnement normal du BTB. Quand un branchement indirect est détecté, il lance une succession de prédiction pour prédire un branchement. L'incrémentation du ''program counter'' normale cesse pendant ce processus. Et cela ne doit être fait que quand un branchement indirect est détecté. Pour cela, le BTB mémorise le type de chaque branchement, sans l'entrée du BTBT : est-ce qu'il est conditionnel, inconditionnel, mais aussi direct ou indirect. Et le type est accesible en sortie du BTB, sur une sortie de quelques bit. Quand l'unité de prédiction remarque qu'un branchement indirect vient de sortir du BTB, alors elle lance le processus. [[File:Virtual program counter.png|centre|vignette|upright=2.5|Virtual program counter]] L'avantage de cette technique est qu'elle s’implémente facilement, son cout en transistors est faible. Elle demande juste d'ajouter quelques circuits autour d'un BTB classique. Elle utilise l'unité de prédiction classique, pas besoin d'une unité de prédiction séparée. Vu qu'il y a un seul BTB, on n'a pas les désavantages d'un BTB séparé pour les branchements indirects. Les entrées du BTB sont réparties entre branchements directs et indirects de manière optimale. Niveau performance, elle est un peu moins bonne que les unités de prédiction ITTAGE qu'on détaillera dans la suite du chapitre. Mais elle est suffisante pour être utilisée sur certains processeurs commerciaux. ===La prédiction de l'adresse de retour d'une fonction=== Presque tous les processeurs conçus après les années 2000 peuvent prévoir l'adresse à laquelle il faudra reprendre lorsqu'une fonction/sous-programme a fini de s’exécuter. Ils font de la '''prédiction de retour de fonction'''. Pour cela, ils possèdent pour une unité de prédiction de branchement spécialisée, appelée le ''return stack predictor'', ou '''''pile de prédiction de retour de fonction'''''. Le terme ''return stack predictor'' étant plus court, nous l'utiliserons dans la suite du cours. Le ''return stack predictor'' est capable de reconnaitre les instructions de retour de fonction, et de prédire l'adresse de retour. Pour comprendre comment elle fait, rappelez-vous le chapitre sur la pile d'appel. Dans ce chapitre, j'avais introduit le concept de ''pile d'adresse de retour'', à savoir une pile d'appel très simplifiée, qui ne contient que les adresses de retour des fonctions en cours d'exécution. Le ''return stack predictor'' est essentiellement composé d'une telle pile d'adresse de retour, avec des circuits pour empiler et dépiler les adresses de retour. Le ''return stack predictor'' doit reconstruire une pile d'adresses de retour, à partir du programme exécuté. La pile d'adresse est mémorisée dans le ''return stack predictor'', elle est totalement séparée de la pile d'appel normale. Le programme exécuté continue d'utiliser la même pile d'appel qu'avant, ce qui fait que les adresses de retour sont dispersées dans la pile d'appel, parfois dans des registres spéciaux du processeur. Mais cela ne pose pas de problèmes pour le processeur, qui reconstruit la pile d'adresse de retour avec l'aide du décodeur. La reconstruction se fait à partir des instructions d'appel et de retour de fonction. Une instruction d'appel de fonction empile une adresse de retour, une instruction de retour de fonction la dépile. Lorsque le décodeur d'instruction reconnait une instruction d'appel de fonction, il extrait l'adresse de retour et l'envoie au ''return stack predictor''. L'adresse de retour est alors empilée, ce qui fait qu'elle se retrouve au sommet de la pile d'adresse de retour. Dépiler une adresse de retour revient à prédire un branchement. Lorsque le ''return stack predictor'' détecte une instruction de retour de fonction, il dépile l'adresse au sommet de la pile. L'adresse est alors envoyée du cache d'instruction, pour l'ire l'instruction adéquate, et le ''program counter'' est mis à jour avec cette adresse. Toute la difficulté revient à détecter les instructions de retour de fonction, sans quoi on ne sait pas quand dépiler l'adresse. La documentation là-dessus n'est pas très fournie, il est difficile de savoir ce qui est fait dans les processeurs modernes. Mais on peut faire quelques suppositions. La première possibilité est l'usage du ''prédécodage'', une technique qui décode partiellement les instructions lors de leur chargement dans le cache d'instruction. Elle sera vue dans le chapitre suivant, mais parlons-en rapidement. Le prédécodage ajoute quelques bits au début de chaque instruction pour préciser si c'est un branchement conditionnel, inconditionnel, un retour de fonction. On peut ainsi savoir rapidement quand dépiler : dès qu'une instruction est lue dans le cache d'instruction, le ''return stack predictor'' vérifie les bits ajoutés et agit en fonction. La seconde possibilité est que le BTB reconnait l'instruction de retour de fonction. Pour chaque branchement, il mémorise non seulement son adresse de retour, mais aussi son type. Par type, on veut dire : si c'est un branchement conditionnel, inconditionnel, un retour de fonction. Les retours de fonction sont donc reconnus à condition qu'ils aient été exécutés auparavant. Il est aussi possible d'avoir un BTB spécialisé dans les retours de fonction, qui est alimenté par les décodeurs. Dès que le décodeur reconnait une instruction de retour de fonction, il ajoute son ''program counter'' dans ce BTB spécialisé. Une dernière possibilité est que le ''return stack predictor'' agit une fois l'instruction partiellement décodée. Le décodeur d'instruction reconnait les instructions de retour de fonction rapidement, en un cycle, et consulte alors le ''return stack predictor''. L'adresse est dépilée, et le décodeur sait où brancher. Il n'a pas besoin de devoir lire l'adresse de retour en mémoire, il la lit depuis le ''return stack predictor'', directement. La prédiction se fait donc assez tard dans le pipeline, quelques instructions ont été chargées à tort. Mais le décodeur peut les annuler, comme il le ferait avec un branchement inconditionnel mal prédit. ==La prédiction de branchement locale== La prédiction de branchement tente de prédire si un branchement sera pris ou non-pris, et décide quelle adresse envoyer au cache en fonction du résultat. Si on prédit qu'un branchement est non pris, on laisse le ''program counter'' faire son travail. À l'inverse si le branchement est prédit comme étant pris, l'adresse de destination est envoyée au cache d'instruction et au ''program counter''. La prédiction de branchement assigne une probabilité qu'un soit branchement soit pris en fonction de ces informations : si la probabilité est de plus de 50%, le branchement est considéré comme pris. La prédiction de branchement se base avant tout sur des statistiques, c’est-à-dire qu'elle détermine la probabilité d’exécution d'un branchement en fonction d'informations assez diverses. La prédiction de branchement n'a d'intérêt que si les prédictions sont suffisamment fiables pour valoir le coup. Rappelons que la pénalité lors d'une mauvaise prédiction est importante, d'autant plus coûteuse que le pipeline est long. Il est généralement admis qu'il faut au minimum un taux de réussite proche de 90%, voire plus sur les processeurs modernes. Heureusement, la plupart des branchements sont des '''branchements biaisés''', c’est-à-dire qu'ils sont presque toujours pris ou presque toujours non-pris, sauf en de rares occasions. De tels branchements font que la prédiction des branchements est facile, bien qu'ils posent paradoxalement quelques problèmes, comme nous le verrons plus bas. Un autre point important est que les unités de prédiction de branchement doivent être très rapides. Idéalement, elles doivent fournir leur prédiction en un seul cycle d'horloge. Un seul cycle d'horloge est un temps très court, surtout sur les ordinateurs avec une fréquence élevée. Les techniques de prédiction dynamique ne peuvent donc pas utiliser des méthodes statistiques extraordinairement complexes. Cela va à l'encontre du fait que le taux de mauvaises prédictions doit être très faible, idéalement inférieur à 1% sur les processeurs modernes. Vous comprenez donc aisément que concevoir une unité de prédiction de branchement est un véritable défi d’ingénierie électronique qui requiert des prouesses technologiques sans précédent. Suivant la nature des informations utilisées, on peut distinguer plusieurs types de prédiction de branchement : locale, globale, dynamique, statique, etc. Nous allons d'abord voir les méthodes statiques avant de voir les méthodes dynamiques. La '''prédiction locale''' prédit chaque branchement indépendamment les uns des autres. Elle sépare bien les statistiques/moyennes pour chaque branchement. Et c'est autant un point fort qu'un point faible. En effet, elle n'exploite pas les corrélations entre branchements, à savoir le fait que l'exécution d'un branchement a tendance à déborder sur les suivants. Dans ce qui suit, nous allons voir les techniques de prédiction locales. ===Les ''branch hints''=== Dans son implémentation la plus explicite, la prédiction est inscrite dans le branchement lui-même. Quelques bits de l'opcode du branchement précisent si le branchement est majoritairement pris ou non pris. Ils permettent d'influencer les règles de prédiction de branchement et de passer outre les réglages par défaut. Ces bits sont appelés des '''suggestions de branchement''' (''branch hint''). Mais tous les processeurs ne gèrent pas cette fonctionnalité. Et de plus, cette solution marche assez mal. La raison est que le programmeur ou le compilateur doit déterminer si le branchement est souvent pris ou non-pris, mais qu'il n'a pas de moyen réellement fiable pour cela. Une solution possible est d’exécuter le programme sur un ensemble de données réalistes et d'analyser le comportement de chaque branchement, afin de déterminer les suggestions de branchement adéquates, mais c'est une solution lourde et peu pratique, qui a de bonnes chances de donner des résultats peu reproductibles d'une exécution à l'autre. ===La prédiction statique de branchement=== La '''prédiction statique''' prédit le résultat du branchement en fonction de caractéristiques fixes du branchement lui-même, comme son adresse, son mode d'adressage, l'adresse de destination, la position du branchement en mémoire, si le branchement est direct ou indirect, etc. Les informations en question sont connues une fois le branchement décodé, il n'y a pas besoin de mémoriser la moindre information supplémentaire. Une première implémentation, assez évidente, fait la différence entre les branchements inconditionnels toujours pris, et les branchements conditionnels au résultat variable. L'idée est de prédire les branchements inconditionnels comme toujours pris alors que les branchements conditionnels sont prédits comme non-pris (ce qui est une approximation). L'implémentation est relativement simple : on utilise un BTB qui ne mémorise que les branchements inconditionnels, et basta ! Les branchements conditionnels sont simplement ignorés, traités comme n'importe quelle autre instruction. Cette méthode est particulièrement inefficace pour les branchements de boucle, où la condition est toujours vraie, sauf en sortie de boucle ! Il a donc fallu affiner légèrement l'algorithme de prédiction statique. Une implémentation plus subtile fait la différence entre les branchements des boucles et les autres. Reste à identifier ceux-ci d'une manière ou d'une autre. Et un bon moyen est de regarder si le branchement revient en arrière ou au contraire saute une portion de code. Un programme s'exécute des adresses basses vers les adresses hautes, les instructions vont naturellement dans le sens croissant des adresses. Un branchement de boucle fait revenir en arrière, histoire de répéter un morceau de code, pas les autres branchements (du moins pas forcément). Et cela nous amène à faire la distinction entre les branchements conditionnels ascendants et descendants. Un '''branchement conditionnel ascendant''' est un branchement qui demande au processeur de reprendre plus loin dans la mémoire : l'adresse de destination est supérieure à l'adresse du branchement. Un branchement '''conditionnel descendant''' a une adresse de destination inférieure à l'adresse du branchement : le branchement demande au processeur de reprendre en arrière dans la mémoire, avant le branchement. Les branchements ascendants sont rarement pris (ils servent dans les conditions de type SI…ALORS), contrairement aux branchements descendants (qui servent à fabriquer des boucles). On peut ainsi modifier l’algorithme de prédiction statique comme suit : * les branchements inconditionnels sont toujours pris ; * les branchements descendants sont toujours pris ; * les branchements ascendants ne sont jamais pris. L'implémentation demande d'élargir le BTB. Le BTB n'a pas seulement à mémoriser l'adresse du branchement et son adresse de destination, mais aussi le type du branchement. En général, il se contente de deux bits, qui indiquent si le branchement est conditionnel, inconditionnel, ou un retour de fonction. Les retours de fonction sont traités à part, histoire de profiter du ''return stack predictor''. {|class="wikitable" |+ BTB avec prédiction de branchement statique évoluée |- ! Adresse du branchement !! Adresse de destination !! Type du branchement |- | 0x4165485 || 0x14657445541 | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Retour de fonction |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... || ... |} ===La prédiction avec des compteurs à saturation=== Les méthodes de prédiction de branchement statique sont intéressantes, mais elles montrent rapidement leurs limites. Pour plus de performances, il faut utiliser la '''prédiction dynamique''', qui extrait des statistiques sur l’exécution des branchements. Pour le reste du chapitre, toutes les techniques utilisent la prédiction dynamique, qu'elles soient locales ou globales. Sa version la plus simple se contente de mémoriser ce qui s'est passé lors de l’exécution précédente du branchement. Si le branchement a été pris, alors on suppose qu'il sera pris la prochaine fois. De même, s'il n'a pas été pris, on suppose qu'il ne le sera pas lors de sa prochaine exécution. Pour cela, chaque entrée du BTB contient un bit qui mémorise la dernière exécution du branchement : 1 s'il a été pris, 0 s'il n'a pas été pris. C'est ce qu'on appelle la '''prédiction de branchements sur 1 bit'''. Au vu de sa simplicité, il ne faut pas s'attendre à des miracles. La méthode fonctionne bien pour les branchements des boucles et les branchements inconditionnels, mais elle n'est pas assez performante. Une forme plus élaborée de prédiction utilise des '''compteurs à saturation'''. Un compteur à saturation est un compteur qui est incrémenté à chaque fois qu'un branchement est pris, décrémenté s'il est non-pris. Le compteur est initialisé à la moitié de sa valeur maximale, ce qui correspond à une probabilité proche de 50%. Les compteurs à saturation sont très utilisés et pas seulement pour de la prédiction locale, les méthodes globales en utilisent aussi. Pour faire la prédiction, l'unité de prédiction regarde le bit de poids fort du compteur. Le branchement est considéré comme pris si ce bit de poids fort est à 1, et non pris s'il vaut 0. En clair : on considère que le branchement est pris si le compteur indique une probabilité supérieure à 50%. [[File:Compteur à saturation.png|centre|vignette|upright=2|Compteur à saturation.]] : Pour la culture générale : le compteur à saturation du Pentium 1 était légèrement bogué, ce qui fait que sa prédiction de branchement était sous-optimale. La plupart des processeurs utilisent un compteur à saturation par branchement, ce qui veut dire un compteur à saturation par entrée dans le BTB. Vu chaque compteur est associé à une entrée du BTB, le phénomène d’''aliasing'' mentionné plus haut peut perturber les prédictions de branchement. Deux branchements peuvent se voir associés à la même entrée du BTB, et agir sur le même compteur à saturation, ce qui entraine l'apparition d''''interférences entre branchements'''. L'interférence peut avoir des effets positifs, neutres ou négatifs, suivant la corrélation entre les deux branchements. L’''aliasing'' n'est pas un problème si les deux branchements sont fortement corrélés, par exemple si les deux sont tout le temps pris ou tout le temps non-pris. Si les deux branchements vont dans le même sens, l'interférence est neutre, voire légèrement positive. Mais si l'un est souvent pris et l’autre souvent non-pris, l'interférence est négative. Les deux branchements vont se marcher dessus sans vergogne, chacun interférant sur les prédictions de l'autre ! Il est rare que l’''aliasing'' ait un impact significatif sur les unités de prédiction des deux paragraphes précédents. Il se manifeste surtout quand la BTB est très petite et la seule solution est d'augmenter la taille du BTB. ===La prédiction avec un historique local=== La prédiction avec des compteurs à saturation est très simple, mais il est possible de créer une implémentation alternative, très proche, avec des avantages et désavantages différents. Un compteur à saturation enregistre l'historique d'un branchement, à savoir ses dernières exécutions. L''''historique''' d'un branchement dit quel a été son résultat dans le passé, pour ses exécutions précédentes. Et il est possible d'enregistrer cet historique et l'utiliser pour faire des prédictions. Par exemple, prenons un branchement sont ses 6 dernières exécutions sont "pris, non-pris, pris, pris, pris, non-pris". Le processeur peut encoder cet historique sur 6 bits. Un branchement pris correspond à un bit à 1, un branchement non-pris correspond à un 0. Le bit de poids fort correspond à l'exécution la plus ancienne, le bit de poids faible à l'exécution la plus récente. Par exemple, si l'historique contient 010, cela veut dire que le branchement a été non pris, puis pris, puis non pris. L'historique d'un branchement est appelé son '''historique local''', mais nous parlerons simplement l'historique. Le processeur peut mémoriser l'historique de chaque branchement. Pour cela, il réserve n bits par branchements, pour mémoriser ses n exécutions précédentes. L'historique d'un branchement est mémorisé dans son entrée du BTB. Et plus précisément dans un champ séparé qui est alimentée comme un registre à décalage. Reste à exploiter cet historique local pour faire des prédictions. Une idée assez simple serait de l'utiliser pour calculer une moyenne des N exécutions précédentes. Si la probabilité d'être pris est supérieure à 50 %, le branchement est considéré comme pris. Dans le cas contraire, il est considéré comme non pris. Le calcul de la moyenne utilise une simple porte à majorité, qui prend en entrée tous les bits de l'historique. Cependant, cette méthode est équivalente à utiliser un compteur à saturation par branchement. La sortie de la porte à majorité est équivalente à ce qu'on aurait dans un compteur à saturation. La seule différence : le cout en circuit et la simplicité de conception. Prenons l'exemple des premiers processeurs PA RISC. Ils mémorisaient, pour chaque branchement, les trois dernières exécutions du branchement, avec trois bits par entrée du BTB. La prédiction était réalisée avec une porte à majorité, dont le résultat était utilisé comme prédiction. Ces processeurs ont préféré utiliser un historique de 3 bits, car il était plus simple à concevoir qu'un compteur à saturation équivalent de deux bits. Il suffisait d'alimenter un registre à décalage avec un bit, pas besoin d'incrémenter ou de décrémenter un compteur, pas besoin de signaux d'incrémentation/décrémentation séparés, etc. Mais pour des historiques plus longs, la donne s'inverse et des compteurs à saturation de deux-trois bits l’emportent. Un défaut de la méthode précédente, qui est partagée avec les compteurs à saturation, est qu'elle marche mal pour les branchements qui suivent un ''pattern''. Je veux dire par là que l'exécution d'un branchement suit un cycle, par exemple pris, pris, non-pris, pris, pris, non-pris, et ainsi de suite en boucle. Notons que l'apparition de tels motifs correspond le plus souvent à la présence de boucles dans le programme. Quand une boucle s’exécute, les branchements qui sont à l'intérieur tendent à exprimer de tels motifs. Avec de tels motifs, les compteurs à saturation feront des prédictions incorrectes, là où les prédicteurs qu'on va voir font des prédictions parfaites. Par exemple, prenons un branchement qui suit le ''pattern'' pris, pris, pris, non-pris. Si l'historique local vaut 1110, le branchement est censé refaire une boucle et être pris. Et certes, on a supposé que l'historique faisait 4 bits. Mais le''pattern'' sera visible tant que l'historique fait plus de 4 bits. Par exemple, le ''pattern'' serait 01110 pour un historique local de 5 bits, 101110 pour un historique local de 6 bits, 1101110 pour un historique local de 7 bits, etc. Un ''pattern'' correspond donc à une valeur bien précise de l'historique local. L'historique local permet de voir ces cycles, reste à les exploiter. Prédire correctement les ''pattern'' demande simplement d'associer un compteur à saturation à chaque valeur possible de l'historique local. Le compteur à saturation dira, quand tel pattern est rencontré, si le branchement sera pris ou non-pris par la suite. Au total, il faut donc un registre d'historique local de n bits couplé à 2^n compteurs à saturation. Et ce pour chaque branchement possible. Les compteurs à saturation sont regroupés dans une '''''Pattern History Table''''', que nous noterons PHT dans ce qui suit. Le résultat est une '''unité de prédiction à deux niveaux locale''', qui contient plusieurs registres d'historique et tout autant de PHT locales. En tout, il y a une PHT et un historique par branchement, donc par entrée du BTB. Quand un branchement est exécuté, son historique local indique quel compteur à saturation choisir dans la PHT locale. En général, l'historique local est mémorisé dans le BTB, reste à gérer la PHT. [[File:Unité de prédiction à deux niveaux purement locale.png|centre|vignette|upright=2.5|Unité de prédiction à deux niveaux avec des historiques et des PHT locales.]] Une unité de prédiction locale à deux niveaux contient plusieurs PHT, reste à savoir quelle PHT correspond à tel branchement. La correspondance se fait idéalement à partir de l'adresse du branchement, mais c'est là une implémentation idéale, qui demande beaucoup de circuits. Généralement, l'unité de prédiction n'utilise que les bits de poids faible de l'adresse du branchement, ce qui permet de faire un choix correct, mais avec une possibilité d'''aliasing''. Il est aussi possible de séparer l'historique local du BTB. Les historiques locaux sont alors mémorisés dans une mémoire RAM dédiée, appelé la '''''Branch History Table''''', ou encore la '''table des historiques locaux''', que nous noterons BHT dans ce qui suivra. Chaque byte de la BHT contient l'historique local associé à un branchement. Pour récupérer l'historique local voulu, l'adresse du branchement est envoyée à la BHT, sur les entrées d'adresse de la BHT, directement. Seuls les bits de poids faible de l'adresse sont utilisés. L'historique local est ensuite envoyé à la PHT adéquate, choisie en fonction des mêmes bits de poids faible du PC. Un bon moyen pour cela est d'accéder à toutes les PHT en parallèle, mais de sélectionner la bonne avec un multiplexeur, ce dernier étant commandé par les bits de poids faible du PC. [[File:Table des historiques locaux.jpg|centre|vignette|upright=2|Table des historiques locaux]] Un défaut des unités de prédiction purement locales de ce type est leur cout en termes de circuits. Utiliser un registre d'historique et une PHT par branchement a un cout en transistor élevé, avec tous les défauts que ca implique en termes de consommation électrique et thermique. Elles ont aussi des temps de calcul un peu plus important que les unités purement globales, mais pas de manière flagrante. Les gains en performance avec un historique local s'observent surtout pour les branchements qui sont dans des boucles ou pour les branchements utilisés pour concevoir des boucles, alors que l'implémentation globale marche bien pour les if..else (surtout quand ils sont imbriqués). Les deux sont donc complémentaires. Dans les faits, elles sont rarement utilisées seules du fait de leurs défauts, au profit d'unités de prédiction hybrides, qui mélangent historiques et PHT locaux et globaux. ==La prédiction de branchement globale/mixte== Utiliser un historique local avec une PHT est très puissant. Mais le cout en circuit est très important. Cependant, on peut adapter l'idée pour faire de la prédiction globale. La '''prédiction globale''' fait des prédictions holistiques, qui tiennent compte de tous les branchements, sans les traiter séparément. Elle ne sépare pas les branchements, tous sont mis dans le même panier. La prédiction globale fait donc le compromis inverse : elle exploite les corrélations entre branchements, mais perd les informations statistiques précise pour chaque branchement. L'idée est qu'au lieu d'utiliser un historique par branchement, on utilise un '''historique global''' qui mémorise les exécutions de tous les branchements conditionnels. L'historique enregistre les N exécutions de branchements conditionnels. L'historique global est mémorisé dans un registre unique, appelé le '''registre d'historique global'''. C'est un registre à décalage mis à jour à chaque fois que le processeur exécute un branchement : on fait rentrer un 1 si le branchement est pris, et un 0 sinon. Une unité de prédiction globale utilise cet historique pour faire sa prédiction. L'unité de prédiction globale utilise un compteur à saturation pour chaque valeur possible du registre d'historique. En clair, pour un registre de N bits, elle utilise 2^n compteurs à saturation. Là encore, les compteurs à saturation sont regroupés dans une ''Pattern History Table''. Nous parlerons de '''PHT globale''' pour désigner une PHT commandée par un historique global, de ''PHT locale'' si elle est adressée par un historique local. [[File:Prédiction dynamique à deux niveaux.png|centre|vignette|upright=2|Prédiction dynamique à deux niveaux.]] Il est admis que l'historique global donne de meilleures prédictions que l'historique local. Mais les deux sont complémentaires, ce qui fait qu'il existe des approches hybrides. Les approches hybrides exploitent à la fois les statistiques pour chaque branchement et les corrélations entre branchements. Les prédicteurs hybrides ont de meilleures performances que les prédicteurs purement globaux ou locaux. Avec un historique global, la prédiction tient compte d'éventuelles corrélations entre branchements consécutifs/proches pour faire les prédictions. C'est un avantage pour les applications qui enchaînent des if...else ou qui contiennent beaucoup de if...else imbriqués les uns dans les autres, qui s’exécutent plusieurs fois de suite. Le résultat de chaque if...else dépend généralement des précédents, ce qui rend l'utilisation d'un historique global intéressant. Par contre, ils sont assez mauvais pour le reste des branchements. Mais cette qualité devient un défaut quand elle détecte des corrélations fortuites ou parasites, inutiles pour la prédiction (corrélation n'est pas causalité, même pour prédire des branchements). Du fait de ce défaut, la prédiction globale a besoin de registres d'historiques globaux très larges, de plusieurs centaines de bits au mieux. [[File:2 level branch predictor.svg|centre|vignette|upright=2|Unités de prédiction de branchement à deux niveaux avec une seule PHT.]] Le circuit précédent a cependant un problème : si deux branchements différents s'exécutent et que l'historique est le même, le processeur n'y verra que du feu. Il y a alors une interférence, à savoir que les deux branchements vont agir sur le même compteur à saturation. On se retrouve alors dans une situation conceptuellement identique à l’''aliasing'' dans le BTB ou avec des compteurs à saturation. Sauf que les interférences sont alors beaucoup plus nombreuses et qu'elles sont clairement un problème pour les performances ! Mitiger les interférences devient alors bien plus important qu'avec les unités de prédiction précédentes. Heureusement, si augmenter la taille de la PHT peut mitiger le problème, d'autres solutions sont possibles sur les unités de prédiction globales. Elles sont au nombre de deux : mitiger les interférences liées aux branchements biaisés, ajouter des informations qui discriminent les branchements. Voyons ces solutions dans le détail. ===La mitigation des interférences par usage de l'adresse de branchement=== La première solution pour réduire l'impact de l’''aliasing'' est d'augmenter la taille de la PHT et de l'historique. Plus l'historique et la PHT sont grands, moins l’''aliasing'' est fréquent. Mais avoir des historiques et des PHT de très grande taille n'est pas une mince affaire et le cout en termes de performances et de portes logiques est souvent trop lourd. Une autre solution consiste à utiliser, en plus de l'historique, des informations sur le branchement lui-même pour choisir le compteur à saturation à utiliser. Typiquement, on peut utiliser l'adresse du branchement en plus de l'historique, pour choisir le compteur à saturation adéquat. Pour être précis, on n'utilise que rarement l'adresse complète du branchement, mais seulement les bits de poids faible. La raison est que cela fait moins de bits à utiliser, donc moins de circuits, mais l’''aliasing'' est juste atténué, pas éliminé. Des branchements différents ont beau avoir des adresses différentes, les bits de poids faible de leurs adresses peuvent être identiques. Des confusions sont donc possibles, mais sont assez rares pour que cela suffise en pratique. Une unité de prédiction de ce type est illustrée ci-dessous. Il y a plusieurs PHT séparées, donc plusieurs ensembles de 2^n compteurs à saturation. Le choix de la PHT est réalisé par l'adresse du branchement, alors que le choix du compteur à saturation est réalisé par l'historique. Les compteurs à saturation d'un branchement sont mis à jour seulement quand le branchement s'est chargé pendant que l'historique associé était dans le registre d'historique. Cette méthode a cependant le défaut de gâcher énormément de transistors. [[File:Unité de prédiction de branchement basée sur des compteurs à saturation sélectionnés par l'historique global.jpg|centre|vignette|upright=2|Unité de prédiction de branchement basée sur des compteurs à saturation sélectionnés par l'historique global]] D'autres unités de prédiction fonctionnent sur le même principe que l'unité précédente, sauf que les différentes PHTs sont fusionnées en une seule grosse PHT. C'est ce que font les unités de prédiction '''''gshare''''' et '''''gselect''''', ainsi que leurs dérivées. Avec les unités de prédiction '''''gselect''''', on concatène les bits de poids faible de l'adresse du branchement et l'historique pour obtenir le numéro du compteur à saturation à utiliser. [[File:Prédiction « gselect ».png|centre|vignette|upright=2|Prédiction « gselect ».]] Avec les unités de prédiction '''''gshare''''', on fait un XOR entre le registre d'historique et les bits de poids faible de l'adresse du branchement. Le résultat de ce XOR donne le numéro du compteur à utiliser. Avec cette technique, on utilise une PHT globale et un historique global, mais le registre d'historique est manipulé de manière à limiter l'''aliasing''. [[File:Prédiction « gshare ».png|centre|vignette|upright=2|Prédiction « gshare ».]] Intuitivement, on pourrait croire que les unités de prédiction gselect ont de meilleures performances. C'est vrai que les unités gshare combinent l'adresse du branchement et l'historique d'une manière qui fait perdre un peu d'information (impossible de retrouver l'adresse du branchement et l'historique à partir du résultat du XOR), alors que les unités gselect conservent ces informations. Mais il faut prendre en compte le fait que les deux unités doivent se comparer à PHT identique, de même taille. Prenons par exemple une PHT de 256 compteurs, adressée par 8 bits. Avec une unité gselect, les 8 bits sont utilisés à la fois pour l'historique et pour les bits de poids faible de l'adresse du branchement. Alors qu'avec une unité gshare, on peut utiliser un historique de 8 bits et les 8 bits de poids faible de l'adresse du branchement. L'augmentation de la taille de l'historique et du nombre de bits utilisés fait que l’''aliasing'' est réduit, et cela compense le fait que l'on fait un XOR au lieu d'une concaténation. Les unités précédentes réduisent l'''aliasing'' en segmentant la PHT : telles entrées sont allouées à un branchement, telles entrées à tel autre branchement, etc. Cependant, l'historique local n'est pas utilisé. Mais quitte à allouer des entrées à un branchement, autant en profiter pour leur associer son historique local. Il s'agit alors d'approches hybrides. Par exemple, on peut citer la '''prédiction mixte''' (''alloyed predictor''). Avec celle-ci, la table des compteurs à saturation est adressée avec la concaténation : de bits de l'adresse du branchement, du registre d'historique global et du registre d'historique local adressé par l'adresse du branchement. [[File:Prédiction mixte.jpg|centre|vignette|upright=2|Prédiction mixte.]] Une version optimisée de ce circuit permet d’accéder à la PHT et à la BHT en parallèle. Avec elle les bits de poids faible de l'adresse du branchement sont concaténés avec l'historique global. Le résultat est ensuite envoyé à plusieurs PHT locales distinctes, en parallèle. Les différences PHT envoient leurs résultats à un multiplexeur, commandé par l'historique local, qui sélectionne le résultat adéquat. L'inconvénient de cette mise en œuvre est que le circuit est assez gros, notamment en raison de la présence de plusieurs PHT séparées. [[File:Alloyed predictor optimisé.jpg|centre|vignette|upright=2|''Alloyed predictor'' optimisé]] ===La mitigation des interférences par la structuration en caches des PHTs=== Les techniques précédentes utilisent les bits de poids faible de l'adresse pour sélectionner la PHT adéquate, ou pour sélectionner le compteur dans la PHT. Mais le reste de l'adresse n'est pas utilisé. Cependant, il est théoriquement possible de conserver les bits de poids fort, afin d'identifier le branchement associé à un compteur. Pour cela, chaque compteur à saturation se voit associé à un ''tag'', qui mémorise le reste de l'adresse du branchement associé au compteur, comme pour les mémoires caches. Lorsque l'unité de prédiction démarre une prédiction, elle récupère les bits de poids fort de l'adresse du branchement et compare ceux-ci avec le ''tag''. S’il y a un ''match'', c'est signe que le compteur est bien associé au branchement à prédire. Dans le cas contraire, c'est signe qu'on fait face à une situation d’''aliasing'' et le compteur n'est pas utilisé pour la prédiction. Le résultat transforme la PHT en une sorte de cache assez particulier, qui associe un compteur à saturation à chaque couple adresse-historique. La technique du paragraphe précédent peut être encore améliorée afin de réduire l’''aliasing''. L’''aliasing'' sur une unité de prédiction de branchement est similaire aux conflits d'accès au cache, où deux données/adresses atterrissent dans la même ligne de cache. Une PHT peut formellement être vue comme un cache directement adressé. Une solution pour limiter ces conflits d'accès à de tels, est de les transformer en un cache associatif à n voies. On peut faire la même chose avec les unités de prédiction de branchement, ce qui revient à dupliquer les PHT, de manière à ce que les bits de poids faible de l'adresse se voient attribuer à plusieurs compteurs à saturation, un par branchement possible. Ce faisant, on réduit les interférences entre branchements. Mais cette technique augmente la quantité de circuits utilisés et la consommation d'énergie, ainsi que le temps d'accès, sans compter qu'elle requiert d'utilisation de ''tags'' pour fonctionner à la perfection. Pour les unités de prédiction de branchement, les mesures à ce sujet ne semblent pas montrer un impact réellement important sur les performances, comparé à une organisation de type directement adressé. Il est possible de pousser la logique encore plus loin en ajoutant des caches de victime et d'autres mécanismes courant sur les caches usuels. ===La mitigation des interférences liées aux branchements biaisés=== L’''aliasing'' a un impact négatif quand deux branchements aliasés (qui sont attribués au même compteur à saturation) vont dans des sens différents : si l'un est non-pris, l'autre a de bonnes chances d'être pris (ou inversement). Or, la plupart des branchements sont biaisés, à savoir très fortement orientés dans une direction qu'une autre (presque toujours pris ou presque toujours non-pris, à plus de 90/99% de chances). Plus un branchement a une probabilité élevée d' être pris ou non-pris, plus sa capacité à interférer avec les autres est forte. Le pire des cas est quand deux branchements sont biaisés mais dans des directions inverses : l'interférence est maximale. En conséquence, les branchements biaisés ou quasi biaisés sont les plus problématiques pour l’''aliasing''. Or, il est possible de concevoir des prédicteurs de branchements qui atténuent fortement les interférences des branchements biaisés ou quasi biaisés. C’est le cas de l’''agree predictor'' et de l'unité de prédiction bimodale, que nous allons voir dans ce qui suit. Les branchements très souvent pris vont interférer très fortement avec les branchements très souvent non-pris, et inversement. Par contre, les branchements très souvent pris n'interféreront pas entre eux, de même que les branchements non-pris iront bien ensemble. D'où l'idée de séparer ces deux types de branchements dans des PHT séparées, pour éviter le mauvais ''aliasing''. L''''unité de prédiction bimodale''' part d'une unité gshare, et sépare la PHT globale en deux : une PHT dédiée aux branchements qui sont très souvent pris et une autre pour les branchements très peu pris. Les deux PHT fournissent chacune une prédiction. La bonne prédiction est choisie par un multiplexeur commandé par une unité de sélection, qui se charge de déduire quelle unité a raison. Cette unité de sélection est une unité de prédiction basée sur des compteurs à saturation de 2 bits. On peut encore améliorer l'unité de sélection de prédiction en n'utilisant non pas des compteurs à saturation, mais une unité de prédiction dynamique à deux niveaux, ou toute autre unité vue auparavant. [[File:Prédiction bimodale.jpg|centre|vignette|upright=2|Prédiction bimodale.]] La '''prédiction par consensus''' (''agree predictor'') combine une unité de prédiction à historique global (idéalement de type gshare ou gselect, mais une unité normale marche aussi) et des compteurs à saturation. Il s'agit techniquement d'une technique de prédiction mixte, à la fois globale et locale. Le BTB intègre un compteur à saturation de un bit, qui indique la direction privilégiée du branchement. L'unité à historique global a un fonctionnement changé : elle calcule non pas la probabilité que le branchement soit pris ou non-pris, mais la probabilité que le résultat du branchement soit compatible avec le résultat de l'unité de prédiction de 1 bit. La prédiction finale vérifie que ces deux circuits sont d'accord, en faisant un NXOR entre les résultats des deux unités de prédiction de branchement. [[File:Prédiction par consensus.png|centre|vignette|upright=2|Prédiction par consensus.]] Des calculs simples de probabilités montrent que l'''agree predictor'' a des résultats assez importants. Ses résultats sont d'autant meilleurs que les branchements sont biaisés et penchent fortement vers le côté pris ou non-pris. ==La prédiction de branchement avec des perceptrons== Les méthodes précédentes utilisent au moins un compteur à saturation par historique possible. Sachant qu'il y a 2^n valeurs possibles pour un historique de n bits, le nombre de compteurs à saturation augmente exponentiellement avec la taille de l'historique. Ce qui limite grandement la taille de l'historique, alors qu'on aurait au contraire besoin d'un historique assez large pour faire des prédictions excellentes. Pour contourner le problème, des alternatives basées sur des techniques d'apprentissage artificiel (''machine learning'') existent. Un exemple est celui des unités de prédiction de branchement dites de ''neural branch prediction'', basée sur des perceptrons. Dans ce qui suit, nous les appelerons des '''unités perceptron'''. Les perceptrons sont un algorithme d'apprentissage automatique très simple, ce qui fait que les explications qui vont suivre devraient être simples. En théorie, il est possible d'utiliser des techniques d'apprentissage automatique plus élaborées, mais cela n'est pas possible en pratique. Rappelons qu'une unité de prédiction de branchement doit idéalement fournir sa prédiction en un cycle d'horloge. L'implémentation d'un perceptron est déjà problématique de ce point de vue, car elle demande d'utiliser beaucoup de circuits arithmétiques complexes (multiplication et addition). Autant dire que les autres techniques de ''machine learning'', encore plus gourmandes en calculs, ne sont pas praticables. ===Les perceptrons et leur usage en tant qu'unité de prédiction de branchements=== L'idée derrière les unités perceptron est que certains bits de l'historique sont plus importants que d'autres. En soi, ce genre de situation est réaliste. Il arrive que certains branchements soient pris uniquement si les deux branchements précédents ne le sont pas, ou que si l'avant-dernier branchement est lui aussi pris. Mais les autres résultats de branchement présents dans l'historique ne sont pas utiles. En conséquence, deux historiques semblables, qui ne sont différents que d'un ou deux bits, peuvent donner des prédictions presque identiques. Dans ce cas, on peut faire la prédiction en n'utilisant que les bits identiques entre ces historiques, ou du moins en leur donnant plus d'importance qu'aux autres. On exploite alors des corrélations avec certains bits de l'historique, plutôt qu'avec l'historique entier lui-même. Pour coder ces corrélations, on associe un coefficient à chaque bit de l'historique, qui indique à quel point ce bit est important pour déterminer le résultat final. Ce coefficient est proportionnel à la corrélation entre le bit de l'historique associé, et le résultat du branchement à prédire. Les coefficients sont des nombres entiers qui peuvent être positifs, nuls ou négatifs. Un coefficient positif signifie que si le branchement associé a été pris, alors le branchement à prédire a de bonnes chances de l'être aussi (et inversement). Un coefficient nul signifie que les deux branchements sont indépendants et que le bit de l'historique associé n'est pas à prendre en compte. Pour un coefficient négatif, cela signifie que si le branchement associé a été pris, alors le branchement à prédire a de bonnes chances d'être non-pris (et inversement). Les coefficients en question sont généralement compris entre -1 et 1. Une fois qu'on connait ces coefficients de corrélations, on peut calculer la probabilité que le branchement à prédire soit pris, avec des calculs arithmétiques simples. Dans les calculs qui vont suivre, un branchement pris est codé par un 1, alors qu'un branchement non-pris est codé par un -1 ! Ce détail permet de simplifier grandement les calculs. Dans sa version la plus simple, le perceptron calcule un nombre Z qui est positif ou nul si le branchement est pris, négatif s'il ne l'est pas. Tous les coefficients <math>p_i</math> sont alors des nombres relatifs, pouvant être positifs, nuls ou négatifs. Ils sont généralement compris entre -1 et 1. La formule devient donc celle-ci : : <math>Z = w_0 + \sum_i (h_i \times w_i)</math> [[File:ArtificialNeuronModel francais.png|centre|vignette|upright=2|Perceptron.]] : Sur le plan mathématique, le perceptron effectue un produit scalaire entre deux vecteurs : l'historique et le vecteur des poids. Choisir les coefficients adéquats est le but de l'algorithme du perceptron. L'unité de prédiction part de poids par défaut, qui sont mis à jour à chaque bonne ou mauvaise prédiction. Toute la magie de cet algorithme tient dans la manière dont sont mis à jour les poids. L'idée est de prendre le vecteur des poids, puis d'ajouter ou soustraire l'historique suivant le résultat du branchement. Les poids évoluent ainsi à chaque branchement, améliorant leurs prédictions d'une exécution à l'autre. La règle de mise à jour des poids est donc la suivante : : <math>w_i \leftarrow w_i + t \times h_i</math>, avec t = 1 pour un branchement pris, -1 pour un branchement non-pris. ===L'implémentation matérielle des perceptrons et ses optimisations=== Les calculs réalisés par un perceptrons sont simples, juste des multiplications et des additions et cela se sent dans la conception du circuit. L'unité perceptron utilise un perceptron par entrée du BTB, un par branchement pour simplifier. Pour cela, on utilise une mémoire SRAM qui mémorise les poids de chaque perceptron. La SRAM est adressée par les bits de poids faible du ''program counter'', de l'adresse du branchement. Une fois les poids récupérés, ils sont envoyés à un circuit de calcul de la prédiction, composé de circuits multiplieurs suivis par un additionneur multi-opérande. Le circuit de mise à jour des poids récupère la prédiction, les poids du perceptron, mais aussi le résultat du branchement. La mise à jour étant une simple addition, le circuit est composé d'additionneurs/soustracteurs, couplés à des registres pour mémoriser la prédiction et les poids du perceptron en attendant que le résultat du branchement soit disponible. Vu qu'il y a un délai de quelques cycles avant que le résultat du branchement soit disponible et que les prédictions doivent continuer pendant ce temps, les poids du perceptron et la prédiction sont stockés dans des FIFOs lues/écrites à chaque cycle. [[File:Unité de prédiction de branchement basée sur des perceptrons.png|centre|vignette|upright=2|Unité de prédiction de branchement basée sur des perceptrons]] Rien de bien compliqué sur le principe, mais rappelons que le perceptron doit fournir un résultat en moins d'un cycle d'horloge, et cela tient compte de l'accès au BTB. C'est un temps très court, surtout pour un circuit qui implique des multiplications. Les multiplications prennent facilement deux à trois cycles d'horloge. Autant dire que si on ajoute le temps d'accès à une petite SRAM, pour récupérer le perceptron, c'est presque impossible. Mais quelques optimisations simples permettent de rendre le calcul plus rapide. Premièrement, la taille de la SRAM est réduite grâce à quelques économies assez simples. Notamment, le perceptron utilise des poids codés sur quelques bits, généralement un octet, parfois 7 ou 5 bits, guère plus. Les poids sont encodés en complément à 1 ou en complément à 2, là où les perceptrons normaux utilisent des nombres flottants. Rien que ces deux choix simplifient les circuits et les rendent plus rapides. Le désavantage d'utiliser peu de bits par poids est une perte mineure en termes de taux de prédiction, qui est plus que compensée par l'économie en portes logiques, qui permet d'augmenter la taille de la SRAM. D'autres optimisations portent sur le circuit de calcul. Par exemple, la multiplication <math>h_i \times w_i</math> est facultative. Vu que l'historique contient les valeurs 1 et -1, il suffit d'additionner le poids associé si la valeur est 1 et de le soustraire s'il vaut -1. On peut même simplifier la soustraction et se limiter à une complémentation à un (une inversion des bits du poids). En faisant cela, les circuits multiplieurs disparaissent et le circuit de prédiction se résume à un simple additionneur multi-opérande couplé à quelques inverseurs commandables. Certains perceptrons prennent en compte à la fois l'historique global et l'historique local pour faire leur prédiction. Les deux historiques sont simplement concaténés et envoyés en entrée du perceptron. Cela marche assez bien, car les perceptrons peuvent utiliser des historiques de grande taille sans problèmes. Par contre, il y a besoin d'ajouter une ''branch history table'' au circuit, ce qui demande des portes logiques en plus, mais aussi un temps de calcul supplémentaire (l'accès à cette table n'est pas gratuit). ===Les avantages et inconvénients des perceptrons pour la prédiction de branchements=== L'avantage des unités perceptron est qu'elles utilisent un nombre de portes logiques qui est proportionnel à la taille de l'historique, et non pas exponentiel. Cela permet d'utiliser un historique de grande taille, et donc d'obtenir un taux de bonnes prédictions très élevé. Leur désavantage est que leurs performances de prédiction sont moins bonnes à historique équivalent. Les unités perceptrons ont des taux de prédiction élevés, mais seulement quand certaines conditions mathématiques sont respectées par les historiques. La condition en question est que les historiques correspondants à un branchement pris et les historiques où ce même branchement est non-pris doivent être linéairement séparables. Heureusement, les branchements des programmes informatiques sont une majorité à respecter la condition de séparation linéaire, ce qui rend les perceptrons parfaitement adaptés à la prédiction de branchements. La notion de linéairement séparable est un peu difficile à comprendre. Pour l'expliquer, imaginez que les N bits de l'historique forme un espace à N-dimensions discrets. Chaque historique est un point dans cet espace N-dimensionnel et chaque bit sert de coordonnée pour se repérer dans cet espace. Une fonction est linéairement séparable si l'espace N-dimensionnel peut être coupé en deux par un hyperplan : d'un côté de ce plan se trouvent les historiques des branchements pris, de l'autre se trouvent les historiques des branchements non-pris. Cet hyperplan est défini par l'ensemble des points qui respecte l'équation suivante : : <math>w_0 + \sum_i (h_i \times w_i) = 0</math> Un exemple où cette condition est respectée est quand le résultat d'une prédiction se calcule avec un ET entre plusieurs branchements de l'historique. Si un branchement est pris quand plusieurs branchements de l’historique sont tous pris ou tous non-pris, la condition est respectée et le perceptron donnera des prédictions correctes. Un exemple où cette condition n'est pas respectée est un banal XOR. Prenons l'exemple d'un historique global de 2 bits. Imaginons que le branchement à prédire soit pris : soit quand le premier branchement de l'historique est pris et le second non-pris, soit quand le premier est non-pris et le second pris. Concrètement, la prédiction exacte se calcule en faisant un XOR entre les deux bits de l'historique. Dans ce cas, la condition "linéairement séparable" n'est pas respectée et le perceptron n'aura pas des performances optimales. Les unités perceptron utilisent idéalement un perceptron par branchement. Cela demande d'associer, l'adresse du branchement pour chaque perceptron, généralement en donnant un perceptron par entrée du BTB. Mais une telle organisation n'est pas possible en pratique, car elle demanderait d'ajouter un ''tag'' à chaque perceptron/entrée du BTB, ce qui demanderait beaucoup de circuits et de ressources matérielles. Dans les faits l'association entre un branchement et un perceptron se fait en utilisant les bits de poids faible de l'adresse de branchement. Mais faire cela entrainera l'apparition de phénomènes d'''aliasing'', comme pour les unités de prédiction à deux niveaux. Les techniques vues précédemment peuvent en théorie être adaptées sur les unités à perceptrons, dans une certaine mesure. Notons que les perceptrons sont des algorithmes ou des circuits qui permettent de classer des données d'entrée en deux types. Ici, la donnée d'entrée est l'historique global et la sortie du perceptron indique si le branchement prédit est pris ou non-pris. Ils ne sont donc pas adaptés à d'autres tâches de prédiction, comme pour le préchargement des lignes de cache ou la prédiction de l'adresse de destination d'un branchement, ou toute autre tâche d'exécution spéculative un tant soit peu complexe. Leur utilisation reste cantonnée à la prédiction de branchement proprement dit, guère plus. Par contre, les autres techniques vues plus haut peuvent être utilisées pour le préchargement des lignes de cache, ou d'autres mécanismes de prédiction plus complexes, que nous aborderons dans la suite du cours. ==La prédiction avec plusieurs unités de branchement distinctes== Il est possible de combiner plusieurs unités de prédiction de branchement différentes et de combiner leurs résultats en une seule prédiction. On peut par exemple combiner une unité de prédiction spécialisée dans les boucles, une unité de prédiction 2 bits basée sur des compteurs à saturation, une unité de prédiction statique et une unité ''gshare''. Tout le problème est de décider quelle unité a fait la bonne prédiction, comment combiner les résultats des différentes unités de prédiction. Soit on choisit un résultat qui parait plus fiable que les autres, soit on combine les résultats pour faire une sorte de moyenne des prédictions. La méthode la plus simple de choisir le résultat d'une unité de prédiction parmi toutes les autres. Pour implémenter le tout, il suffit d'un multiplexeur qui effectue le choix de la prédiction. Reste à commander le multiplexeur, ce qui demande un '''circuit méta-prédicteur''' qui sélectionne la bonne unité de prédiction. Il arrive que le circuit méta-prédicteur soit conçu avec les mêmes techniques que les unités de prédiction de branchement elle-mêmes. Dans le cas le plus simple, il s'agit d'un simple compteur à saturation, mis à jour suivant la concordance des prédictions avec le résultat effectif du branchement. Mais c'est loin d'être systématique. Une autre solution combine le résultat des différentes unités de prédiction avec une fonction mathématique adéquate. Avec un nombre impair d'unités de prédiction, une méthode assez efficace est de simplement prendre le résultat majoritaire. Si une majorité d'unités pense que le branchement est pris, alors on le considère comme pris, et comme non pris dans le cas contraire. Par contre, avec un nombre pair d'unités, cette méthode simple ne marche pas, il y a un risque d'égalité. L'unité de prédiction de branchement « '''''e-gskew''''' » utilise trois unités gshare et combine leurs résultats avec un vote à majorité. Les trois unités gshare utilisent des XOR légèrement différents, dans le sens où les bits de l'adresse du branchement choisi ne sont pas les mêmes dans les trois unités, sans compter que le résultat du XOR peut subir une modification qui dépend de l'unité de prédiction choisie. Autrement dit, la fonction de hachage qui associe une entrée à un branchement dépend de l'unité. [[File:Unité de prédiction « e-gskew ».png|centre|vignette|upright=2|Unité de prédiction « e-gskew ».]] Enfin, une dernière solution est que certaines unités de prédiction ont la priorité sur toutes les autres. Par exemple, prenons un processeur avec un ''return stack predictor'', une unité qui détecte les boucles et une unité de prédiction avec un historique. Si le ''return stack predictor'' détecte une instruction de retour de fonction, sa prédiction passera avant toutes les autres unités de prédiction. Et dans ce qui va suivre, c'est cette solution qui sera utilisé pour les unités de prédiction qu'on va étudier. ===La prédiction des branchements des boucles=== Les boucles sont à part, pour ce qui est de la prédiction de branchement. Rappelons qu'une boucle FOR/WHILE répète une série d'instruction N fois, le nombre N étant appelé le '''compteur de boucle'''. Ce qui fait que les branchements d'une boucle FOR sont pris n − 1 fois, la dernière exécution étant non prise. La conséquence est que les unités de prédiction usuelles sont assez mauvaises pour prédire les branchements des boucles. Par contre, la prédiction statique marche particulièrement bien comparé au reste. La prédiction statique prédit que les branchements des boucles sont toujours pris, ce qui donne des prédictions qui sont d'autant meilleures que la boucle est répétée un grand nombre de fois. Reste à gérer les branchements des boucles au mieux, en sachant cela. L'idée est d'utiliser plusieurs unités de prédiction séparées : une spécialisée dans les boucles et d'autres unités de prédiction. Uen unité de méta-prédiction se charge d'activer l'unité de prédiction des boucles dès qu'une boucle est détéctée. Les unités de prédiction modernes essayent de détecter les branchements des boucles afin de les mettre à part. En soi, ce n'est pas compliqué les branchements des boucles sont presque toujours des branchements descendants et réciproquement. De plus, ces branchements sont pris à chaque répétition de la boucle, et ne sont non-pris que quand on quitte la boucle. Le compteur de boucle analyse l'exécution des branchements, pour détecter les branchements des boucles. Un branchement est considéré comme un branchement de boucle si : il est pris N fois, avant de ne pas être pris une fois. Au moment où le branchement n'est plus pris, le ''Loop Predictor'' lui attribue une entrée dédié. L'entrée mémorise mémorise le nombre de fois que ce branchement a été pris, à savoir N, et est associée à un compteur. Le compteur est incrémenté/décrémenté à chaque exécution du branchement, et sa valeur est comparée à la valeur N. Quand le compteur atteint N+1, il est réinitialisé. Il s'agit d'une implémentation simple, mais est visiblement proche de celle utilisée sur le Pentium M, si j'en crois l'article "The Intel Pentium M Processor : Microarchitecture and Performance". Il faut noter que la boucle doit s'exécuter au moins une fois pour être détectée, et seules les exécutions suivantes sont prédites correctement. La boucle doit donc s'exécuter plusieurs fois, ce qui implique qu'elle est elle-même dans une boucle plus grande qu'elle. Une telle unité est donc capable de prédire spécifiquement les branchements de boucles FOR imbriquées, à savoir où une boucle FOR est dans une autre boucle FOR. Concrètement, cela permet de répéter la première boucle FOR plusieurs fois de suite, souvent un grand nombre de fois. Avec les boucles imbriquées, le compteur de boucle peut changer d'une répétition à l'autre, mais ce n'est que rarement le cas. Dans de nombreux cas, le compteur de boucle reste le même d'une répétition à l'autre. Ce qui fait qu'une fois la boucle exécutée une première fois, on peut prédire à la perfection les exécutions suivantes. Pour cela, il suffit de déterminer le compteur de boucle après la première exécution de la boucle, puis de le mémoriser dans un compteur. Lors des exécutions suivantes de la boucle, on compte le nombre de fois que le branchement de la boucle s'exécute : il est prédit comme pris tant que le compteur est différent du compteur de boucle, mais non-pris en cas d'égalité. ===Le prédicteur L-TAGE=== Il est possible d'utiliser plusieurs unités de prédiction, chacune utilisant des historiques de taille différente. Pour donner un exemple simple, on pourrait avoir une unité avec un historique de 2 bits, une autre avec un historique de 3 bits, une autre de 4 bits, etc. Une méta-unité de branchement prédit alors quelle taille d'historique donne les meilleurs résultats. Il est fréquent que les tailles d'historiques utilisées suivent une formule mathématique précise, appelée suite géométrique, ce qui est un mot bien barbare pour dire que l'on passe d'un historique au suivant en multipliant sa taille par une constante bien précise. Par exemple, on pourrait avoir des historiques de taille 2, 4, 8, 16, 32. C'est d'ailleurs le principe derrière les '''unités de branchement TAGE (''TAgged GEometric length predictor'')'''. Elles utilisent plusieurs registres d'historiques, chacun avec sa propre PHT, dont la taille suit une suite géométrique. Avec cette méthode, les branchements qui ont un motif répétitif sont alors parfaitement détectés, peu importe sa taille. Par exemple, un branchement avec un historique dont le cycle est pris, non-pris, non-pris, sera parfaitement prédit avec un historique de 3 bits, 6 bits, ou de 3*n bits, mais pas forcément avec un historique de 4 ou 8 bits. De ce fait, utiliser plusieurs unités de branchement avec plusieurs tailles d'historique fonctionne plutôt bien. Le prédicteur TAGE a été raffiné avec le temps, sur plusieurs points, que je ne vais pas détailler dans ce qui suit. Précisons cependant que les PHT sont structurées en caches, afin d'éliminer les phénomènes d'''aliasing''. L'ajout d'une unité de prédiction pour les boucles, a donné le '''prédicteur L-TAGE''', qui a lui-même été amélioré par la suite. Les unités de branchement TAGE sont les plus utilisées au moment où j'écris ces lignes (2026), et la majorité des processeurs x86 commerciaux utilisent une variante de ce prédicteur, à quelques exceptions près. ===La prédiction de branchement à deux niveaux=== Les processeurs modernes utilisent une prédiction de branchement à deux niveaux. Une première unité de branchement fournit des résultats rapides et peu précis, la seconde a des résultats plus précis mais fournit ses résultats après quelques cycles d'horloge. La première unité envoie les adresses prédites au cache d'instruction, pour charger les instructions prédites et met à jour le ''program counter'' en conséquence. La seconde envoie les adresses prédites à l'étage de décodage d'instruction, histoire d'annuler des branchements considérés à tort comme pris/non-pris. Il y a donc une unité de prédiction rapide, dont le résultat est corrigé par une seconde unité. La seconde unité a le dernier mot, elle corrige ou annule les prédictions de la première. Ce qui lui vaut le nom, en anglais, d'''overiding predictor''. La correction se fait simplement en corrigeant le ''program counter'' et en forçant le décodeur à émettre des NOPs pendant quelques cycles, le temps d'éliminer les instructions chargées à tort. [[File:Double BTB sur les CPU modernes.png|centre|vignette|upright=2|Double BTB sur les CPU modernes.]] Les deux unités de prédiction n'utilisent pas le même algorithme. Par exemple, la microarchitecture Silvermont d'Intel utilisait deux unités de prédiction : une unité rapide basée sur des compteurs à saturation, et un prédicteur ''gshare'' lent. Les deux unités de prédiction utilisent des BTB séparés, de taille différente, au moins deux. Vu que les BTB sont des caches, on les appelle des '''BTB de niveau L1 et L2'''. Par exemple, les processeurs Bulldozer d'AMD disposent de deux BTBs : un BTB L1 de 512 entrées, et un BTB L2 de 5120 entrées. Le BTB de niveau L2 est souvent un cache de victime, qui mémorise les branchements retirés de la BTB de niveau L1. La BTB pour les branchements indirects vient en plus de ces deux BTBs, elle complète souvent la BTB de niveau L2. Le ''return stack predictor'' vient en plus des BTB L1 et L2, mais sa petite taille et sa rapidité font qu'il complète l'unité/BTB de niveau L1. Quelques processeurs ont trois niveaux de BTB, avec un niveau L0, L1 et L2. La BTB L0 aliment le cache d'instruction, alors que la L1 et la L2 préviennent les décodeurs d'instruction. La différence entre la L1 et la L2 est que la L2 met plus de temps à prévenir les décodeurs. Les branchements peuvent donc être annulés/forcés à deux étages de décodage différents. Pour donner quelques exemples, les processeurs Zen d'AMD disposent de plusieurs BTBs, avec des niveaux L0, L1 et L2. Le tableau ci-dessous vous donne la taille et la latence de chaque niveau de BTB. La BTB de niveau L0 alimente le cache d'instruction, la L1 permet d'annuler les instructions au premier cycle de décodage, la BTB L2 annule les instructions vers le milieu/fin du décodage. {|class="wikitable" |- ! rowspan="3" | Zen 2 | 16 || 0 |- | 512 || 1 |- | 7168 || 4 |- ! rowspan="3" | Zen 3 | 16 || 0 |- | 1024 || 1 |- | 6656 + 1536 (branchements indirects)|| 4 |} ==Les optimisations de la prédiction de branchements== La prédiction de branchement est complexe et toute économie est bonne à prendre. Pour améliorer les performances, ou simplement améliorer l'utilisation du BTB et des PHT, diverses techniques ont été inventées. Ces techniques marchent quel que soit l'unité de prédiction prise en compte. Elles peuvent s'appliquer aussi bien aux unités basées sur des perceptrons, que sur des unités à deux niveaux ou des unités de prédiction dynamique en général. Ces techniques sont assez variés : certaines profitent du fait que de nombreux branchements sont biaisés, d'autres tentent de réduire les interférences entre branchements sans utiliser l'historique global/local, etc. ===Le filtrage de branchements=== Une première optimisation permet d'économiser l'usage des différentes ressources matérielles, comme la BTB ou les PHT, en les réservant aux branchements non-biaisés. Idéalement, les branchements biaisés peuvent être prédits en utilisant des techniques très simples. D'où l'idée d'utiliser deux unités de prédiction spécialisées : une pour les branchements biaisés et une autre plus complexe. L'unité de prédiction pour les branchements biaisés est une unité de prédiction à 1 bit qui indique si le branchement est pris ou non, ce qui suffit largement pour de tels branchements. Cette technique s'appelle le '''filtrage de branchement''' et son nom est assez parlant. On filtre les branchements biaisés pour éviter qu'ils utilisent des PHT et d'autres ressources qui gagneraient à être utilisées pour des branchements plus difficiles à prédire. Appliquer cette idée demande de reconnaître les branchements fortement biaisés d'une manière ou d'une autre, ce qui est plus facile à dire qu'à faire. Une solution similaire enregistre quels branchements sont biaisés dans le BTB, et utilise cette information pour faire des prédictions. ===L'usage de fonctions de hashage pour indexer les diverses SRAM/tables=== Plus haut, nous avons vu que les unités de prédiction de branchement contiennent des structures qui sont adressées par l'adresse de branchement. Tel est le cas du ''Branch Target Buffer'', de certaines PHT globales ou locales, de la ''Branch History Table'' qui stocke les historiques locaux, ou encore de la SRAM des poids des unités à base de perceptrons. En pratique, ces structures sont adressées non pas par l'adresse de branchement complète, mais pas les bits de poids faible de cette adresse. Cela a pour conséquence l'apparition d'un ''aliasing'' lié au fait que ces structures vont confondre deux branchements pour lesquels les bits de poids faible de l'adresse sont identiques. Il est possible d'utiliser autre chose que les bits de poids faible de l'adresse du branchement, afin de limiter les interférences. Et les possibilités sont multiples. Les possibilités en question s'inspirent des traitements effectués sur les adresses des banques dans les mémoires évoluées. Une première solution serait de faire un XOR entre les bits de poids faible de l'adresse du branchement, et d'autres bits de cette même adresse. Ainsi, deux branchements éloignés en mémoire donneraient des résultats différents, même si leurs bits de poids faible sont identiques. Une autre possibilité serait de diviser l'adresse du branchement par un nombre et de garder le reste. Ce calcul de modulo n'est en soi pas très différent du fait de conserver seulement les bits de poids faible. Conserver les N bits de poids faible consiste en effet à prendre le modulo par 2^N de l'adresse. Ici, l'idée serait de faire un modulo par un nombre P, qui serait un nombre premier proche de 2^N. Le fait que le nombre soit premier limite les cas où deux adresses différentes donneraient le même reste, ce qui réduit l'''aliasing''. Le fait de prendre un nombre proche de 2^N pour une entrée de N bits est que le résultat reste assez proche de ce qu'on obtiendrait en gardant les bits de poids faible, cette dernière étant une solution pas trop mauvaise. D'autres possibilités similaires se basent sur des réductions polynomiales ou d'autres astuces impliquant des nombres premiers. L'efficacité de ces méthodes dépend grandement de la taille de la SRAM/table considérée, des adresses des branchements et de beaucoup d'autres paramètres. La réduction des interférences par telle ou telle méthode dépend aussi de l'unité de prédiction considérée. Les résultats ne sont pas les mêmes selon que l'on parle d'une unité à perceptrons ou d'une unité à deux niveaux ou d'unités à base de compteurs à saturation, ou que l'on parle du BTB. Par contre, toutes les méthodes ne sont pas équivalentes en termes de temps de calcul ou de portes logiques. Autant la première solution avec un XOR ajoute un temps de calcul négligeable et quelques portes logiques, autant les autres méthodes requièrent l'ajout d'un diviseur très lent et gourmand en portes logiques pour calculer des modulos. Elles ne sont généralement pas praticables pour ces raisons, le temps de calcul serait trop élevé. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Le pipeline | prevText=Le pipeline | next=Les optimisations du chargement des instructions | nextText=Les optimisations du chargement des instructions }} </noinclude> h6lqm56vdaw45owpohgtfucbi6bsza7 774408 774407 2026-10-07T22:17:01Z Mewtow 31375 /* Le prédicteur L-TAGE */ 774408 wikitext text/x-wiki Les branchements sont pris en charge par plusieurs étages du pipeline. Exécuter un branchement demande de savoir deux choses : s'il est pris ou non, quelle est l'adresse de destination. L'adresse de destination est localisée grâce au mode d'adressage du branchement. Avec le mode d'adressage direct, elle est extraite de l'instruction par le séquenceur. Avec l'adressage indirect, elle est lue dans un registre. Avec les branchements relatifs, elle est calculée via un additionneur, qui additionne un décalage au ''program counter''. L'additionneur peut être soit dans l'étage de ''PC Update'', soit dans l'unité de calcul de l'étage d'exécution. Passons maintenant au résultat du branchement, à savoir s'il est pris ou non. Pour savoir si le branchement est pris, il faut distinguer les branchements conditionnels et inconditionnels. Pour rappel, les branchements inconditionnels regroupent les appels de fonction, les simples instructions de saut, et quelques autres. Ils sont toujours pris, ils s'exécutent toujours. À partir du moment où le décodeur a décodé un branchement inconditionnel, il sait que le branchement est pris. Pour les branchements conditionnels, ils sont pris seulement si une condition précise est respectée. On sait s'ils sont exécutés une fois que la condition est calculée par l'unité de calcul, ou par une unité de branchement spécialisée. [[File:Gestion des branchements sur un pipeline.png|centre|vignette|upright=2.0|Gestion des branchements conditionnels sur un pipeline.]] Sur le schéma précédent, les branchements inconditionnels ne sont pas montrés. Pour cela, il faudrait rajouter une connexion du décodeur vers le multiplexeur de branchement. De plus, il faudrait aussi rajouter de quoi retarder l'exécution du branchement seulement une fois que l'adresse est connue. Par exemple, elle est connue un cycle d'horloge plus si l'adresse est lue depuis les registres, comparé à un branchement direct. ==Les branchements et les exceptions imprécises/précises== Un branchement est censé altérer le ''program counter'' immédiatement, en un cycle d'horloge. Mais sur un pipeline, Le branchement fait bien reprendre le processeur au bon endroit, mais avec du retard. La raison est qu'un branchement met plusieurs cycles pour s'exécuter. Il faut plusieurs cycles pour déterminer quelle est l'adresse de destination, temps qui dépend de là où est lue l'adresse. Et on sait si le branchement est pris seulement au niveau du décodeur, voire au niveau de l'unité de calcul. Le branchement doit traverser plusieurs étages avant qu'on connaisse son résultat. Il y a bien 3 à 4 cycles avant cela. Et pendant ce temps de trajet, des instructions seront chargées dans le pipeline, potentiellement à tort. Si le branchement n'est pas pris, ce n'est pas grave : les instructions n'ont pas été chargées à tort. Mais dans le cas contraire, les instructions n'auraient pas dû rentrer dans le pipeline. Le problème est similaire à celui observé avec les exceptions précises/imprécises. La différence est que les branchements sont des instructions machines, pas les exceptions matérielles. Mais le problème des instructions chargées à tort est le même dans les grandes lignes. Une différence est que les branchements sont résolus plus ou moins au même étage, alors que les exceptions peuvent survenir dans tout le pipeline. Une autre différence, bien plus importante, est que les branchements non-pris ne chargent pas d'instructions à tort et qu'il faut faire la différence entre branchement pris et non-pris. Dans la suite, nous parlerons d'instructions fautives pour parler des instructions chargées à tort. Pour corriger ce problème, il existe une solution logicielle. Le compilateur fait suivre les branchements par des instructions qui ne font rien, des NOP (No OPeration) : c'est ce qu'on appelle un '''délai de branchement'''. L'idée est que toutes les instructions chargées à tort sont des NOPs, qui ne font rien. Mais le nombre de NOP à ajouter dépend du pipeline du processeur, ce qui peut poser des problèmes de compatibilité. Mais d'autres solutions, matérielles cette fois, sont utilisées de nos jours. ===L'annulation des instructions au décodage=== Les branchements inconditionnels sont dans un cas plus favorable : ils sont identifiés dans l'unité de décodage et sont toujours pris. D'autres branchements sont dans ce cas : les appels de fonctions, les interruptions logicielles. Avec eux, le décodeur annule les instructions fautives, il les remplace par des instructions NOP. L'étage de décodage sait que les instructions suivant un branchement inconditionnel ont été chargées à tort. Précisément N instructions, avec N le nombre d'étages entre le chargement et le décodage (inclus). Les N instructions suivantes sont tout simplement annulés, elles sont remplacées par des NOPs. L'implémentation demande juste un simple compteur et quelques circuits annexes. Cependant, la technique ne marche pas du tout pour les branchements conditionnels. En effet, on ne sait pas s'ils sont pris ou non à l'étage de décodage. Du moins pas avec un pipeline normal. Mais certains processeurs, basés sur le pipeline RISC classique, calculaient les branchements pendant l'étape de décodage, dans une ALU spécialisée qui travaille en parallèle de l'unité de décodage. Le défaut de cette technique est que l'ALU en question doit lire les registres. Pour rappel, sur ce pipeline, l'étape de décodage effectue la lecture des opérandes dans les registres, en parallèle de l'unité de décodage. Mais ce qu'on n'a pas dit dans le chapitre précédent, c'est qu'elle effectue aussi le calcul des branchements dans une mini-ALU séparée ! Ce faisant, le résultat du branchement est connu dès la fin de l'étape de décodage. ===L'usage du système d'exceptions précises pour les branchements conditionnels=== La solution gère le problème en empêchant les instructions chargées à tort d'enregistrer leurs résultats, afin qu'elles n'aient pas le moindre effet sur l'état du processeur. Le hardware pour les exceptions précises, décrit quelques paragraphes avant, est réutilisé pour cela. Les branchements conditionnels peuvent être pris ou non-pris et on doit tenir compte de ces deux cas. Si le branchement n'est pas pris, les instructions n'ont pas été chargées à tort, elles peuvent enregistrer leur résultat dans les registres. Par contre, ce n'est pas le cas pour les branchements pris, les mécanismes d'exceptions précise doivent alors d'activer. Pour cela, la meilleure solution est que l'unité de calcul génère un indicateur d'exception si le branchement n'est pas pris. L'ALU effectue la comparaison et génère ou non l'indicateur d'exception adéquat suivant le résultat de celle-ci. L'idée est d'utiliser un indicateur d'exception particulier, qui précise qu'un branchement a été pris, signe que des instructions ont été chargées à tort. L'indicateur est propagé dans le pipeline, jusqu'à atteindre l'étage d'enregistrement dans les registres. Cet étage décide s'il faut autoriser ou non les écritures qu'il recoit, pour annuler les instructions chargées à tort. Le ''program counter'' est corrigé immédiatement, simplement en exécutant le branchement. Reste à savoir qui génère cet indicateur d'exception de branchement pris. Diverses optimisations visent à éliminer les instructions chargées à tort, à faire en sorte que des instructions utiles soient chargées. Le chapitre suivant va détailler ces optimisations dans le détail, qui sont regroupées sous le terme de prédiction de branchement. Elles ne fonctionnent pas pour les exceptions précises, seulement les branchements. ===La prédiction de branchement=== Les techniques précédentes ont pour défaut que de nombreux cycles d'horloge sont gâchés à cause des instructions fautives. Pour éviter ce gâchis, les concepteurs de processeurs ont inventé l'''exécution spéculative de branchement'', qui consiste à deviner l'adresse de destination du branchement et charger les bonnes instructions dès le départ. Le but est de ne pas charger d'instruction à tort, en prédisant quelles seront les bonnes instructions à charger. Et cela demande de résoudre trois problèmes : * reconnaître les branchements ; * savoir si un branchement sera exécuté ou non : c'est la '''prédiction de branchement''' ; * dans le cas où un branchement serait exécuté, il faut aussi savoir quelle est l'adresse de destination : c'est la '''prédiction de l'adresse de destination''' d'un branchement, aussi appelée ''branch target prediction''. Pour résoudre le second problème, le processeur contient un circuit qui déterminer si le branchement est pris (on doit brancher vers l'adresse de destination) ou non pris (on poursuit l’exécution du programme immédiatement après le branchement) : c'est l''''unité de prédiction de branchement'''. La prédiction de direction de branchement est elle aussi déléguée à un circuit spécialisé : l’'''unité de prédiction de direction de branchement'''. C'est l'unité de prédiction de branchement qui autorise l'unité de prédiction de destination de branchement à modifier le ''program counter''. Dans certains processeurs, les deux unités sont regroupées dans le même circuit. [[File:Relation entre prédiction de branchement et de direction de branchement.png|centre|vignette|upright=2|Relation entre prédiction de branchement et de direction de branchement.]] ==Les erreurs de prédiction de branchement== Le processeur peut parfaitement se tromper en faisant ses prédictions, ce qui charge des instructions par erreur dans le pipeline. Dans ce cas, on parle d''''erreur de prédiction'''. Les erreurs de prédiction sont corrigées par le système d'exception précises, ou un équivalent. En clair, les instructions chargées à tort se propagent jusqu'à la sortie du pipeline, où leurs résultats sont alors annulés. N'importe quelle technique de gestion des exceptions précise fait le travail, mais les processeurs modernes utilisent un tampon de réordonnancement (''reorder buffer'') qu'on détaillera dans quelques chapitres. Détecter les erreurs de prédiction demande d'ajouter un circuit dans le processeur : l’''unité de vérification de branchement'' (''branch verification unit''). Elle génère un indicateur d'exception, qui est géré par le système d'exception précise. Pour générer l'indicateur, elle compare l'adresse de destination prédite et celle calculée en exécutant le branchement. Pour cela, l'adresse prédite doit être propagée dans le pipeline jusqu’à ce que l'adresse de destination du branchement soit calculée. [[File:Unité de vérification de branchement.png|centre|vignette|upright=2|Unité de vérification de branchement]] ===La pénalité de mauvaise prédiction=== Le temps pour vider le pipeline des instructions fautives dépend du pipeline. La dernière instruction à être chargée dans le pipeline le sera en même temps qu'on détecte l'erreur de prédiction. Il faudra attendre que cette instruction quitte le pipeline, et donc qu'elle traverse tous les étages. Il y a donc un temps d'attente pour collecter les résultats de ces instructions et la décision de ne pas enregistrer leurs résultats dans les registres Le nombre d'instructions chargées à tort dépend de où est détectée l'erreur de prédiction. Plus le résultat du branchement est connu tard, plus le nombre d'instructions chargées à tort sera grand, et plus le nombre de cycles d'horloge gâchés le sera aussi. Le nombre de cycles gâchés s'appelle la '''pénalité de mauvaise prédiction'''. Elle correspond précisément au nombre de cycles/étages entre le premier étage du pipeline, et l'étage où le branchement est résolu (on connait l'adresse à laquelle brancher). Plus celui-ci est éloigné du début du pipeline, plus la pénalité est importante. [[File:MIT 6.823 Computer System Architecture, Fall 2005 - P-3 vs. P-4 Pipelines - 4473425702.jpg|centre|vignette|upright=2.5|Comparaison de la pénalité de mauvaise prédiction entre le Pentium 3 et le Pentium 4 d'Intel.]] Elle est généralement d'autant plus longue que le pipeline lui-même est long. Les concepteurs de processeurs font donc face à un compromis : un pipeline long permet d'exécuter plus d'instructions en parallèle, mais cela augmente aussi la pénalité de mauvaise prédiction. Un pipeline de 10-15 étapes est un bon compromis. Les pipelines plus longs ont besoin d'une prédiction de branchement élaborée pour fonctionner correctement. Plus la prédiction de branchement est performante, plus le pipeline peut être long. Les pénalités de mauvaise prédiction seront plus importantes, mais les mauvaises prédictions seront plus rares, ce qui compense complétement. ===La ''minimal control dependency''=== En utilisant une technique du nom de ''minimal control dependency'', seules les instructions qui dépendent du résultat du branchement sont supprimées du pipeline en cas de mauvaise prédiction, les autres n'étant pas annulées. Sans ''minimal control dependency'', toutes les instructions qui suivent un branchement dans notre pipeline sont annulées. Pourtant, certaines d'entre elles pourraient être utiles. Prenons un exemple : supposons que l'on dispose d'un processeur de 31 étages (un Pentium 4 par exemple). Supposons que l'adresse du branchement est connue au 9éme étage. On fait face à un branchement qui envoie le processeur seulement 6 instructions plus loin. Si toutes les instructions qui suivent le branchement sont supprimées, les instructions en rouge sont celles qui sont chargées incorrectement. On remarque pourtant que certaines instructions chargées sont potentiellement correctes : celles qui suivent le point d'arrivée du branchement. Elles ne le sont pas forcément : il se peut qu'elles aient des dépendances avec les instructions supprimées. Mais si elles n'en ont pas, alors ces instructions auraient dûes être exécutées. Il serait donc plus efficace de les laisser enregistrer leurs résultats au lieu de les ré-exécuter à l’identique un peu plus tard. Ce genre de choses est possible sur les processeurs qui implémentent une technique du nom de ''Minimal Control Dependancy''. En gros, cette technique fait en sorte que seules les instructions qui dépendent du résultat du branchement soient supprimées du pipeline en cas de mauvaise prédiction. [[File:Minimal cntrol dependency.png|centre|vignette|upright=2|Minimal control dependency]] ==La reconnaissance des branchements== Pour prédire des branchements, le processeur doit faire la différence entre branchements et autres instructions, dès l'étage de chargement. Or, un processeur « normal », sans prédiction de branchement, ne peut faire cette différence qu'à l'étage de décodage, et pas avant. Les chercheurs ont donc dû trouver une solution. La première solution se base sur les techniques de prédécodage vues dans le chapitre sur le cache. Pour rappel, ce prédécodage consiste à décoder partiellement les instructions lors de leur chargement dans le cache d'instructions et à mémoriser des informations utiles dans la ligne de cache. Dans le cas des branchements, les circuits de prédécodage peuvent identifier les branchements et mémoriser cette information dans la ligne de cache. Une autre solution mémorise les branchements déjà rencontrés, dans un cache dédié. Ce cache mémorise l'adresse (le ''program counter'') des branchements déjà rencontrés : il s'appelle le '''tampon d’adresse de branchement''' (''branch adress buffer''). Il est intégré à l'unité de prédiction de branchement, mais est alimenté par le décodeur d'instruction. Reconnaitre un branchement demande l'implication du décodeur d'instruction, qui sait si l'instruction qu'il vient de décoder est un branchement ou non. Il peut même savoir si le branchement est conditionnel, inconditionnel, un appel de retour de fonction, etc. Lorsque le décodeur d'instruction reconnait un branchement, il ajoute ce branchement dans le cache Le tapon d'adresse de branchement contient une adresse mémoire par branchement, et éventuellement d'autres informations. Et pour comprendre quelle adresse est mémorisée, il faut faire attention à un détail. Les explications qui vont suivre vont faire intervenir deux adresses. * La première est l''''adresse de destination''' du branchement, à savoir l'adresse à laquelle le processeur doit reprendre son exécution si le branchement est pris. * L'autre adresse est l''''adresse du branchement''' lui-même. Elle correspond à sa position en mémoire RAM. L'adresse du branchement est la valeur du ''program counter'' quand le branchement est chargé dans le cache, alors que l'adresse de destination est celle du ''program Counter'' une fois le branchement exécuté. Il faudra faire bien attention à ne pas confondre les deux adresses dans ce qui suit. L'adresse du branchement est celle qui est utilisée pour la reconnaissance du branchement. Le tampon d'adresse de branchement mémorise, pour chaque branchement, son adresse de branchement (d'où son nom). À chaque cycle d'horloge, l'unité de chargement envoie le ''program counter'' en entrée du tampon d’adresse de branchement. Un succès dans ce cache indique qu'un branchement déjà rencontré est ré-executé, on peut utiliser la prédiction de branchement. Dans le cas contraire, la prédiction de branchement n'est pas utilisée, et l'instruction est chargée normalement. ==La prédiction de l'adresse de destination== La '''prédiction de l'adresse de destination d'un branchement''' détermine l'adresse de destination d'un branchement. Rappelons qu'il existe plusieurs modes d'adressage pour les branchements et que chacun d'entre eux précise l'adresse de destination à sa manière. Suivant le mode d'adressage, l'adresse de destination est soit dans l'instruction elle-même (adressage direct), soit calculée à l’exécution en additionnant un ''offset'' au ''program counter'' (adressage relatif), soit dans un registre du processeur (branchement indirect), soit précisée de manière implicite (retour de fonction, adresse au sommet de la pile). : Histoire de simplifier la terminologie, je parlerais parfois de '''prédiction de destination''' pour parler de la ''prédiction de l'adresse de destination d'un branchement''. Pour rappel, les branchements se classent en trois types : * les branchements directs pour lequel l'adresse de destination est toujours la même ; * les branchements indirects où l'adresse de destination est dans un registre et peut changer ; * les instructions de retour de fonction. Les branchements directs sont facilement prévisibles, vu que l'adresse vers laquelle il faut brancher est toujours la même. La prédiction des branchements directs et relatifs se fait globalement de la même manière, ce qui fait que nous ferons la confusion dans ce qui suit. Pour les instructions de retour de fonction, une prédiction parfaite est possible. Et c'est ce qui explique qu'elles sont traitées à part, par une unité de branchement dédiée. Pour les branchements indirects, vu que cette adresse change, la prédire celle-ci est particulièrement compliqué (quand c'est possible). Les branchements indirects sont eux aussi traités par une unité de prédiction dédiée rien que pour eux. ===La prédiction de destination pour les branchements directs=== Lorsqu'un branchement est exécuté, on mémoriser l'adresse de destination et la réutiliser lors d'exécutions ultérieures du branchement. Cette technique marche à la perfection pour les branchements directs, pour lesquels cette adresse est toujours la même, mais donne des résultats mitigés pour les branchements indirects. Pour mémoriser l'adresse de destination, on utilise un cache qui mémorise les correspondances entre l'adresse du branchement et l'adresse de destination. Ce cache est appelé le '''''branch target buffer''''' en anglais, qui sera abrévié en BTB dans ce qui suit. Le BTB est une amélioration du tampon d'adresse de branchement vu plus haut, auquel on aurait ajouté les adresses de destination des branchements. Le BTB est utilisé comme suit : on envoie en entrée l'adresse du branchement, le BTB répond au cycle suivant en précisant : s’il reconnaît l'adresse d'entrée, et quelle est l'adresse de destination si c'est le cas. Le BTB peut être un cache totalement associatif, associatif par voie, ou directement adressé, mais ces derniers sont les plus fréquents. Les BTB sont généralement des caches de type directement adressés, où seuls les bits de poids faible de l'adresse du branchement adressent le cache, le reste de l'adresse est tout simplement ignoré. Il existe aussi des BTB qui sont construits comme les caches associatifs à plusieurs voies, mais ceux-ci impliquent généralement la présence de ''tags'', ce qui fait qu'ils sont un peu moins fréquents. [[File:Branch target buffer directement adressé.png|centre|vignette|upright=2|Branch target buffer directement adressé.]] Comme pour tous les caches, un accès au BTB peut entraîner un défaut de cache, c’est-à-dire qu'un branchement censé être dans le BTB n'y est pas. Comme pour les autres caches, les défauts de cache peuvent se classer en trois types : les défauts de cache à froid (''cold miss'') causés par la première exécution d'un branchement, les défauts liés à la capacité du BTB/cache, et les défauts par conflit d'accès au cache. Les défauts liés à la capacité du BTB sont les plus simples à comprendre. La capacité limitée du BTB fait que d'anciens branchements sont éliminées pour laisser la place à de nouveaux. En conséquence, ces branchements peuvent donner des erreurs de prédiction si leur correspondance a été éliminée du cache. On peut réduire des défauts en augmentant la taille du BTB. Les défauts liés aux conflits d'accès au BTB sont eux beaucoup plus intéressants, car la conception du BTB est assez spéciale dans la manière dont sont gérés ce genre de défauts. Le BTB est un cache partiellement associatif, ce qui fait que des défauts de cache par conflit d'accès peuvent avoir lieu. Pour rappel, les défauts par conflit d'accès ont lieu quand deux adresses mémoires se voient attribuer la même ligne de cache. Pour un BTB, cela correspond au cas où deux branchements se voient attribuer la même entrée dans le BTB, ce qui porte le nom d’''aliasing''. Ne pas détecter ce genre de situation sur un cache normal entraînerait des problèmes : la donnée lue/écrite ne serait pas forcément la bonne. Les caches normaux utilisent un système de tags pour éviter ce problème et on s'attendrait à ce que les BTB fassent de même. Mais pour un BTB, l'absence de ''tags'' n'est pas un problème : la seule conséquence est une augmentation du taux de mauvaises prédictions, dont les conséquences en termes de performance peuvent être facilement compensées. Les BTB peuvent donc se passer de tags, ce qui a de nombreux avantages : le circuit est plus rapide, prend moins de portes logiques, consomme moins d'énergie, etc. Ces économies de portes logiques et de performances peuvent être utilisées pour augmenter la taille du BTB, ce qui compense, voire surcompense les mauvaises prédictions induites par l’''aliasing''. Mais ce n'est pas systématique : d'autres BTBs utilisent des ''tags'' pour améliorer le taux de prédiction. Nous ferons donc la différence entre '''BTB tagués''' et '''BTB non-tagués'''. Des processeurs se passent de BTB. À la place, ils mémorisent les correspondances entre branchement et adresse de destination dans les bits de contrôle du cache d'instructions. Mais nous verrons cela en détail dans le chapitre suivant. ===La prédiction de destination pour les branchements indirects=== Un BTB classique peut fonctionner pour les branchements indirects. Son seul défaut est qu'il mémorisera la dernière adresse de destination prise par le branchement. Et cela peut fonctionner pour les branchements indirects fortement biaisés, qui ont presque toujours la même adresse de destination. Mais la majorité des branchements indirects change d'adresse de destination régulièrement. Tous les processeurs commerciaux datant d'avant le Pentium M utilisaient un BTB normal pour prédire les branchements indirects. Les processeurs qui les ont suivi sont capables de prédire l'adresse de destination d'un branchement indirect. , grâce à une '''unité de prédiction des branchements indirects''', séparée de l'unité de prédiction de branchement normale. Elle utilise un BTB amélioré, qui mémorise plusieurs adresses de destination pour un seul branchement, en compagnie d'informations pour prédire laquelle est la bonne. Il est possible d'utiliser un BTB unique, pour tous types de branchements, appelé un '''BTB unifié'''. Un BTB unifié a plusieurs adresse de destination par entrée du BTB. Mais une seule est utilisée pour les branchements directs. Vu que les branchements directs sont majoritaires, cela fait beaucoup d'entrées inutilisées. En conséquence, elle est tout sauf optimale, et n'est presque jamais utilisée en pratique. {|class="wikitable" |+ Branch Taarget Buffer unifié |- ! Adresse du branchement !! Informations de prédiction !! Adresse de destination !! Seconde adresse de destination |- | 0x54462146 || 11101011 || 0x52495674 || 0x24987461 |- | 0x54142446 || 11101101 || 0x15646956 || x0x4158746 |- | 0x25456542 || 10011101 || 0x52495674 || 0x24157461 |- | 0x15455855 || 11010101 || 0x52485754 || 0x24024511 |- | ... || ... || ... || ... |- | ... || ... || ... || ... |- | 0x54462146 || 1001101 || 0x52495674 || 0x24987461 |} Une seconde solution utilise un BTB séparé, distinct du BTB pour les branchements directs. En séparant les deux, on a un BTB simple qui ne mémorise qu'une adresse de destination par branchement, et un BTB pour branchements indirects avec plusieurs adresses de destination par branchement. [[File:BTB avec prédiction de branchements indirects.png|centre|vignette|upright=2.5|BTB avec prédiction de branchements indirects.]] L'idée est que l'on économise des circuits à nombre d'entrées égales. Par exemple, comparons ce que cela donne avec 64 entrées. D'un côté, on a un BTB à 64 entrées, capable de gérer deux adresse de destination par branchement. En pratique, la majorité des branchements sont des branchements directs, qui n'utiliseraient qu'une seule case mémoire. Les circuits du BTB seraient donc sous-utilisés. De l'autre, on a un BTB à 48 entrées pour branchements directs, et un second BTB avec 16 entrées, capable de mémoriser deux adresses par branchements. Le premier BTB utilise moins de circuits, et est utilisé à pleine capacité, il ne gaspille pas de circuits pour mémoriser des adresses de destination vides. Par contre, on perd un peu en flexibilité. L'organisation précédente marche bien si 1/4 des branchements rencontrés sont des branchements indirects. Mais si le ratio est différent, la performance est légèrement sous-optimale. Attention, cela ne veut pas dire qu'il y aura des entrées vides dans un BTB, non. Simplement, le premier BTB risque de manquer de place pour les branchements directs récents, alors que les branchements indirects seront eux bien prédits, même les anciens. À l'opposé, un BTB unique permet de mémoriser les 64 derniers branchements, peu importe qu'ils soient indirects ou non. En clair : un BTB unique est plus flexible, ce qui donne un gain de performance dans certaines situations. Cependant, cet avantage théorique ne tient pas compte de l'économie en circuits, qui peut être utilisée pour faire grossir le BTB pour branchements directs. Par exemple, à budget à transistor équivalent, on a le choix entre BTB à 64 entrées uniques, et un BTB pour branchements directs de 80 entrées avec un BTB indirect de 16 entrées. Le fait qu'un BTB soit plus gros permet des gains de performances qui compensent la perte de flexibilité. On peut ainsi avoir le meilleur des deux monde, à savoir une économie de circuits et les gains en performance qui en découlent, malgré une petite perte de flexibilité. Une troisième solution utilise un BTB normal, mais réserve plusieurs entrées pour un branchement indirect. Par exemple, un branchement indirect qui a trois adresses de destination prendra trois entrées : une par adresse de destination. Mais quelques difficultés surviennent alors. Comment placer trois copies d'un même branchement, alors que ces trois copies ont le même ''program counter'' ? Et que faire pour savoir quelle copie sera prise ? Que faire si l'unité de prédiction de branchement prédit les trois copies comme prises ? La technique du '''''Program Counter'' virtuel''' (''Virtual Program Counter'') résoud ces problèmes d'une manière assez élégante. L'idée est la même que précédemment, à savoir qu'il y a une copie du branchement par adresse de destination, dans le BTB. Mais ces copies n'ont pas exactement le même ''Program counter''. Chaque copie se voit attribuer un ''Program Counter'' virtuel. Et comprendre pourquoi demande de comprendre l'idée sous-jacente. La technique a été inspirée par une optimisation des compilateurs, qui transforme un branchement indirects en une séquence de branchements conditionnels. Par exemple, un branchement indirects qui peut brancher vers les adresses A, B et C est transformé en trois branchements conditionnels : un qui branche vers A, un autre qui branche vers B et un autre vers C. Elle ne marche pas pour tous les branchements indirects, elle demande des circonstances particulières pour fonctionner. Mais l'idée est adaptée pour le ''design'' de l'unité de prédiction de branchement. L'idée est qu'un branchement indirect est déroulé en plusieurs branchements dans le BTB. Les branchements sont traités en séquence : l'unité de prédiction prédit si le premier branchement est pris ou non. S'il est pris, son adresse de destination est envoyée au cache d'instruction. S'il n'est pas pris, l'unité de prédiction tester la seconde copie du branchement, et rebelote. Il teste toutes les copies, une par une, et s’arrête quand il tombe sur un branchement pris. Si aucune copie n'est prise, alors le branchement est prédit comme non-pris. Première remarque : il s'agit d'un processus itératif, où on test un branchement à la fois. Et oui, c'est une prédiction par cycle d'horloge. Prédire un branchement indirect peut donc demander plusieurs cycles d'horloge. Mais ce n'est pas un problème, car la prédiction peut être utilisée par l'unité de décodage, pour annuler les instructions chargées à tord. Deuxième remarque : comment passer d'un branchement à l'autre ? La première copie a un ''program counter'' normal, qui correspond à l'adresse du branchement. La seconde a un ''program counter'' qui est calculé à partir de cette première adresse, la troisième copie a une adresse calculée à partir de la seconde, etc. Concrètement, n'importe quelle fonction un tant soit peu simple suffit, tant qu'elle est déterministe. L'article qui a proposé cette techniques (''Virtual Program Counter (VPC) Prediction : Very Low Cost Indirect Branch Prediction Using Conditional Branch Prediction Hardware'') se contenait de faire un XOR entre l'adresse précédente et une constante, avec une constante différente par itération. Troisième remarque : l'algorithme demande de faire une parenthèse dans le fonctionnement normal du BTB. Quand un branchement indirect est détecté, il lance une succession de prédiction pour prédire un branchement. L'incrémentation du ''program counter'' normale cesse pendant ce processus. Et cela ne doit être fait que quand un branchement indirect est détecté. Pour cela, le BTB mémorise le type de chaque branchement, sans l'entrée du BTBT : est-ce qu'il est conditionnel, inconditionnel, mais aussi direct ou indirect. Et le type est accesible en sortie du BTB, sur une sortie de quelques bit. Quand l'unité de prédiction remarque qu'un branchement indirect vient de sortir du BTB, alors elle lance le processus. [[File:Virtual program counter.png|centre|vignette|upright=2.5|Virtual program counter]] L'avantage de cette technique est qu'elle s’implémente facilement, son cout en transistors est faible. Elle demande juste d'ajouter quelques circuits autour d'un BTB classique. Elle utilise l'unité de prédiction classique, pas besoin d'une unité de prédiction séparée. Vu qu'il y a un seul BTB, on n'a pas les désavantages d'un BTB séparé pour les branchements indirects. Les entrées du BTB sont réparties entre branchements directs et indirects de manière optimale. Niveau performance, elle est un peu moins bonne que les unités de prédiction ITTAGE qu'on détaillera dans la suite du chapitre. Mais elle est suffisante pour être utilisée sur certains processeurs commerciaux. ===La prédiction de l'adresse de retour d'une fonction=== Presque tous les processeurs conçus après les années 2000 peuvent prévoir l'adresse à laquelle il faudra reprendre lorsqu'une fonction/sous-programme a fini de s’exécuter. Ils font de la '''prédiction de retour de fonction'''. Pour cela, ils possèdent pour une unité de prédiction de branchement spécialisée, appelée le ''return stack predictor'', ou '''''pile de prédiction de retour de fonction'''''. Le terme ''return stack predictor'' étant plus court, nous l'utiliserons dans la suite du cours. Le ''return stack predictor'' est capable de reconnaitre les instructions de retour de fonction, et de prédire l'adresse de retour. Pour comprendre comment elle fait, rappelez-vous le chapitre sur la pile d'appel. Dans ce chapitre, j'avais introduit le concept de ''pile d'adresse de retour'', à savoir une pile d'appel très simplifiée, qui ne contient que les adresses de retour des fonctions en cours d'exécution. Le ''return stack predictor'' est essentiellement composé d'une telle pile d'adresse de retour, avec des circuits pour empiler et dépiler les adresses de retour. Le ''return stack predictor'' doit reconstruire une pile d'adresses de retour, à partir du programme exécuté. La pile d'adresse est mémorisée dans le ''return stack predictor'', elle est totalement séparée de la pile d'appel normale. Le programme exécuté continue d'utiliser la même pile d'appel qu'avant, ce qui fait que les adresses de retour sont dispersées dans la pile d'appel, parfois dans des registres spéciaux du processeur. Mais cela ne pose pas de problèmes pour le processeur, qui reconstruit la pile d'adresse de retour avec l'aide du décodeur. La reconstruction se fait à partir des instructions d'appel et de retour de fonction. Une instruction d'appel de fonction empile une adresse de retour, une instruction de retour de fonction la dépile. Lorsque le décodeur d'instruction reconnait une instruction d'appel de fonction, il extrait l'adresse de retour et l'envoie au ''return stack predictor''. L'adresse de retour est alors empilée, ce qui fait qu'elle se retrouve au sommet de la pile d'adresse de retour. Dépiler une adresse de retour revient à prédire un branchement. Lorsque le ''return stack predictor'' détecte une instruction de retour de fonction, il dépile l'adresse au sommet de la pile. L'adresse est alors envoyée du cache d'instruction, pour l'ire l'instruction adéquate, et le ''program counter'' est mis à jour avec cette adresse. Toute la difficulté revient à détecter les instructions de retour de fonction, sans quoi on ne sait pas quand dépiler l'adresse. La documentation là-dessus n'est pas très fournie, il est difficile de savoir ce qui est fait dans les processeurs modernes. Mais on peut faire quelques suppositions. La première possibilité est l'usage du ''prédécodage'', une technique qui décode partiellement les instructions lors de leur chargement dans le cache d'instruction. Elle sera vue dans le chapitre suivant, mais parlons-en rapidement. Le prédécodage ajoute quelques bits au début de chaque instruction pour préciser si c'est un branchement conditionnel, inconditionnel, un retour de fonction. On peut ainsi savoir rapidement quand dépiler : dès qu'une instruction est lue dans le cache d'instruction, le ''return stack predictor'' vérifie les bits ajoutés et agit en fonction. La seconde possibilité est que le BTB reconnait l'instruction de retour de fonction. Pour chaque branchement, il mémorise non seulement son adresse de retour, mais aussi son type. Par type, on veut dire : si c'est un branchement conditionnel, inconditionnel, un retour de fonction. Les retours de fonction sont donc reconnus à condition qu'ils aient été exécutés auparavant. Il est aussi possible d'avoir un BTB spécialisé dans les retours de fonction, qui est alimenté par les décodeurs. Dès que le décodeur reconnait une instruction de retour de fonction, il ajoute son ''program counter'' dans ce BTB spécialisé. Une dernière possibilité est que le ''return stack predictor'' agit une fois l'instruction partiellement décodée. Le décodeur d'instruction reconnait les instructions de retour de fonction rapidement, en un cycle, et consulte alors le ''return stack predictor''. L'adresse est dépilée, et le décodeur sait où brancher. Il n'a pas besoin de devoir lire l'adresse de retour en mémoire, il la lit depuis le ''return stack predictor'', directement. La prédiction se fait donc assez tard dans le pipeline, quelques instructions ont été chargées à tort. Mais le décodeur peut les annuler, comme il le ferait avec un branchement inconditionnel mal prédit. ==La prédiction de branchement locale== La prédiction de branchement tente de prédire si un branchement sera pris ou non-pris, et décide quelle adresse envoyer au cache en fonction du résultat. Si on prédit qu'un branchement est non pris, on laisse le ''program counter'' faire son travail. À l'inverse si le branchement est prédit comme étant pris, l'adresse de destination est envoyée au cache d'instruction et au ''program counter''. La prédiction de branchement assigne une probabilité qu'un soit branchement soit pris en fonction de ces informations : si la probabilité est de plus de 50%, le branchement est considéré comme pris. La prédiction de branchement se base avant tout sur des statistiques, c’est-à-dire qu'elle détermine la probabilité d’exécution d'un branchement en fonction d'informations assez diverses. La prédiction de branchement n'a d'intérêt que si les prédictions sont suffisamment fiables pour valoir le coup. Rappelons que la pénalité lors d'une mauvaise prédiction est importante, d'autant plus coûteuse que le pipeline est long. Il est généralement admis qu'il faut au minimum un taux de réussite proche de 90%, voire plus sur les processeurs modernes. Heureusement, la plupart des branchements sont des '''branchements biaisés''', c’est-à-dire qu'ils sont presque toujours pris ou presque toujours non-pris, sauf en de rares occasions. De tels branchements font que la prédiction des branchements est facile, bien qu'ils posent paradoxalement quelques problèmes, comme nous le verrons plus bas. Un autre point important est que les unités de prédiction de branchement doivent être très rapides. Idéalement, elles doivent fournir leur prédiction en un seul cycle d'horloge. Un seul cycle d'horloge est un temps très court, surtout sur les ordinateurs avec une fréquence élevée. Les techniques de prédiction dynamique ne peuvent donc pas utiliser des méthodes statistiques extraordinairement complexes. Cela va à l'encontre du fait que le taux de mauvaises prédictions doit être très faible, idéalement inférieur à 1% sur les processeurs modernes. Vous comprenez donc aisément que concevoir une unité de prédiction de branchement est un véritable défi d’ingénierie électronique qui requiert des prouesses technologiques sans précédent. Suivant la nature des informations utilisées, on peut distinguer plusieurs types de prédiction de branchement : locale, globale, dynamique, statique, etc. Nous allons d'abord voir les méthodes statiques avant de voir les méthodes dynamiques. La '''prédiction locale''' prédit chaque branchement indépendamment les uns des autres. Elle sépare bien les statistiques/moyennes pour chaque branchement. Et c'est autant un point fort qu'un point faible. En effet, elle n'exploite pas les corrélations entre branchements, à savoir le fait que l'exécution d'un branchement a tendance à déborder sur les suivants. Dans ce qui suit, nous allons voir les techniques de prédiction locales. ===Les ''branch hints''=== Dans son implémentation la plus explicite, la prédiction est inscrite dans le branchement lui-même. Quelques bits de l'opcode du branchement précisent si le branchement est majoritairement pris ou non pris. Ils permettent d'influencer les règles de prédiction de branchement et de passer outre les réglages par défaut. Ces bits sont appelés des '''suggestions de branchement''' (''branch hint''). Mais tous les processeurs ne gèrent pas cette fonctionnalité. Et de plus, cette solution marche assez mal. La raison est que le programmeur ou le compilateur doit déterminer si le branchement est souvent pris ou non-pris, mais qu'il n'a pas de moyen réellement fiable pour cela. Une solution possible est d’exécuter le programme sur un ensemble de données réalistes et d'analyser le comportement de chaque branchement, afin de déterminer les suggestions de branchement adéquates, mais c'est une solution lourde et peu pratique, qui a de bonnes chances de donner des résultats peu reproductibles d'une exécution à l'autre. ===La prédiction statique de branchement=== La '''prédiction statique''' prédit le résultat du branchement en fonction de caractéristiques fixes du branchement lui-même, comme son adresse, son mode d'adressage, l'adresse de destination, la position du branchement en mémoire, si le branchement est direct ou indirect, etc. Les informations en question sont connues une fois le branchement décodé, il n'y a pas besoin de mémoriser la moindre information supplémentaire. Une première implémentation, assez évidente, fait la différence entre les branchements inconditionnels toujours pris, et les branchements conditionnels au résultat variable. L'idée est de prédire les branchements inconditionnels comme toujours pris alors que les branchements conditionnels sont prédits comme non-pris (ce qui est une approximation). L'implémentation est relativement simple : on utilise un BTB qui ne mémorise que les branchements inconditionnels, et basta ! Les branchements conditionnels sont simplement ignorés, traités comme n'importe quelle autre instruction. Cette méthode est particulièrement inefficace pour les branchements de boucle, où la condition est toujours vraie, sauf en sortie de boucle ! Il a donc fallu affiner légèrement l'algorithme de prédiction statique. Une implémentation plus subtile fait la différence entre les branchements des boucles et les autres. Reste à identifier ceux-ci d'une manière ou d'une autre. Et un bon moyen est de regarder si le branchement revient en arrière ou au contraire saute une portion de code. Un programme s'exécute des adresses basses vers les adresses hautes, les instructions vont naturellement dans le sens croissant des adresses. Un branchement de boucle fait revenir en arrière, histoire de répéter un morceau de code, pas les autres branchements (du moins pas forcément). Et cela nous amène à faire la distinction entre les branchements conditionnels ascendants et descendants. Un '''branchement conditionnel ascendant''' est un branchement qui demande au processeur de reprendre plus loin dans la mémoire : l'adresse de destination est supérieure à l'adresse du branchement. Un branchement '''conditionnel descendant''' a une adresse de destination inférieure à l'adresse du branchement : le branchement demande au processeur de reprendre en arrière dans la mémoire, avant le branchement. Les branchements ascendants sont rarement pris (ils servent dans les conditions de type SI…ALORS), contrairement aux branchements descendants (qui servent à fabriquer des boucles). On peut ainsi modifier l’algorithme de prédiction statique comme suit : * les branchements inconditionnels sont toujours pris ; * les branchements descendants sont toujours pris ; * les branchements ascendants ne sont jamais pris. L'implémentation demande d'élargir le BTB. Le BTB n'a pas seulement à mémoriser l'adresse du branchement et son adresse de destination, mais aussi le type du branchement. En général, il se contente de deux bits, qui indiquent si le branchement est conditionnel, inconditionnel, ou un retour de fonction. Les retours de fonction sont traités à part, histoire de profiter du ''return stack predictor''. {|class="wikitable" |+ BTB avec prédiction de branchement statique évoluée |- ! Adresse du branchement !! Adresse de destination !! Type du branchement |- | 0x4165485 || 0x14657445541 | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Retour de fonction |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... || ... |} ===La prédiction avec des compteurs à saturation=== Les méthodes de prédiction de branchement statique sont intéressantes, mais elles montrent rapidement leurs limites. Pour plus de performances, il faut utiliser la '''prédiction dynamique''', qui extrait des statistiques sur l’exécution des branchements. Pour le reste du chapitre, toutes les techniques utilisent la prédiction dynamique, qu'elles soient locales ou globales. Sa version la plus simple se contente de mémoriser ce qui s'est passé lors de l’exécution précédente du branchement. Si le branchement a été pris, alors on suppose qu'il sera pris la prochaine fois. De même, s'il n'a pas été pris, on suppose qu'il ne le sera pas lors de sa prochaine exécution. Pour cela, chaque entrée du BTB contient un bit qui mémorise la dernière exécution du branchement : 1 s'il a été pris, 0 s'il n'a pas été pris. C'est ce qu'on appelle la '''prédiction de branchements sur 1 bit'''. Au vu de sa simplicité, il ne faut pas s'attendre à des miracles. La méthode fonctionne bien pour les branchements des boucles et les branchements inconditionnels, mais elle n'est pas assez performante. Une forme plus élaborée de prédiction utilise des '''compteurs à saturation'''. Un compteur à saturation est un compteur qui est incrémenté à chaque fois qu'un branchement est pris, décrémenté s'il est non-pris. Le compteur est initialisé à la moitié de sa valeur maximale, ce qui correspond à une probabilité proche de 50%. Les compteurs à saturation sont très utilisés et pas seulement pour de la prédiction locale, les méthodes globales en utilisent aussi. Pour faire la prédiction, l'unité de prédiction regarde le bit de poids fort du compteur. Le branchement est considéré comme pris si ce bit de poids fort est à 1, et non pris s'il vaut 0. En clair : on considère que le branchement est pris si le compteur indique une probabilité supérieure à 50%. [[File:Compteur à saturation.png|centre|vignette|upright=2|Compteur à saturation.]] : Pour la culture générale : le compteur à saturation du Pentium 1 était légèrement bogué, ce qui fait que sa prédiction de branchement était sous-optimale. La plupart des processeurs utilisent un compteur à saturation par branchement, ce qui veut dire un compteur à saturation par entrée dans le BTB. Vu chaque compteur est associé à une entrée du BTB, le phénomène d’''aliasing'' mentionné plus haut peut perturber les prédictions de branchement. Deux branchements peuvent se voir associés à la même entrée du BTB, et agir sur le même compteur à saturation, ce qui entraine l'apparition d''''interférences entre branchements'''. L'interférence peut avoir des effets positifs, neutres ou négatifs, suivant la corrélation entre les deux branchements. L’''aliasing'' n'est pas un problème si les deux branchements sont fortement corrélés, par exemple si les deux sont tout le temps pris ou tout le temps non-pris. Si les deux branchements vont dans le même sens, l'interférence est neutre, voire légèrement positive. Mais si l'un est souvent pris et l’autre souvent non-pris, l'interférence est négative. Les deux branchements vont se marcher dessus sans vergogne, chacun interférant sur les prédictions de l'autre ! Il est rare que l’''aliasing'' ait un impact significatif sur les unités de prédiction des deux paragraphes précédents. Il se manifeste surtout quand la BTB est très petite et la seule solution est d'augmenter la taille du BTB. ===La prédiction avec un historique local=== La prédiction avec des compteurs à saturation est très simple, mais il est possible de créer une implémentation alternative, très proche, avec des avantages et désavantages différents. Un compteur à saturation enregistre l'historique d'un branchement, à savoir ses dernières exécutions. L''''historique''' d'un branchement dit quel a été son résultat dans le passé, pour ses exécutions précédentes. Et il est possible d'enregistrer cet historique et l'utiliser pour faire des prédictions. Par exemple, prenons un branchement sont ses 6 dernières exécutions sont "pris, non-pris, pris, pris, pris, non-pris". Le processeur peut encoder cet historique sur 6 bits. Un branchement pris correspond à un bit à 1, un branchement non-pris correspond à un 0. Le bit de poids fort correspond à l'exécution la plus ancienne, le bit de poids faible à l'exécution la plus récente. Par exemple, si l'historique contient 010, cela veut dire que le branchement a été non pris, puis pris, puis non pris. L'historique d'un branchement est appelé son '''historique local''', mais nous parlerons simplement l'historique. Le processeur peut mémoriser l'historique de chaque branchement. Pour cela, il réserve n bits par branchements, pour mémoriser ses n exécutions précédentes. L'historique d'un branchement est mémorisé dans son entrée du BTB. Et plus précisément dans un champ séparé qui est alimentée comme un registre à décalage. Reste à exploiter cet historique local pour faire des prédictions. Une idée assez simple serait de l'utiliser pour calculer une moyenne des N exécutions précédentes. Si la probabilité d'être pris est supérieure à 50 %, le branchement est considéré comme pris. Dans le cas contraire, il est considéré comme non pris. Le calcul de la moyenne utilise une simple porte à majorité, qui prend en entrée tous les bits de l'historique. Cependant, cette méthode est équivalente à utiliser un compteur à saturation par branchement. La sortie de la porte à majorité est équivalente à ce qu'on aurait dans un compteur à saturation. La seule différence : le cout en circuit et la simplicité de conception. Prenons l'exemple des premiers processeurs PA RISC. Ils mémorisaient, pour chaque branchement, les trois dernières exécutions du branchement, avec trois bits par entrée du BTB. La prédiction était réalisée avec une porte à majorité, dont le résultat était utilisé comme prédiction. Ces processeurs ont préféré utiliser un historique de 3 bits, car il était plus simple à concevoir qu'un compteur à saturation équivalent de deux bits. Il suffisait d'alimenter un registre à décalage avec un bit, pas besoin d'incrémenter ou de décrémenter un compteur, pas besoin de signaux d'incrémentation/décrémentation séparés, etc. Mais pour des historiques plus longs, la donne s'inverse et des compteurs à saturation de deux-trois bits l’emportent. Un défaut de la méthode précédente, qui est partagée avec les compteurs à saturation, est qu'elle marche mal pour les branchements qui suivent un ''pattern''. Je veux dire par là que l'exécution d'un branchement suit un cycle, par exemple pris, pris, non-pris, pris, pris, non-pris, et ainsi de suite en boucle. Notons que l'apparition de tels motifs correspond le plus souvent à la présence de boucles dans le programme. Quand une boucle s’exécute, les branchements qui sont à l'intérieur tendent à exprimer de tels motifs. Avec de tels motifs, les compteurs à saturation feront des prédictions incorrectes, là où les prédicteurs qu'on va voir font des prédictions parfaites. Par exemple, prenons un branchement qui suit le ''pattern'' pris, pris, pris, non-pris. Si l'historique local vaut 1110, le branchement est censé refaire une boucle et être pris. Et certes, on a supposé que l'historique faisait 4 bits. Mais le''pattern'' sera visible tant que l'historique fait plus de 4 bits. Par exemple, le ''pattern'' serait 01110 pour un historique local de 5 bits, 101110 pour un historique local de 6 bits, 1101110 pour un historique local de 7 bits, etc. Un ''pattern'' correspond donc à une valeur bien précise de l'historique local. L'historique local permet de voir ces cycles, reste à les exploiter. Prédire correctement les ''pattern'' demande simplement d'associer un compteur à saturation à chaque valeur possible de l'historique local. Le compteur à saturation dira, quand tel pattern est rencontré, si le branchement sera pris ou non-pris par la suite. Au total, il faut donc un registre d'historique local de n bits couplé à 2^n compteurs à saturation. Et ce pour chaque branchement possible. Les compteurs à saturation sont regroupés dans une '''''Pattern History Table''''', que nous noterons PHT dans ce qui suit. Le résultat est une '''unité de prédiction à deux niveaux locale''', qui contient plusieurs registres d'historique et tout autant de PHT locales. En tout, il y a une PHT et un historique par branchement, donc par entrée du BTB. Quand un branchement est exécuté, son historique local indique quel compteur à saturation choisir dans la PHT locale. En général, l'historique local est mémorisé dans le BTB, reste à gérer la PHT. [[File:Unité de prédiction à deux niveaux purement locale.png|centre|vignette|upright=2.5|Unité de prédiction à deux niveaux avec des historiques et des PHT locales.]] Une unité de prédiction locale à deux niveaux contient plusieurs PHT, reste à savoir quelle PHT correspond à tel branchement. La correspondance se fait idéalement à partir de l'adresse du branchement, mais c'est là une implémentation idéale, qui demande beaucoup de circuits. Généralement, l'unité de prédiction n'utilise que les bits de poids faible de l'adresse du branchement, ce qui permet de faire un choix correct, mais avec une possibilité d'''aliasing''. Il est aussi possible de séparer l'historique local du BTB. Les historiques locaux sont alors mémorisés dans une mémoire RAM dédiée, appelé la '''''Branch History Table''''', ou encore la '''table des historiques locaux''', que nous noterons BHT dans ce qui suivra. Chaque byte de la BHT contient l'historique local associé à un branchement. Pour récupérer l'historique local voulu, l'adresse du branchement est envoyée à la BHT, sur les entrées d'adresse de la BHT, directement. Seuls les bits de poids faible de l'adresse sont utilisés. L'historique local est ensuite envoyé à la PHT adéquate, choisie en fonction des mêmes bits de poids faible du PC. Un bon moyen pour cela est d'accéder à toutes les PHT en parallèle, mais de sélectionner la bonne avec un multiplexeur, ce dernier étant commandé par les bits de poids faible du PC. [[File:Table des historiques locaux.jpg|centre|vignette|upright=2|Table des historiques locaux]] Un défaut des unités de prédiction purement locales de ce type est leur cout en termes de circuits. Utiliser un registre d'historique et une PHT par branchement a un cout en transistor élevé, avec tous les défauts que ca implique en termes de consommation électrique et thermique. Elles ont aussi des temps de calcul un peu plus important que les unités purement globales, mais pas de manière flagrante. Les gains en performance avec un historique local s'observent surtout pour les branchements qui sont dans des boucles ou pour les branchements utilisés pour concevoir des boucles, alors que l'implémentation globale marche bien pour les if..else (surtout quand ils sont imbriqués). Les deux sont donc complémentaires. Dans les faits, elles sont rarement utilisées seules du fait de leurs défauts, au profit d'unités de prédiction hybrides, qui mélangent historiques et PHT locaux et globaux. ==La prédiction de branchement globale/mixte== Utiliser un historique local avec une PHT est très puissant. Mais le cout en circuit est très important. Cependant, on peut adapter l'idée pour faire de la prédiction globale. La '''prédiction globale''' fait des prédictions holistiques, qui tiennent compte de tous les branchements, sans les traiter séparément. Elle ne sépare pas les branchements, tous sont mis dans le même panier. La prédiction globale fait donc le compromis inverse : elle exploite les corrélations entre branchements, mais perd les informations statistiques précise pour chaque branchement. L'idée est qu'au lieu d'utiliser un historique par branchement, on utilise un '''historique global''' qui mémorise les exécutions de tous les branchements conditionnels. L'historique enregistre les N exécutions de branchements conditionnels. L'historique global est mémorisé dans un registre unique, appelé le '''registre d'historique global'''. C'est un registre à décalage mis à jour à chaque fois que le processeur exécute un branchement : on fait rentrer un 1 si le branchement est pris, et un 0 sinon. Une unité de prédiction globale utilise cet historique pour faire sa prédiction. L'unité de prédiction globale utilise un compteur à saturation pour chaque valeur possible du registre d'historique. En clair, pour un registre de N bits, elle utilise 2^n compteurs à saturation. Là encore, les compteurs à saturation sont regroupés dans une ''Pattern History Table''. Nous parlerons de '''PHT globale''' pour désigner une PHT commandée par un historique global, de ''PHT locale'' si elle est adressée par un historique local. [[File:Prédiction dynamique à deux niveaux.png|centre|vignette|upright=2|Prédiction dynamique à deux niveaux.]] Il est admis que l'historique global donne de meilleures prédictions que l'historique local. Mais les deux sont complémentaires, ce qui fait qu'il existe des approches hybrides. Les approches hybrides exploitent à la fois les statistiques pour chaque branchement et les corrélations entre branchements. Les prédicteurs hybrides ont de meilleures performances que les prédicteurs purement globaux ou locaux. Avec un historique global, la prédiction tient compte d'éventuelles corrélations entre branchements consécutifs/proches pour faire les prédictions. C'est un avantage pour les applications qui enchaînent des if...else ou qui contiennent beaucoup de if...else imbriqués les uns dans les autres, qui s’exécutent plusieurs fois de suite. Le résultat de chaque if...else dépend généralement des précédents, ce qui rend l'utilisation d'un historique global intéressant. Par contre, ils sont assez mauvais pour le reste des branchements. Mais cette qualité devient un défaut quand elle détecte des corrélations fortuites ou parasites, inutiles pour la prédiction (corrélation n'est pas causalité, même pour prédire des branchements). Du fait de ce défaut, la prédiction globale a besoin de registres d'historiques globaux très larges, de plusieurs centaines de bits au mieux. [[File:2 level branch predictor.svg|centre|vignette|upright=2|Unités de prédiction de branchement à deux niveaux avec une seule PHT.]] Le circuit précédent a cependant un problème : si deux branchements différents s'exécutent et que l'historique est le même, le processeur n'y verra que du feu. Il y a alors une interférence, à savoir que les deux branchements vont agir sur le même compteur à saturation. On se retrouve alors dans une situation conceptuellement identique à l’''aliasing'' dans le BTB ou avec des compteurs à saturation. Sauf que les interférences sont alors beaucoup plus nombreuses et qu'elles sont clairement un problème pour les performances ! Mitiger les interférences devient alors bien plus important qu'avec les unités de prédiction précédentes. Heureusement, si augmenter la taille de la PHT peut mitiger le problème, d'autres solutions sont possibles sur les unités de prédiction globales. Elles sont au nombre de deux : mitiger les interférences liées aux branchements biaisés, ajouter des informations qui discriminent les branchements. Voyons ces solutions dans le détail. ===La mitigation des interférences par usage de l'adresse de branchement=== La première solution pour réduire l'impact de l’''aliasing'' est d'augmenter la taille de la PHT et de l'historique. Plus l'historique et la PHT sont grands, moins l’''aliasing'' est fréquent. Mais avoir des historiques et des PHT de très grande taille n'est pas une mince affaire et le cout en termes de performances et de portes logiques est souvent trop lourd. Une autre solution consiste à utiliser, en plus de l'historique, des informations sur le branchement lui-même pour choisir le compteur à saturation à utiliser. Typiquement, on peut utiliser l'adresse du branchement en plus de l'historique, pour choisir le compteur à saturation adéquat. Pour être précis, on n'utilise que rarement l'adresse complète du branchement, mais seulement les bits de poids faible. La raison est que cela fait moins de bits à utiliser, donc moins de circuits, mais l’''aliasing'' est juste atténué, pas éliminé. Des branchements différents ont beau avoir des adresses différentes, les bits de poids faible de leurs adresses peuvent être identiques. Des confusions sont donc possibles, mais sont assez rares pour que cela suffise en pratique. Une unité de prédiction de ce type est illustrée ci-dessous. Il y a plusieurs PHT séparées, donc plusieurs ensembles de 2^n compteurs à saturation. Le choix de la PHT est réalisé par l'adresse du branchement, alors que le choix du compteur à saturation est réalisé par l'historique. Les compteurs à saturation d'un branchement sont mis à jour seulement quand le branchement s'est chargé pendant que l'historique associé était dans le registre d'historique. Cette méthode a cependant le défaut de gâcher énormément de transistors. [[File:Unité de prédiction de branchement basée sur des compteurs à saturation sélectionnés par l'historique global.jpg|centre|vignette|upright=2|Unité de prédiction de branchement basée sur des compteurs à saturation sélectionnés par l'historique global]] D'autres unités de prédiction fonctionnent sur le même principe que l'unité précédente, sauf que les différentes PHTs sont fusionnées en une seule grosse PHT. C'est ce que font les unités de prédiction '''''gshare''''' et '''''gselect''''', ainsi que leurs dérivées. Avec les unités de prédiction '''''gselect''''', on concatène les bits de poids faible de l'adresse du branchement et l'historique pour obtenir le numéro du compteur à saturation à utiliser. [[File:Prédiction « gselect ».png|centre|vignette|upright=2|Prédiction « gselect ».]] Avec les unités de prédiction '''''gshare''''', on fait un XOR entre le registre d'historique et les bits de poids faible de l'adresse du branchement. Le résultat de ce XOR donne le numéro du compteur à utiliser. Avec cette technique, on utilise une PHT globale et un historique global, mais le registre d'historique est manipulé de manière à limiter l'''aliasing''. [[File:Prédiction « gshare ».png|centre|vignette|upright=2|Prédiction « gshare ».]] Intuitivement, on pourrait croire que les unités de prédiction gselect ont de meilleures performances. C'est vrai que les unités gshare combinent l'adresse du branchement et l'historique d'une manière qui fait perdre un peu d'information (impossible de retrouver l'adresse du branchement et l'historique à partir du résultat du XOR), alors que les unités gselect conservent ces informations. Mais il faut prendre en compte le fait que les deux unités doivent se comparer à PHT identique, de même taille. Prenons par exemple une PHT de 256 compteurs, adressée par 8 bits. Avec une unité gselect, les 8 bits sont utilisés à la fois pour l'historique et pour les bits de poids faible de l'adresse du branchement. Alors qu'avec une unité gshare, on peut utiliser un historique de 8 bits et les 8 bits de poids faible de l'adresse du branchement. L'augmentation de la taille de l'historique et du nombre de bits utilisés fait que l’''aliasing'' est réduit, et cela compense le fait que l'on fait un XOR au lieu d'une concaténation. Les unités précédentes réduisent l'''aliasing'' en segmentant la PHT : telles entrées sont allouées à un branchement, telles entrées à tel autre branchement, etc. Cependant, l'historique local n'est pas utilisé. Mais quitte à allouer des entrées à un branchement, autant en profiter pour leur associer son historique local. Il s'agit alors d'approches hybrides. Par exemple, on peut citer la '''prédiction mixte''' (''alloyed predictor''). Avec celle-ci, la table des compteurs à saturation est adressée avec la concaténation : de bits de l'adresse du branchement, du registre d'historique global et du registre d'historique local adressé par l'adresse du branchement. [[File:Prédiction mixte.jpg|centre|vignette|upright=2|Prédiction mixte.]] Une version optimisée de ce circuit permet d’accéder à la PHT et à la BHT en parallèle. Avec elle les bits de poids faible de l'adresse du branchement sont concaténés avec l'historique global. Le résultat est ensuite envoyé à plusieurs PHT locales distinctes, en parallèle. Les différences PHT envoient leurs résultats à un multiplexeur, commandé par l'historique local, qui sélectionne le résultat adéquat. L'inconvénient de cette mise en œuvre est que le circuit est assez gros, notamment en raison de la présence de plusieurs PHT séparées. [[File:Alloyed predictor optimisé.jpg|centre|vignette|upright=2|''Alloyed predictor'' optimisé]] ===La mitigation des interférences par la structuration en caches des PHTs=== Les techniques précédentes utilisent les bits de poids faible de l'adresse pour sélectionner la PHT adéquate, ou pour sélectionner le compteur dans la PHT. Mais le reste de l'adresse n'est pas utilisé. Cependant, il est théoriquement possible de conserver les bits de poids fort, afin d'identifier le branchement associé à un compteur. Pour cela, chaque compteur à saturation se voit associé à un ''tag'', qui mémorise le reste de l'adresse du branchement associé au compteur, comme pour les mémoires caches. Lorsque l'unité de prédiction démarre une prédiction, elle récupère les bits de poids fort de l'adresse du branchement et compare ceux-ci avec le ''tag''. S’il y a un ''match'', c'est signe que le compteur est bien associé au branchement à prédire. Dans le cas contraire, c'est signe qu'on fait face à une situation d’''aliasing'' et le compteur n'est pas utilisé pour la prédiction. Le résultat transforme la PHT en une sorte de cache assez particulier, qui associe un compteur à saturation à chaque couple adresse-historique. La technique du paragraphe précédent peut être encore améliorée afin de réduire l’''aliasing''. L’''aliasing'' sur une unité de prédiction de branchement est similaire aux conflits d'accès au cache, où deux données/adresses atterrissent dans la même ligne de cache. Une PHT peut formellement être vue comme un cache directement adressé. Une solution pour limiter ces conflits d'accès à de tels, est de les transformer en un cache associatif à n voies. On peut faire la même chose avec les unités de prédiction de branchement, ce qui revient à dupliquer les PHT, de manière à ce que les bits de poids faible de l'adresse se voient attribuer à plusieurs compteurs à saturation, un par branchement possible. Ce faisant, on réduit les interférences entre branchements. Mais cette technique augmente la quantité de circuits utilisés et la consommation d'énergie, ainsi que le temps d'accès, sans compter qu'elle requiert d'utilisation de ''tags'' pour fonctionner à la perfection. Pour les unités de prédiction de branchement, les mesures à ce sujet ne semblent pas montrer un impact réellement important sur les performances, comparé à une organisation de type directement adressé. Il est possible de pousser la logique encore plus loin en ajoutant des caches de victime et d'autres mécanismes courant sur les caches usuels. ===La mitigation des interférences liées aux branchements biaisés=== L’''aliasing'' a un impact négatif quand deux branchements aliasés (qui sont attribués au même compteur à saturation) vont dans des sens différents : si l'un est non-pris, l'autre a de bonnes chances d'être pris (ou inversement). Or, la plupart des branchements sont biaisés, à savoir très fortement orientés dans une direction qu'une autre (presque toujours pris ou presque toujours non-pris, à plus de 90/99% de chances). Plus un branchement a une probabilité élevée d' être pris ou non-pris, plus sa capacité à interférer avec les autres est forte. Le pire des cas est quand deux branchements sont biaisés mais dans des directions inverses : l'interférence est maximale. En conséquence, les branchements biaisés ou quasi biaisés sont les plus problématiques pour l’''aliasing''. Or, il est possible de concevoir des prédicteurs de branchements qui atténuent fortement les interférences des branchements biaisés ou quasi biaisés. C’est le cas de l’''agree predictor'' et de l'unité de prédiction bimodale, que nous allons voir dans ce qui suit. Les branchements très souvent pris vont interférer très fortement avec les branchements très souvent non-pris, et inversement. Par contre, les branchements très souvent pris n'interféreront pas entre eux, de même que les branchements non-pris iront bien ensemble. D'où l'idée de séparer ces deux types de branchements dans des PHT séparées, pour éviter le mauvais ''aliasing''. L''''unité de prédiction bimodale''' part d'une unité gshare, et sépare la PHT globale en deux : une PHT dédiée aux branchements qui sont très souvent pris et une autre pour les branchements très peu pris. Les deux PHT fournissent chacune une prédiction. La bonne prédiction est choisie par un multiplexeur commandé par une unité de sélection, qui se charge de déduire quelle unité a raison. Cette unité de sélection est une unité de prédiction basée sur des compteurs à saturation de 2 bits. On peut encore améliorer l'unité de sélection de prédiction en n'utilisant non pas des compteurs à saturation, mais une unité de prédiction dynamique à deux niveaux, ou toute autre unité vue auparavant. [[File:Prédiction bimodale.jpg|centre|vignette|upright=2|Prédiction bimodale.]] La '''prédiction par consensus''' (''agree predictor'') combine une unité de prédiction à historique global (idéalement de type gshare ou gselect, mais une unité normale marche aussi) et des compteurs à saturation. Il s'agit techniquement d'une technique de prédiction mixte, à la fois globale et locale. Le BTB intègre un compteur à saturation de un bit, qui indique la direction privilégiée du branchement. L'unité à historique global a un fonctionnement changé : elle calcule non pas la probabilité que le branchement soit pris ou non-pris, mais la probabilité que le résultat du branchement soit compatible avec le résultat de l'unité de prédiction de 1 bit. La prédiction finale vérifie que ces deux circuits sont d'accord, en faisant un NXOR entre les résultats des deux unités de prédiction de branchement. [[File:Prédiction par consensus.png|centre|vignette|upright=2|Prédiction par consensus.]] Des calculs simples de probabilités montrent que l'''agree predictor'' a des résultats assez importants. Ses résultats sont d'autant meilleurs que les branchements sont biaisés et penchent fortement vers le côté pris ou non-pris. ==La prédiction de branchement avec des perceptrons== Les méthodes précédentes utilisent au moins un compteur à saturation par historique possible. Sachant qu'il y a 2^n valeurs possibles pour un historique de n bits, le nombre de compteurs à saturation augmente exponentiellement avec la taille de l'historique. Ce qui limite grandement la taille de l'historique, alors qu'on aurait au contraire besoin d'un historique assez large pour faire des prédictions excellentes. Pour contourner le problème, des alternatives basées sur des techniques d'apprentissage artificiel (''machine learning'') existent. Un exemple est celui des unités de prédiction de branchement dites de ''neural branch prediction'', basée sur des perceptrons. Dans ce qui suit, nous les appelerons des '''unités perceptron'''. Les perceptrons sont un algorithme d'apprentissage automatique très simple, ce qui fait que les explications qui vont suivre devraient être simples. En théorie, il est possible d'utiliser des techniques d'apprentissage automatique plus élaborées, mais cela n'est pas possible en pratique. Rappelons qu'une unité de prédiction de branchement doit idéalement fournir sa prédiction en un cycle d'horloge. L'implémentation d'un perceptron est déjà problématique de ce point de vue, car elle demande d'utiliser beaucoup de circuits arithmétiques complexes (multiplication et addition). Autant dire que les autres techniques de ''machine learning'', encore plus gourmandes en calculs, ne sont pas praticables. ===Les perceptrons et leur usage en tant qu'unité de prédiction de branchements=== L'idée derrière les unités perceptron est que certains bits de l'historique sont plus importants que d'autres. En soi, ce genre de situation est réaliste. Il arrive que certains branchements soient pris uniquement si les deux branchements précédents ne le sont pas, ou que si l'avant-dernier branchement est lui aussi pris. Mais les autres résultats de branchement présents dans l'historique ne sont pas utiles. En conséquence, deux historiques semblables, qui ne sont différents que d'un ou deux bits, peuvent donner des prédictions presque identiques. Dans ce cas, on peut faire la prédiction en n'utilisant que les bits identiques entre ces historiques, ou du moins en leur donnant plus d'importance qu'aux autres. On exploite alors des corrélations avec certains bits de l'historique, plutôt qu'avec l'historique entier lui-même. Pour coder ces corrélations, on associe un coefficient à chaque bit de l'historique, qui indique à quel point ce bit est important pour déterminer le résultat final. Ce coefficient est proportionnel à la corrélation entre le bit de l'historique associé, et le résultat du branchement à prédire. Les coefficients sont des nombres entiers qui peuvent être positifs, nuls ou négatifs. Un coefficient positif signifie que si le branchement associé a été pris, alors le branchement à prédire a de bonnes chances de l'être aussi (et inversement). Un coefficient nul signifie que les deux branchements sont indépendants et que le bit de l'historique associé n'est pas à prendre en compte. Pour un coefficient négatif, cela signifie que si le branchement associé a été pris, alors le branchement à prédire a de bonnes chances d'être non-pris (et inversement). Les coefficients en question sont généralement compris entre -1 et 1. Une fois qu'on connait ces coefficients de corrélations, on peut calculer la probabilité que le branchement à prédire soit pris, avec des calculs arithmétiques simples. Dans les calculs qui vont suivre, un branchement pris est codé par un 1, alors qu'un branchement non-pris est codé par un -1 ! Ce détail permet de simplifier grandement les calculs. Dans sa version la plus simple, le perceptron calcule un nombre Z qui est positif ou nul si le branchement est pris, négatif s'il ne l'est pas. Tous les coefficients <math>p_i</math> sont alors des nombres relatifs, pouvant être positifs, nuls ou négatifs. Ils sont généralement compris entre -1 et 1. La formule devient donc celle-ci : : <math>Z = w_0 + \sum_i (h_i \times w_i)</math> [[File:ArtificialNeuronModel francais.png|centre|vignette|upright=2|Perceptron.]] : Sur le plan mathématique, le perceptron effectue un produit scalaire entre deux vecteurs : l'historique et le vecteur des poids. Choisir les coefficients adéquats est le but de l'algorithme du perceptron. L'unité de prédiction part de poids par défaut, qui sont mis à jour à chaque bonne ou mauvaise prédiction. Toute la magie de cet algorithme tient dans la manière dont sont mis à jour les poids. L'idée est de prendre le vecteur des poids, puis d'ajouter ou soustraire l'historique suivant le résultat du branchement. Les poids évoluent ainsi à chaque branchement, améliorant leurs prédictions d'une exécution à l'autre. La règle de mise à jour des poids est donc la suivante : : <math>w_i \leftarrow w_i + t \times h_i</math>, avec t = 1 pour un branchement pris, -1 pour un branchement non-pris. ===L'implémentation matérielle des perceptrons et ses optimisations=== Les calculs réalisés par un perceptrons sont simples, juste des multiplications et des additions et cela se sent dans la conception du circuit. L'unité perceptron utilise un perceptron par entrée du BTB, un par branchement pour simplifier. Pour cela, on utilise une mémoire SRAM qui mémorise les poids de chaque perceptron. La SRAM est adressée par les bits de poids faible du ''program counter'', de l'adresse du branchement. Une fois les poids récupérés, ils sont envoyés à un circuit de calcul de la prédiction, composé de circuits multiplieurs suivis par un additionneur multi-opérande. Le circuit de mise à jour des poids récupère la prédiction, les poids du perceptron, mais aussi le résultat du branchement. La mise à jour étant une simple addition, le circuit est composé d'additionneurs/soustracteurs, couplés à des registres pour mémoriser la prédiction et les poids du perceptron en attendant que le résultat du branchement soit disponible. Vu qu'il y a un délai de quelques cycles avant que le résultat du branchement soit disponible et que les prédictions doivent continuer pendant ce temps, les poids du perceptron et la prédiction sont stockés dans des FIFOs lues/écrites à chaque cycle. [[File:Unité de prédiction de branchement basée sur des perceptrons.png|centre|vignette|upright=2|Unité de prédiction de branchement basée sur des perceptrons]] Rien de bien compliqué sur le principe, mais rappelons que le perceptron doit fournir un résultat en moins d'un cycle d'horloge, et cela tient compte de l'accès au BTB. C'est un temps très court, surtout pour un circuit qui implique des multiplications. Les multiplications prennent facilement deux à trois cycles d'horloge. Autant dire que si on ajoute le temps d'accès à une petite SRAM, pour récupérer le perceptron, c'est presque impossible. Mais quelques optimisations simples permettent de rendre le calcul plus rapide. Premièrement, la taille de la SRAM est réduite grâce à quelques économies assez simples. Notamment, le perceptron utilise des poids codés sur quelques bits, généralement un octet, parfois 7 ou 5 bits, guère plus. Les poids sont encodés en complément à 1 ou en complément à 2, là où les perceptrons normaux utilisent des nombres flottants. Rien que ces deux choix simplifient les circuits et les rendent plus rapides. Le désavantage d'utiliser peu de bits par poids est une perte mineure en termes de taux de prédiction, qui est plus que compensée par l'économie en portes logiques, qui permet d'augmenter la taille de la SRAM. D'autres optimisations portent sur le circuit de calcul. Par exemple, la multiplication <math>h_i \times w_i</math> est facultative. Vu que l'historique contient les valeurs 1 et -1, il suffit d'additionner le poids associé si la valeur est 1 et de le soustraire s'il vaut -1. On peut même simplifier la soustraction et se limiter à une complémentation à un (une inversion des bits du poids). En faisant cela, les circuits multiplieurs disparaissent et le circuit de prédiction se résume à un simple additionneur multi-opérande couplé à quelques inverseurs commandables. Certains perceptrons prennent en compte à la fois l'historique global et l'historique local pour faire leur prédiction. Les deux historiques sont simplement concaténés et envoyés en entrée du perceptron. Cela marche assez bien, car les perceptrons peuvent utiliser des historiques de grande taille sans problèmes. Par contre, il y a besoin d'ajouter une ''branch history table'' au circuit, ce qui demande des portes logiques en plus, mais aussi un temps de calcul supplémentaire (l'accès à cette table n'est pas gratuit). ===Les avantages et inconvénients des perceptrons pour la prédiction de branchements=== L'avantage des unités perceptron est qu'elles utilisent un nombre de portes logiques qui est proportionnel à la taille de l'historique, et non pas exponentiel. Cela permet d'utiliser un historique de grande taille, et donc d'obtenir un taux de bonnes prédictions très élevé. Leur désavantage est que leurs performances de prédiction sont moins bonnes à historique équivalent. Les unités perceptrons ont des taux de prédiction élevés, mais seulement quand certaines conditions mathématiques sont respectées par les historiques. La condition en question est que les historiques correspondants à un branchement pris et les historiques où ce même branchement est non-pris doivent être linéairement séparables. Heureusement, les branchements des programmes informatiques sont une majorité à respecter la condition de séparation linéaire, ce qui rend les perceptrons parfaitement adaptés à la prédiction de branchements. La notion de linéairement séparable est un peu difficile à comprendre. Pour l'expliquer, imaginez que les N bits de l'historique forme un espace à N-dimensions discrets. Chaque historique est un point dans cet espace N-dimensionnel et chaque bit sert de coordonnée pour se repérer dans cet espace. Une fonction est linéairement séparable si l'espace N-dimensionnel peut être coupé en deux par un hyperplan : d'un côté de ce plan se trouvent les historiques des branchements pris, de l'autre se trouvent les historiques des branchements non-pris. Cet hyperplan est défini par l'ensemble des points qui respecte l'équation suivante : : <math>w_0 + \sum_i (h_i \times w_i) = 0</math> Un exemple où cette condition est respectée est quand le résultat d'une prédiction se calcule avec un ET entre plusieurs branchements de l'historique. Si un branchement est pris quand plusieurs branchements de l’historique sont tous pris ou tous non-pris, la condition est respectée et le perceptron donnera des prédictions correctes. Un exemple où cette condition n'est pas respectée est un banal XOR. Prenons l'exemple d'un historique global de 2 bits. Imaginons que le branchement à prédire soit pris : soit quand le premier branchement de l'historique est pris et le second non-pris, soit quand le premier est non-pris et le second pris. Concrètement, la prédiction exacte se calcule en faisant un XOR entre les deux bits de l'historique. Dans ce cas, la condition "linéairement séparable" n'est pas respectée et le perceptron n'aura pas des performances optimales. Les unités perceptron utilisent idéalement un perceptron par branchement. Cela demande d'associer, l'adresse du branchement pour chaque perceptron, généralement en donnant un perceptron par entrée du BTB. Mais une telle organisation n'est pas possible en pratique, car elle demanderait d'ajouter un ''tag'' à chaque perceptron/entrée du BTB, ce qui demanderait beaucoup de circuits et de ressources matérielles. Dans les faits l'association entre un branchement et un perceptron se fait en utilisant les bits de poids faible de l'adresse de branchement. Mais faire cela entrainera l'apparition de phénomènes d'''aliasing'', comme pour les unités de prédiction à deux niveaux. Les techniques vues précédemment peuvent en théorie être adaptées sur les unités à perceptrons, dans une certaine mesure. Notons que les perceptrons sont des algorithmes ou des circuits qui permettent de classer des données d'entrée en deux types. Ici, la donnée d'entrée est l'historique global et la sortie du perceptron indique si le branchement prédit est pris ou non-pris. Ils ne sont donc pas adaptés à d'autres tâches de prédiction, comme pour le préchargement des lignes de cache ou la prédiction de l'adresse de destination d'un branchement, ou toute autre tâche d'exécution spéculative un tant soit peu complexe. Leur utilisation reste cantonnée à la prédiction de branchement proprement dit, guère plus. Par contre, les autres techniques vues plus haut peuvent être utilisées pour le préchargement des lignes de cache, ou d'autres mécanismes de prédiction plus complexes, que nous aborderons dans la suite du cours. ==La prédiction avec plusieurs unités de branchement distinctes== Il est possible de combiner plusieurs unités de prédiction de branchement différentes et de combiner leurs résultats en une seule prédiction. On peut par exemple combiner une unité de prédiction spécialisée dans les boucles, une unité de prédiction 2 bits basée sur des compteurs à saturation, une unité de prédiction statique et une unité ''gshare''. Tout le problème est de décider quelle unité a fait la bonne prédiction, comment combiner les résultats des différentes unités de prédiction. Soit on choisit un résultat qui parait plus fiable que les autres, soit on combine les résultats pour faire une sorte de moyenne des prédictions. La méthode la plus simple de choisir le résultat d'une unité de prédiction parmi toutes les autres. Pour implémenter le tout, il suffit d'un multiplexeur qui effectue le choix de la prédiction. Reste à commander le multiplexeur, ce qui demande un '''circuit méta-prédicteur''' qui sélectionne la bonne unité de prédiction. Il arrive que le circuit méta-prédicteur soit conçu avec les mêmes techniques que les unités de prédiction de branchement elle-mêmes. Dans le cas le plus simple, il s'agit d'un simple compteur à saturation, mis à jour suivant la concordance des prédictions avec le résultat effectif du branchement. Mais c'est loin d'être systématique. Une autre solution combine le résultat des différentes unités de prédiction avec une fonction mathématique adéquate. Avec un nombre impair d'unités de prédiction, une méthode assez efficace est de simplement prendre le résultat majoritaire. Si une majorité d'unités pense que le branchement est pris, alors on le considère comme pris, et comme non pris dans le cas contraire. Par contre, avec un nombre pair d'unités, cette méthode simple ne marche pas, il y a un risque d'égalité. L'unité de prédiction de branchement « '''''e-gskew''''' » utilise trois unités gshare et combine leurs résultats avec un vote à majorité. Les trois unités gshare utilisent des XOR légèrement différents, dans le sens où les bits de l'adresse du branchement choisi ne sont pas les mêmes dans les trois unités, sans compter que le résultat du XOR peut subir une modification qui dépend de l'unité de prédiction choisie. Autrement dit, la fonction de hachage qui associe une entrée à un branchement dépend de l'unité. [[File:Unité de prédiction « e-gskew ».png|centre|vignette|upright=2|Unité de prédiction « e-gskew ».]] Enfin, une dernière solution est que certaines unités de prédiction ont la priorité sur toutes les autres. Par exemple, prenons un processeur avec un ''return stack predictor'', une unité qui détecte les boucles et une unité de prédiction avec un historique. Si le ''return stack predictor'' détecte une instruction de retour de fonction, sa prédiction passera avant toutes les autres unités de prédiction. Et dans ce qui va suivre, c'est cette solution qui sera utilisé pour les unités de prédiction qu'on va étudier. ===La prédiction des branchements des boucles=== Les boucles sont à part, pour ce qui est de la prédiction de branchement. Rappelons qu'une boucle FOR/WHILE répète une série d'instruction N fois, le nombre N étant appelé le '''compteur de boucle'''. Ce qui fait que les branchements d'une boucle FOR sont pris n − 1 fois, la dernière exécution étant non prise. La conséquence est que les unités de prédiction usuelles sont assez mauvaises pour prédire les branchements des boucles. Par contre, la prédiction statique marche particulièrement bien comparé au reste. La prédiction statique prédit que les branchements des boucles sont toujours pris, ce qui donne des prédictions qui sont d'autant meilleures que la boucle est répétée un grand nombre de fois. Reste à gérer les branchements des boucles au mieux, en sachant cela. L'idée est d'utiliser plusieurs unités de prédiction séparées : une spécialisée dans les boucles et d'autres unités de prédiction. Uen unité de méta-prédiction se charge d'activer l'unité de prédiction des boucles dès qu'une boucle est détéctée. Les unités de prédiction modernes essayent de détecter les branchements des boucles afin de les mettre à part. En soi, ce n'est pas compliqué les branchements des boucles sont presque toujours des branchements descendants et réciproquement. De plus, ces branchements sont pris à chaque répétition de la boucle, et ne sont non-pris que quand on quitte la boucle. Le compteur de boucle analyse l'exécution des branchements, pour détecter les branchements des boucles. Un branchement est considéré comme un branchement de boucle si : il est pris N fois, avant de ne pas être pris une fois. Au moment où le branchement n'est plus pris, le ''Loop Predictor'' lui attribue une entrée dédié. L'entrée mémorise mémorise le nombre de fois que ce branchement a été pris, à savoir N, et est associée à un compteur. Le compteur est incrémenté/décrémenté à chaque exécution du branchement, et sa valeur est comparée à la valeur N. Quand le compteur atteint N+1, il est réinitialisé. Il s'agit d'une implémentation simple, mais est visiblement proche de celle utilisée sur le Pentium M, si j'en crois l'article "The Intel Pentium M Processor : Microarchitecture and Performance". Il faut noter que la boucle doit s'exécuter au moins une fois pour être détectée, et seules les exécutions suivantes sont prédites correctement. La boucle doit donc s'exécuter plusieurs fois, ce qui implique qu'elle est elle-même dans une boucle plus grande qu'elle. Une telle unité est donc capable de prédire spécifiquement les branchements de boucles FOR imbriquées, à savoir où une boucle FOR est dans une autre boucle FOR. Concrètement, cela permet de répéter la première boucle FOR plusieurs fois de suite, souvent un grand nombre de fois. Avec les boucles imbriquées, le compteur de boucle peut changer d'une répétition à l'autre, mais ce n'est que rarement le cas. Dans de nombreux cas, le compteur de boucle reste le même d'une répétition à l'autre. Ce qui fait qu'une fois la boucle exécutée une première fois, on peut prédire à la perfection les exécutions suivantes. Pour cela, il suffit de déterminer le compteur de boucle après la première exécution de la boucle, puis de le mémoriser dans un compteur. Lors des exécutions suivantes de la boucle, on compte le nombre de fois que le branchement de la boucle s'exécute : il est prédit comme pris tant que le compteur est différent du compteur de boucle, mais non-pris en cas d'égalité. ===Le prédicteur L-TAGE=== Il est possible d'utiliser plusieurs unités de prédiction, chacune utilisant des historiques de taille différente. Pour donner un exemple simple, on pourrait avoir une unité avec un historique de 2 bits, une autre avec un historique de 3 bits, une autre de 4 bits, etc. Une méta-unité de branchement prédit alors quelle taille d'historique donne les meilleurs résultats. Il est fréquent que les tailles d'historiques utilisées suivent une formule mathématique précise, appelée suite géométrique, ce qui est un mot bien barbare pour dire que l'on passe d'un historique au suivant en multipliant sa taille par une constante bien précise. Par exemple, on pourrait avoir des historiques de taille 2, 4, 8, 16, 32. C'est d'ailleurs le principe derrière les '''unités de branchement TAGE (''TAgged GEometric length predictor'')'''. Elles utilisent plusieurs registres d'historiques, chacun avec sa propre PHT, dont la taille suit une suite géométrique. Avec cette méthode, les branchements qui ont un motif répétitif sont alors parfaitement détectés, peu importe sa taille. Par exemple, un branchement avec un historique dont le cycle est pris, non-pris, non-pris, sera parfaitement prédit avec un historique de 3 bits, 6 bits, ou de 3*n bits, mais pas forcément avec un historique de 4 ou 8 bits. De ce fait, utiliser plusieurs unités de branchement avec plusieurs tailles d'historique fonctionne plutôt bien. Le prédicteur TAGE a été raffiné avec le temps, sur plusieurs points, que je ne vais pas détailler dans ce qui suit. Précisons cependant que les PHT sont structurées en caches, afin d'éliminer les phénomènes d'''aliasing''. L'ajout d'une unité de prédiction pour les boucles, a donné le '''prédicteur L-TAGE''', qui a lui-même été amélioré par la suite. Les unités de branchement TAGE sont les plus utilisées au moment où j'écris ces lignes (2026), et la majorité des processeurs x86 commerciaux utilisent une variante de ce prédicteur, à quelques exceptions près. Le '''prédicteur ITTAGE''' peut être adapté pour prédire des branchements indirects. La seule différence est que chaque table d'historique est associée non seulement à une prédiction pris/non-pris, mais aussi à une adresse de destination. L'idée derrière l'ITTAGE est que l'adresse de destination dépend de l'historique. L'adresse de destination dépend de ce qui se passe avant qu'on exécute le branchement indirect, donc de l'historique global. Il est donc fréquent que des historiques différents donne un résultat différent, que ce soit pour la prédiction pris/non-pris, mais aussi pour l'adresse de destination. L'ITTAGE associe donc une adresse de destination par ''pattern'' prédit. ===La prédiction de branchement à deux niveaux=== Les processeurs modernes utilisent une prédiction de branchement à deux niveaux. Une première unité de branchement fournit des résultats rapides et peu précis, la seconde a des résultats plus précis mais fournit ses résultats après quelques cycles d'horloge. La première unité envoie les adresses prédites au cache d'instruction, pour charger les instructions prédites et met à jour le ''program counter'' en conséquence. La seconde envoie les adresses prédites à l'étage de décodage d'instruction, histoire d'annuler des branchements considérés à tort comme pris/non-pris. Il y a donc une unité de prédiction rapide, dont le résultat est corrigé par une seconde unité. La seconde unité a le dernier mot, elle corrige ou annule les prédictions de la première. Ce qui lui vaut le nom, en anglais, d'''overiding predictor''. La correction se fait simplement en corrigeant le ''program counter'' et en forçant le décodeur à émettre des NOPs pendant quelques cycles, le temps d'éliminer les instructions chargées à tort. [[File:Double BTB sur les CPU modernes.png|centre|vignette|upright=2|Double BTB sur les CPU modernes.]] Les deux unités de prédiction n'utilisent pas le même algorithme. Par exemple, la microarchitecture Silvermont d'Intel utilisait deux unités de prédiction : une unité rapide basée sur des compteurs à saturation, et un prédicteur ''gshare'' lent. Les deux unités de prédiction utilisent des BTB séparés, de taille différente, au moins deux. Vu que les BTB sont des caches, on les appelle des '''BTB de niveau L1 et L2'''. Par exemple, les processeurs Bulldozer d'AMD disposent de deux BTBs : un BTB L1 de 512 entrées, et un BTB L2 de 5120 entrées. Le BTB de niveau L2 est souvent un cache de victime, qui mémorise les branchements retirés de la BTB de niveau L1. La BTB pour les branchements indirects vient en plus de ces deux BTBs, elle complète souvent la BTB de niveau L2. Le ''return stack predictor'' vient en plus des BTB L1 et L2, mais sa petite taille et sa rapidité font qu'il complète l'unité/BTB de niveau L1. Quelques processeurs ont trois niveaux de BTB, avec un niveau L0, L1 et L2. La BTB L0 aliment le cache d'instruction, alors que la L1 et la L2 préviennent les décodeurs d'instruction. La différence entre la L1 et la L2 est que la L2 met plus de temps à prévenir les décodeurs. Les branchements peuvent donc être annulés/forcés à deux étages de décodage différents. Pour donner quelques exemples, les processeurs Zen d'AMD disposent de plusieurs BTBs, avec des niveaux L0, L1 et L2. Le tableau ci-dessous vous donne la taille et la latence de chaque niveau de BTB. La BTB de niveau L0 alimente le cache d'instruction, la L1 permet d'annuler les instructions au premier cycle de décodage, la BTB L2 annule les instructions vers le milieu/fin du décodage. {|class="wikitable" |- ! rowspan="3" | Zen 2 | 16 || 0 |- | 512 || 1 |- | 7168 || 4 |- ! rowspan="3" | Zen 3 | 16 || 0 |- | 1024 || 1 |- | 6656 + 1536 (branchements indirects)|| 4 |} ==Les optimisations de la prédiction de branchements== La prédiction de branchement est complexe et toute économie est bonne à prendre. Pour améliorer les performances, ou simplement améliorer l'utilisation du BTB et des PHT, diverses techniques ont été inventées. Ces techniques marchent quel que soit l'unité de prédiction prise en compte. Elles peuvent s'appliquer aussi bien aux unités basées sur des perceptrons, que sur des unités à deux niveaux ou des unités de prédiction dynamique en général. Ces techniques sont assez variés : certaines profitent du fait que de nombreux branchements sont biaisés, d'autres tentent de réduire les interférences entre branchements sans utiliser l'historique global/local, etc. ===Le filtrage de branchements=== Une première optimisation permet d'économiser l'usage des différentes ressources matérielles, comme la BTB ou les PHT, en les réservant aux branchements non-biaisés. Idéalement, les branchements biaisés peuvent être prédits en utilisant des techniques très simples. D'où l'idée d'utiliser deux unités de prédiction spécialisées : une pour les branchements biaisés et une autre plus complexe. L'unité de prédiction pour les branchements biaisés est une unité de prédiction à 1 bit qui indique si le branchement est pris ou non, ce qui suffit largement pour de tels branchements. Cette technique s'appelle le '''filtrage de branchement''' et son nom est assez parlant. On filtre les branchements biaisés pour éviter qu'ils utilisent des PHT et d'autres ressources qui gagneraient à être utilisées pour des branchements plus difficiles à prédire. Appliquer cette idée demande de reconnaître les branchements fortement biaisés d'une manière ou d'une autre, ce qui est plus facile à dire qu'à faire. Une solution similaire enregistre quels branchements sont biaisés dans le BTB, et utilise cette information pour faire des prédictions. ===L'usage de fonctions de hashage pour indexer les diverses SRAM/tables=== Plus haut, nous avons vu que les unités de prédiction de branchement contiennent des structures qui sont adressées par l'adresse de branchement. Tel est le cas du ''Branch Target Buffer'', de certaines PHT globales ou locales, de la ''Branch History Table'' qui stocke les historiques locaux, ou encore de la SRAM des poids des unités à base de perceptrons. En pratique, ces structures sont adressées non pas par l'adresse de branchement complète, mais pas les bits de poids faible de cette adresse. Cela a pour conséquence l'apparition d'un ''aliasing'' lié au fait que ces structures vont confondre deux branchements pour lesquels les bits de poids faible de l'adresse sont identiques. Il est possible d'utiliser autre chose que les bits de poids faible de l'adresse du branchement, afin de limiter les interférences. Et les possibilités sont multiples. Les possibilités en question s'inspirent des traitements effectués sur les adresses des banques dans les mémoires évoluées. Une première solution serait de faire un XOR entre les bits de poids faible de l'adresse du branchement, et d'autres bits de cette même adresse. Ainsi, deux branchements éloignés en mémoire donneraient des résultats différents, même si leurs bits de poids faible sont identiques. Une autre possibilité serait de diviser l'adresse du branchement par un nombre et de garder le reste. Ce calcul de modulo n'est en soi pas très différent du fait de conserver seulement les bits de poids faible. Conserver les N bits de poids faible consiste en effet à prendre le modulo par 2^N de l'adresse. Ici, l'idée serait de faire un modulo par un nombre P, qui serait un nombre premier proche de 2^N. Le fait que le nombre soit premier limite les cas où deux adresses différentes donneraient le même reste, ce qui réduit l'''aliasing''. Le fait de prendre un nombre proche de 2^N pour une entrée de N bits est que le résultat reste assez proche de ce qu'on obtiendrait en gardant les bits de poids faible, cette dernière étant une solution pas trop mauvaise. D'autres possibilités similaires se basent sur des réductions polynomiales ou d'autres astuces impliquant des nombres premiers. L'efficacité de ces méthodes dépend grandement de la taille de la SRAM/table considérée, des adresses des branchements et de beaucoup d'autres paramètres. La réduction des interférences par telle ou telle méthode dépend aussi de l'unité de prédiction considérée. Les résultats ne sont pas les mêmes selon que l'on parle d'une unité à perceptrons ou d'une unité à deux niveaux ou d'unités à base de compteurs à saturation, ou que l'on parle du BTB. Par contre, toutes les méthodes ne sont pas équivalentes en termes de temps de calcul ou de portes logiques. Autant la première solution avec un XOR ajoute un temps de calcul négligeable et quelques portes logiques, autant les autres méthodes requièrent l'ajout d'un diviseur très lent et gourmand en portes logiques pour calculer des modulos. Elles ne sont généralement pas praticables pour ces raisons, le temps de calcul serait trop élevé. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Le pipeline | prevText=Le pipeline | next=Les optimisations du chargement des instructions | nextText=Les optimisations du chargement des instructions }} </noinclude> 1d6gc3qqkgt7h11qimtiao01zzualnk 774409 774408 2026-10-07T22:18:57Z Mewtow 31375 /* La prédiction de destination pour les branchements indirects */ 774409 wikitext text/x-wiki Les branchements sont pris en charge par plusieurs étages du pipeline. Exécuter un branchement demande de savoir deux choses : s'il est pris ou non, quelle est l'adresse de destination. L'adresse de destination est localisée grâce au mode d'adressage du branchement. Avec le mode d'adressage direct, elle est extraite de l'instruction par le séquenceur. Avec l'adressage indirect, elle est lue dans un registre. Avec les branchements relatifs, elle est calculée via un additionneur, qui additionne un décalage au ''program counter''. L'additionneur peut être soit dans l'étage de ''PC Update'', soit dans l'unité de calcul de l'étage d'exécution. Passons maintenant au résultat du branchement, à savoir s'il est pris ou non. Pour savoir si le branchement est pris, il faut distinguer les branchements conditionnels et inconditionnels. Pour rappel, les branchements inconditionnels regroupent les appels de fonction, les simples instructions de saut, et quelques autres. Ils sont toujours pris, ils s'exécutent toujours. À partir du moment où le décodeur a décodé un branchement inconditionnel, il sait que le branchement est pris. Pour les branchements conditionnels, ils sont pris seulement si une condition précise est respectée. On sait s'ils sont exécutés une fois que la condition est calculée par l'unité de calcul, ou par une unité de branchement spécialisée. [[File:Gestion des branchements sur un pipeline.png|centre|vignette|upright=2.0|Gestion des branchements conditionnels sur un pipeline.]] Sur le schéma précédent, les branchements inconditionnels ne sont pas montrés. Pour cela, il faudrait rajouter une connexion du décodeur vers le multiplexeur de branchement. De plus, il faudrait aussi rajouter de quoi retarder l'exécution du branchement seulement une fois que l'adresse est connue. Par exemple, elle est connue un cycle d'horloge plus si l'adresse est lue depuis les registres, comparé à un branchement direct. ==Les branchements et les exceptions imprécises/précises== Un branchement est censé altérer le ''program counter'' immédiatement, en un cycle d'horloge. Mais sur un pipeline, Le branchement fait bien reprendre le processeur au bon endroit, mais avec du retard. La raison est qu'un branchement met plusieurs cycles pour s'exécuter. Il faut plusieurs cycles pour déterminer quelle est l'adresse de destination, temps qui dépend de là où est lue l'adresse. Et on sait si le branchement est pris seulement au niveau du décodeur, voire au niveau de l'unité de calcul. Le branchement doit traverser plusieurs étages avant qu'on connaisse son résultat. Il y a bien 3 à 4 cycles avant cela. Et pendant ce temps de trajet, des instructions seront chargées dans le pipeline, potentiellement à tort. Si le branchement n'est pas pris, ce n'est pas grave : les instructions n'ont pas été chargées à tort. Mais dans le cas contraire, les instructions n'auraient pas dû rentrer dans le pipeline. Le problème est similaire à celui observé avec les exceptions précises/imprécises. La différence est que les branchements sont des instructions machines, pas les exceptions matérielles. Mais le problème des instructions chargées à tort est le même dans les grandes lignes. Une différence est que les branchements sont résolus plus ou moins au même étage, alors que les exceptions peuvent survenir dans tout le pipeline. Une autre différence, bien plus importante, est que les branchements non-pris ne chargent pas d'instructions à tort et qu'il faut faire la différence entre branchement pris et non-pris. Dans la suite, nous parlerons d'instructions fautives pour parler des instructions chargées à tort. Pour corriger ce problème, il existe une solution logicielle. Le compilateur fait suivre les branchements par des instructions qui ne font rien, des NOP (No OPeration) : c'est ce qu'on appelle un '''délai de branchement'''. L'idée est que toutes les instructions chargées à tort sont des NOPs, qui ne font rien. Mais le nombre de NOP à ajouter dépend du pipeline du processeur, ce qui peut poser des problèmes de compatibilité. Mais d'autres solutions, matérielles cette fois, sont utilisées de nos jours. ===L'annulation des instructions au décodage=== Les branchements inconditionnels sont dans un cas plus favorable : ils sont identifiés dans l'unité de décodage et sont toujours pris. D'autres branchements sont dans ce cas : les appels de fonctions, les interruptions logicielles. Avec eux, le décodeur annule les instructions fautives, il les remplace par des instructions NOP. L'étage de décodage sait que les instructions suivant un branchement inconditionnel ont été chargées à tort. Précisément N instructions, avec N le nombre d'étages entre le chargement et le décodage (inclus). Les N instructions suivantes sont tout simplement annulés, elles sont remplacées par des NOPs. L'implémentation demande juste un simple compteur et quelques circuits annexes. Cependant, la technique ne marche pas du tout pour les branchements conditionnels. En effet, on ne sait pas s'ils sont pris ou non à l'étage de décodage. Du moins pas avec un pipeline normal. Mais certains processeurs, basés sur le pipeline RISC classique, calculaient les branchements pendant l'étape de décodage, dans une ALU spécialisée qui travaille en parallèle de l'unité de décodage. Le défaut de cette technique est que l'ALU en question doit lire les registres. Pour rappel, sur ce pipeline, l'étape de décodage effectue la lecture des opérandes dans les registres, en parallèle de l'unité de décodage. Mais ce qu'on n'a pas dit dans le chapitre précédent, c'est qu'elle effectue aussi le calcul des branchements dans une mini-ALU séparée ! Ce faisant, le résultat du branchement est connu dès la fin de l'étape de décodage. ===L'usage du système d'exceptions précises pour les branchements conditionnels=== La solution gère le problème en empêchant les instructions chargées à tort d'enregistrer leurs résultats, afin qu'elles n'aient pas le moindre effet sur l'état du processeur. Le hardware pour les exceptions précises, décrit quelques paragraphes avant, est réutilisé pour cela. Les branchements conditionnels peuvent être pris ou non-pris et on doit tenir compte de ces deux cas. Si le branchement n'est pas pris, les instructions n'ont pas été chargées à tort, elles peuvent enregistrer leur résultat dans les registres. Par contre, ce n'est pas le cas pour les branchements pris, les mécanismes d'exceptions précise doivent alors d'activer. Pour cela, la meilleure solution est que l'unité de calcul génère un indicateur d'exception si le branchement n'est pas pris. L'ALU effectue la comparaison et génère ou non l'indicateur d'exception adéquat suivant le résultat de celle-ci. L'idée est d'utiliser un indicateur d'exception particulier, qui précise qu'un branchement a été pris, signe que des instructions ont été chargées à tort. L'indicateur est propagé dans le pipeline, jusqu'à atteindre l'étage d'enregistrement dans les registres. Cet étage décide s'il faut autoriser ou non les écritures qu'il recoit, pour annuler les instructions chargées à tort. Le ''program counter'' est corrigé immédiatement, simplement en exécutant le branchement. Reste à savoir qui génère cet indicateur d'exception de branchement pris. Diverses optimisations visent à éliminer les instructions chargées à tort, à faire en sorte que des instructions utiles soient chargées. Le chapitre suivant va détailler ces optimisations dans le détail, qui sont regroupées sous le terme de prédiction de branchement. Elles ne fonctionnent pas pour les exceptions précises, seulement les branchements. ===La prédiction de branchement=== Les techniques précédentes ont pour défaut que de nombreux cycles d'horloge sont gâchés à cause des instructions fautives. Pour éviter ce gâchis, les concepteurs de processeurs ont inventé l'''exécution spéculative de branchement'', qui consiste à deviner l'adresse de destination du branchement et charger les bonnes instructions dès le départ. Le but est de ne pas charger d'instruction à tort, en prédisant quelles seront les bonnes instructions à charger. Et cela demande de résoudre trois problèmes : * reconnaître les branchements ; * savoir si un branchement sera exécuté ou non : c'est la '''prédiction de branchement''' ; * dans le cas où un branchement serait exécuté, il faut aussi savoir quelle est l'adresse de destination : c'est la '''prédiction de l'adresse de destination''' d'un branchement, aussi appelée ''branch target prediction''. Pour résoudre le second problème, le processeur contient un circuit qui déterminer si le branchement est pris (on doit brancher vers l'adresse de destination) ou non pris (on poursuit l’exécution du programme immédiatement après le branchement) : c'est l''''unité de prédiction de branchement'''. La prédiction de direction de branchement est elle aussi déléguée à un circuit spécialisé : l’'''unité de prédiction de direction de branchement'''. C'est l'unité de prédiction de branchement qui autorise l'unité de prédiction de destination de branchement à modifier le ''program counter''. Dans certains processeurs, les deux unités sont regroupées dans le même circuit. [[File:Relation entre prédiction de branchement et de direction de branchement.png|centre|vignette|upright=2|Relation entre prédiction de branchement et de direction de branchement.]] ==Les erreurs de prédiction de branchement== Le processeur peut parfaitement se tromper en faisant ses prédictions, ce qui charge des instructions par erreur dans le pipeline. Dans ce cas, on parle d''''erreur de prédiction'''. Les erreurs de prédiction sont corrigées par le système d'exception précises, ou un équivalent. En clair, les instructions chargées à tort se propagent jusqu'à la sortie du pipeline, où leurs résultats sont alors annulés. N'importe quelle technique de gestion des exceptions précise fait le travail, mais les processeurs modernes utilisent un tampon de réordonnancement (''reorder buffer'') qu'on détaillera dans quelques chapitres. Détecter les erreurs de prédiction demande d'ajouter un circuit dans le processeur : l’''unité de vérification de branchement'' (''branch verification unit''). Elle génère un indicateur d'exception, qui est géré par le système d'exception précise. Pour générer l'indicateur, elle compare l'adresse de destination prédite et celle calculée en exécutant le branchement. Pour cela, l'adresse prédite doit être propagée dans le pipeline jusqu’à ce que l'adresse de destination du branchement soit calculée. [[File:Unité de vérification de branchement.png|centre|vignette|upright=2|Unité de vérification de branchement]] ===La pénalité de mauvaise prédiction=== Le temps pour vider le pipeline des instructions fautives dépend du pipeline. La dernière instruction à être chargée dans le pipeline le sera en même temps qu'on détecte l'erreur de prédiction. Il faudra attendre que cette instruction quitte le pipeline, et donc qu'elle traverse tous les étages. Il y a donc un temps d'attente pour collecter les résultats de ces instructions et la décision de ne pas enregistrer leurs résultats dans les registres Le nombre d'instructions chargées à tort dépend de où est détectée l'erreur de prédiction. Plus le résultat du branchement est connu tard, plus le nombre d'instructions chargées à tort sera grand, et plus le nombre de cycles d'horloge gâchés le sera aussi. Le nombre de cycles gâchés s'appelle la '''pénalité de mauvaise prédiction'''. Elle correspond précisément au nombre de cycles/étages entre le premier étage du pipeline, et l'étage où le branchement est résolu (on connait l'adresse à laquelle brancher). Plus celui-ci est éloigné du début du pipeline, plus la pénalité est importante. [[File:MIT 6.823 Computer System Architecture, Fall 2005 - P-3 vs. P-4 Pipelines - 4473425702.jpg|centre|vignette|upright=2.5|Comparaison de la pénalité de mauvaise prédiction entre le Pentium 3 et le Pentium 4 d'Intel.]] Elle est généralement d'autant plus longue que le pipeline lui-même est long. Les concepteurs de processeurs font donc face à un compromis : un pipeline long permet d'exécuter plus d'instructions en parallèle, mais cela augmente aussi la pénalité de mauvaise prédiction. Un pipeline de 10-15 étapes est un bon compromis. Les pipelines plus longs ont besoin d'une prédiction de branchement élaborée pour fonctionner correctement. Plus la prédiction de branchement est performante, plus le pipeline peut être long. Les pénalités de mauvaise prédiction seront plus importantes, mais les mauvaises prédictions seront plus rares, ce qui compense complétement. ===La ''minimal control dependency''=== En utilisant une technique du nom de ''minimal control dependency'', seules les instructions qui dépendent du résultat du branchement sont supprimées du pipeline en cas de mauvaise prédiction, les autres n'étant pas annulées. Sans ''minimal control dependency'', toutes les instructions qui suivent un branchement dans notre pipeline sont annulées. Pourtant, certaines d'entre elles pourraient être utiles. Prenons un exemple : supposons que l'on dispose d'un processeur de 31 étages (un Pentium 4 par exemple). Supposons que l'adresse du branchement est connue au 9éme étage. On fait face à un branchement qui envoie le processeur seulement 6 instructions plus loin. Si toutes les instructions qui suivent le branchement sont supprimées, les instructions en rouge sont celles qui sont chargées incorrectement. On remarque pourtant que certaines instructions chargées sont potentiellement correctes : celles qui suivent le point d'arrivée du branchement. Elles ne le sont pas forcément : il se peut qu'elles aient des dépendances avec les instructions supprimées. Mais si elles n'en ont pas, alors ces instructions auraient dûes être exécutées. Il serait donc plus efficace de les laisser enregistrer leurs résultats au lieu de les ré-exécuter à l’identique un peu plus tard. Ce genre de choses est possible sur les processeurs qui implémentent une technique du nom de ''Minimal Control Dependancy''. En gros, cette technique fait en sorte que seules les instructions qui dépendent du résultat du branchement soient supprimées du pipeline en cas de mauvaise prédiction. [[File:Minimal cntrol dependency.png|centre|vignette|upright=2|Minimal control dependency]] ==La reconnaissance des branchements== Pour prédire des branchements, le processeur doit faire la différence entre branchements et autres instructions, dès l'étage de chargement. Or, un processeur « normal », sans prédiction de branchement, ne peut faire cette différence qu'à l'étage de décodage, et pas avant. Les chercheurs ont donc dû trouver une solution. La première solution se base sur les techniques de prédécodage vues dans le chapitre sur le cache. Pour rappel, ce prédécodage consiste à décoder partiellement les instructions lors de leur chargement dans le cache d'instructions et à mémoriser des informations utiles dans la ligne de cache. Dans le cas des branchements, les circuits de prédécodage peuvent identifier les branchements et mémoriser cette information dans la ligne de cache. Une autre solution mémorise les branchements déjà rencontrés, dans un cache dédié. Ce cache mémorise l'adresse (le ''program counter'') des branchements déjà rencontrés : il s'appelle le '''tampon d’adresse de branchement''' (''branch adress buffer''). Il est intégré à l'unité de prédiction de branchement, mais est alimenté par le décodeur d'instruction. Reconnaitre un branchement demande l'implication du décodeur d'instruction, qui sait si l'instruction qu'il vient de décoder est un branchement ou non. Il peut même savoir si le branchement est conditionnel, inconditionnel, un appel de retour de fonction, etc. Lorsque le décodeur d'instruction reconnait un branchement, il ajoute ce branchement dans le cache Le tapon d'adresse de branchement contient une adresse mémoire par branchement, et éventuellement d'autres informations. Et pour comprendre quelle adresse est mémorisée, il faut faire attention à un détail. Les explications qui vont suivre vont faire intervenir deux adresses. * La première est l''''adresse de destination''' du branchement, à savoir l'adresse à laquelle le processeur doit reprendre son exécution si le branchement est pris. * L'autre adresse est l''''adresse du branchement''' lui-même. Elle correspond à sa position en mémoire RAM. L'adresse du branchement est la valeur du ''program counter'' quand le branchement est chargé dans le cache, alors que l'adresse de destination est celle du ''program Counter'' une fois le branchement exécuté. Il faudra faire bien attention à ne pas confondre les deux adresses dans ce qui suit. L'adresse du branchement est celle qui est utilisée pour la reconnaissance du branchement. Le tampon d'adresse de branchement mémorise, pour chaque branchement, son adresse de branchement (d'où son nom). À chaque cycle d'horloge, l'unité de chargement envoie le ''program counter'' en entrée du tampon d’adresse de branchement. Un succès dans ce cache indique qu'un branchement déjà rencontré est ré-executé, on peut utiliser la prédiction de branchement. Dans le cas contraire, la prédiction de branchement n'est pas utilisée, et l'instruction est chargée normalement. ==La prédiction de l'adresse de destination== La '''prédiction de l'adresse de destination d'un branchement''' détermine l'adresse de destination d'un branchement. Rappelons qu'il existe plusieurs modes d'adressage pour les branchements et que chacun d'entre eux précise l'adresse de destination à sa manière. Suivant le mode d'adressage, l'adresse de destination est soit dans l'instruction elle-même (adressage direct), soit calculée à l’exécution en additionnant un ''offset'' au ''program counter'' (adressage relatif), soit dans un registre du processeur (branchement indirect), soit précisée de manière implicite (retour de fonction, adresse au sommet de la pile). : Histoire de simplifier la terminologie, je parlerais parfois de '''prédiction de destination''' pour parler de la ''prédiction de l'adresse de destination d'un branchement''. Pour rappel, les branchements se classent en trois types : * les branchements directs pour lequel l'adresse de destination est toujours la même ; * les branchements indirects où l'adresse de destination est dans un registre et peut changer ; * les instructions de retour de fonction. Les branchements directs sont facilement prévisibles, vu que l'adresse vers laquelle il faut brancher est toujours la même. La prédiction des branchements directs et relatifs se fait globalement de la même manière, ce qui fait que nous ferons la confusion dans ce qui suit. Pour les instructions de retour de fonction, une prédiction parfaite est possible. Et c'est ce qui explique qu'elles sont traitées à part, par une unité de branchement dédiée. Pour les branchements indirects, vu que cette adresse change, la prédire celle-ci est particulièrement compliqué (quand c'est possible). Les branchements indirects sont eux aussi traités par une unité de prédiction dédiée rien que pour eux. ===La prédiction de destination pour les branchements directs=== Lorsqu'un branchement est exécuté, on mémoriser l'adresse de destination et la réutiliser lors d'exécutions ultérieures du branchement. Cette technique marche à la perfection pour les branchements directs, pour lesquels cette adresse est toujours la même, mais donne des résultats mitigés pour les branchements indirects. Pour mémoriser l'adresse de destination, on utilise un cache qui mémorise les correspondances entre l'adresse du branchement et l'adresse de destination. Ce cache est appelé le '''''branch target buffer''''' en anglais, qui sera abrévié en BTB dans ce qui suit. Le BTB est une amélioration du tampon d'adresse de branchement vu plus haut, auquel on aurait ajouté les adresses de destination des branchements. Le BTB est utilisé comme suit : on envoie en entrée l'adresse du branchement, le BTB répond au cycle suivant en précisant : s’il reconnaît l'adresse d'entrée, et quelle est l'adresse de destination si c'est le cas. Le BTB peut être un cache totalement associatif, associatif par voie, ou directement adressé, mais ces derniers sont les plus fréquents. Les BTB sont généralement des caches de type directement adressés, où seuls les bits de poids faible de l'adresse du branchement adressent le cache, le reste de l'adresse est tout simplement ignoré. Il existe aussi des BTB qui sont construits comme les caches associatifs à plusieurs voies, mais ceux-ci impliquent généralement la présence de ''tags'', ce qui fait qu'ils sont un peu moins fréquents. [[File:Branch target buffer directement adressé.png|centre|vignette|upright=2|Branch target buffer directement adressé.]] Comme pour tous les caches, un accès au BTB peut entraîner un défaut de cache, c’est-à-dire qu'un branchement censé être dans le BTB n'y est pas. Comme pour les autres caches, les défauts de cache peuvent se classer en trois types : les défauts de cache à froid (''cold miss'') causés par la première exécution d'un branchement, les défauts liés à la capacité du BTB/cache, et les défauts par conflit d'accès au cache. Les défauts liés à la capacité du BTB sont les plus simples à comprendre. La capacité limitée du BTB fait que d'anciens branchements sont éliminées pour laisser la place à de nouveaux. En conséquence, ces branchements peuvent donner des erreurs de prédiction si leur correspondance a été éliminée du cache. On peut réduire des défauts en augmentant la taille du BTB. Les défauts liés aux conflits d'accès au BTB sont eux beaucoup plus intéressants, car la conception du BTB est assez spéciale dans la manière dont sont gérés ce genre de défauts. Le BTB est un cache partiellement associatif, ce qui fait que des défauts de cache par conflit d'accès peuvent avoir lieu. Pour rappel, les défauts par conflit d'accès ont lieu quand deux adresses mémoires se voient attribuer la même ligne de cache. Pour un BTB, cela correspond au cas où deux branchements se voient attribuer la même entrée dans le BTB, ce qui porte le nom d’''aliasing''. Ne pas détecter ce genre de situation sur un cache normal entraînerait des problèmes : la donnée lue/écrite ne serait pas forcément la bonne. Les caches normaux utilisent un système de tags pour éviter ce problème et on s'attendrait à ce que les BTB fassent de même. Mais pour un BTB, l'absence de ''tags'' n'est pas un problème : la seule conséquence est une augmentation du taux de mauvaises prédictions, dont les conséquences en termes de performance peuvent être facilement compensées. Les BTB peuvent donc se passer de tags, ce qui a de nombreux avantages : le circuit est plus rapide, prend moins de portes logiques, consomme moins d'énergie, etc. Ces économies de portes logiques et de performances peuvent être utilisées pour augmenter la taille du BTB, ce qui compense, voire surcompense les mauvaises prédictions induites par l’''aliasing''. Mais ce n'est pas systématique : d'autres BTBs utilisent des ''tags'' pour améliorer le taux de prédiction. Nous ferons donc la différence entre '''BTB tagués''' et '''BTB non-tagués'''. Des processeurs se passent de BTB. À la place, ils mémorisent les correspondances entre branchement et adresse de destination dans les bits de contrôle du cache d'instructions. Mais nous verrons cela en détail dans le chapitre suivant. ===La prédiction de destination pour les branchements indirects=== Un BTB classique peut fonctionner pour les branchements indirects. Son seul défaut est qu'il mémorisera la dernière adresse de destination prise par le branchement. Et cela peut fonctionner pour les branchements indirects fortement biaisés, qui ont presque toujours la même adresse de destination. Mais la majorité des branchements indirects change d'adresse de destination régulièrement. Tous les processeurs commerciaux datant d'avant le Pentium M utilisaient un BTB normal pour prédire les branchements indirects. Les processeurs qui les ont suivi sont capables de prédire l'adresse de destination d'un branchement indirect. , grâce à une '''unité de prédiction des branchements indirects''', séparée de l'unité de prédiction de branchement normale. Elle utilise un BTB amélioré, qui mémorise plusieurs adresses de destination pour un seul branchement, en compagnie d'informations pour prédire laquelle est la bonne. Il est possible d'utiliser un BTB unique, pour tous types de branchements, appelé un '''BTB unifié'''. Un BTB unifié a plusieurs adresse de destination par entrée du BTB. Mais une seule est utilisée pour les branchements directs. Vu que les branchements directs sont majoritaires, cela fait beaucoup d'entrées inutilisées. En conséquence, elle est tout sauf optimale, et n'est presque jamais utilisée en pratique. {|class="wikitable" |+ Branch Target Buffer unifié |- ! Adresse du branchement !! Informations de prédiction !! Adresse de destination !! Seconde adresse de destination |- | 0x54462146 || 11101011 || 0x52495674 || 0x24987461 |- | 0x54142446 || 11101101 || 0x15646956 || x0x4158746 |- | 0x25456542 || 10011101 || 0x52495674 || 0x24157461 |- | 0x15455855 || 11010101 || 0x52485754 || 0x24024511 |- | ... || ... || ... || ... |- | ... || ... || ... || ... |- | 0x54462146 || 1001101 || 0x52495674 || 0x24987461 |} Une seconde solution utilise un BTB séparé, distinct du BTB pour les branchements directs. En séparant les deux, on a un BTB simple qui ne mémorise qu'une adresse de destination par branchement, et un BTB pour branchements indirects avec plusieurs adresses de destination par branchement. [[File:BTB avec prédiction de branchements indirects.png|centre|vignette|upright=2.5|BTB avec prédiction de branchements indirects.]] L'idée est que l'on économise des circuits à nombre d'entrées égales. Par exemple, comparons ce que cela donne avec 64 entrées. D'un côté, on a un BTB à 64 entrées, capable de gérer deux adresse de destination par branchement. En pratique, la majorité des branchements sont des branchements directs, qui n'utiliseraient qu'une seule case mémoire. Les circuits du BTB seraient donc sous-utilisés. De l'autre, on a un BTB à 48 entrées pour branchements directs, et un second BTB avec 16 entrées, capable de mémoriser deux adresses par branchements. Le premier BTB utilise moins de circuits, et est utilisé à pleine capacité, il ne gaspille pas de circuits pour mémoriser des adresses de destination vides. Par contre, on perd un peu en flexibilité. L'organisation précédente marche bien si 1/4 des branchements rencontrés sont des branchements indirects. Mais si le ratio est différent, la performance est légèrement sous-optimale. Attention, cela ne veut pas dire qu'il y aura des entrées vides dans un BTB, non. Simplement, le premier BTB risque de manquer de place pour les branchements directs récents, alors que les branchements indirects seront eux bien prédits, même les anciens. À l'opposé, un BTB unique permet de mémoriser les 64 derniers branchements, peu importe qu'ils soient indirects ou non. En clair : un BTB unique est plus flexible, ce qui donne un gain de performance dans certaines situations. Cependant, cet avantage théorique ne tient pas compte de l'économie en circuits, qui peut être utilisée pour faire grossir le BTB pour branchements directs. Par exemple, à budget à transistor équivalent, on a le choix entre BTB à 64 entrées uniques, et un BTB pour branchements directs de 80 entrées avec un BTB indirect de 16 entrées. Le fait qu'un BTB soit plus gros permet des gains de performances qui compensent la perte de flexibilité. On peut ainsi avoir le meilleur des deux monde, à savoir une économie de circuits et les gains en performance qui en découlent, malgré une petite perte de flexibilité. Une troisième solution utilise un BTB normal, mais réserve plusieurs entrées pour un branchement indirect. Par exemple, un branchement indirect qui a trois adresses de destination prendra trois entrées : une par adresse de destination. Mais quelques difficultés surviennent alors. Comment placer trois copies d'un même branchement, alors que ces trois copies ont le même ''program counter'' ? Et que faire pour savoir quelle copie sera prise ? Que faire si l'unité de prédiction de branchement prédit les trois copies comme prises ? La technique du '''''Program Counter'' virtuel''' (''Virtual Program Counter'') résoud ces problèmes d'une manière assez élégante. L'idée est la même que précédemment, à savoir qu'il y a une copie du branchement par adresse de destination, dans le BTB. Mais ces copies n'ont pas exactement le même ''Program counter''. Chaque copie se voit attribuer un ''Program Counter'' virtuel. Et comprendre pourquoi demande de comprendre l'idée sous-jacente. La technique a été inspirée par une optimisation des compilateurs, qui transforme un branchement indirects en une séquence de branchements conditionnels. Par exemple, un branchement indirects qui peut brancher vers les adresses A, B et C est transformé en trois branchements conditionnels : un qui branche vers A, un autre qui branche vers B et un autre vers C. Elle ne marche pas pour tous les branchements indirects, elle demande des circonstances particulières pour fonctionner. Mais l'idée est adaptée pour le ''design'' de l'unité de prédiction de branchement. L'idée est qu'un branchement indirect est déroulé en plusieurs branchements dans le BTB. Les branchements sont traités en séquence : l'unité de prédiction prédit si le premier branchement est pris ou non. S'il est pris, son adresse de destination est envoyée au cache d'instruction. S'il n'est pas pris, l'unité de prédiction tester la seconde copie du branchement, et rebelote. Il teste toutes les copies, une par une, et s’arrête quand il tombe sur un branchement pris. Si aucune copie n'est prise, alors le branchement est prédit comme non-pris. Première remarque : il s'agit d'un processus itératif, où on test un branchement à la fois. Et oui, c'est une prédiction par cycle d'horloge. Prédire un branchement indirect peut donc demander plusieurs cycles d'horloge. Mais ce n'est pas un problème, car la prédiction peut être utilisée par l'unité de décodage, pour annuler les instructions chargées à tord. Deuxième remarque : comment passer d'un branchement à l'autre ? La première copie a un ''program counter'' normal, qui correspond à l'adresse du branchement. La seconde a un ''program counter'' qui est calculé à partir de cette première adresse, la troisième copie a une adresse calculée à partir de la seconde, etc. Concrètement, n'importe quelle fonction un tant soit peu simple suffit, tant qu'elle est déterministe. L'article qui a proposé cette techniques (''Virtual Program Counter (VPC) Prediction : Very Low Cost Indirect Branch Prediction Using Conditional Branch Prediction Hardware'') se contenait de faire un XOR entre l'adresse précédente et une constante, avec une constante différente par itération. Troisième remarque : l'algorithme demande de faire une parenthèse dans le fonctionnement normal du BTB. Quand un branchement indirect est détecté, il lance une succession de prédiction pour prédire un branchement. L'incrémentation du ''program counter'' normale cesse pendant ce processus. Et cela ne doit être fait que quand un branchement indirect est détecté. Pour cela, le BTB mémorise le type de chaque branchement, sans l'entrée du BTBT : est-ce qu'il est conditionnel, inconditionnel, mais aussi direct ou indirect. Et le type est accesible en sortie du BTB, sur une sortie de quelques bit. Quand l'unité de prédiction remarque qu'un branchement indirect vient de sortir du BTB, alors elle lance le processus. [[File:Virtual program counter.png|centre|vignette|upright=2.5|Virtual program counter]] L'avantage de cette technique est qu'elle s’implémente facilement, son cout en transistors est faible. Elle demande juste d'ajouter quelques circuits autour d'un BTB classique. Elle utilise l'unité de prédiction classique, pas besoin d'une unité de prédiction séparée. Vu qu'il y a un seul BTB, on n'a pas les désavantages d'un BTB séparé pour les branchements indirects. Les entrées du BTB sont réparties entre branchements directs et indirects de manière optimale. Niveau performance, elle est un peu moins bonne que les unités de prédiction ITTAGE qu'on détaillera dans la suite du chapitre. Mais elle est suffisante pour être utilisée sur certains processeurs commerciaux. ===La prédiction de l'adresse de retour d'une fonction=== Presque tous les processeurs conçus après les années 2000 peuvent prévoir l'adresse à laquelle il faudra reprendre lorsqu'une fonction/sous-programme a fini de s’exécuter. Ils font de la '''prédiction de retour de fonction'''. Pour cela, ils possèdent pour une unité de prédiction de branchement spécialisée, appelée le ''return stack predictor'', ou '''''pile de prédiction de retour de fonction'''''. Le terme ''return stack predictor'' étant plus court, nous l'utiliserons dans la suite du cours. Le ''return stack predictor'' est capable de reconnaitre les instructions de retour de fonction, et de prédire l'adresse de retour. Pour comprendre comment elle fait, rappelez-vous le chapitre sur la pile d'appel. Dans ce chapitre, j'avais introduit le concept de ''pile d'adresse de retour'', à savoir une pile d'appel très simplifiée, qui ne contient que les adresses de retour des fonctions en cours d'exécution. Le ''return stack predictor'' est essentiellement composé d'une telle pile d'adresse de retour, avec des circuits pour empiler et dépiler les adresses de retour. Le ''return stack predictor'' doit reconstruire une pile d'adresses de retour, à partir du programme exécuté. La pile d'adresse est mémorisée dans le ''return stack predictor'', elle est totalement séparée de la pile d'appel normale. Le programme exécuté continue d'utiliser la même pile d'appel qu'avant, ce qui fait que les adresses de retour sont dispersées dans la pile d'appel, parfois dans des registres spéciaux du processeur. Mais cela ne pose pas de problèmes pour le processeur, qui reconstruit la pile d'adresse de retour avec l'aide du décodeur. La reconstruction se fait à partir des instructions d'appel et de retour de fonction. Une instruction d'appel de fonction empile une adresse de retour, une instruction de retour de fonction la dépile. Lorsque le décodeur d'instruction reconnait une instruction d'appel de fonction, il extrait l'adresse de retour et l'envoie au ''return stack predictor''. L'adresse de retour est alors empilée, ce qui fait qu'elle se retrouve au sommet de la pile d'adresse de retour. Dépiler une adresse de retour revient à prédire un branchement. Lorsque le ''return stack predictor'' détecte une instruction de retour de fonction, il dépile l'adresse au sommet de la pile. L'adresse est alors envoyée du cache d'instruction, pour l'ire l'instruction adéquate, et le ''program counter'' est mis à jour avec cette adresse. Toute la difficulté revient à détecter les instructions de retour de fonction, sans quoi on ne sait pas quand dépiler l'adresse. La documentation là-dessus n'est pas très fournie, il est difficile de savoir ce qui est fait dans les processeurs modernes. Mais on peut faire quelques suppositions. La première possibilité est l'usage du ''prédécodage'', une technique qui décode partiellement les instructions lors de leur chargement dans le cache d'instruction. Elle sera vue dans le chapitre suivant, mais parlons-en rapidement. Le prédécodage ajoute quelques bits au début de chaque instruction pour préciser si c'est un branchement conditionnel, inconditionnel, un retour de fonction. On peut ainsi savoir rapidement quand dépiler : dès qu'une instruction est lue dans le cache d'instruction, le ''return stack predictor'' vérifie les bits ajoutés et agit en fonction. La seconde possibilité est que le BTB reconnait l'instruction de retour de fonction. Pour chaque branchement, il mémorise non seulement son adresse de retour, mais aussi son type. Par type, on veut dire : si c'est un branchement conditionnel, inconditionnel, un retour de fonction. Les retours de fonction sont donc reconnus à condition qu'ils aient été exécutés auparavant. Il est aussi possible d'avoir un BTB spécialisé dans les retours de fonction, qui est alimenté par les décodeurs. Dès que le décodeur reconnait une instruction de retour de fonction, il ajoute son ''program counter'' dans ce BTB spécialisé. Une dernière possibilité est que le ''return stack predictor'' agit une fois l'instruction partiellement décodée. Le décodeur d'instruction reconnait les instructions de retour de fonction rapidement, en un cycle, et consulte alors le ''return stack predictor''. L'adresse est dépilée, et le décodeur sait où brancher. Il n'a pas besoin de devoir lire l'adresse de retour en mémoire, il la lit depuis le ''return stack predictor'', directement. La prédiction se fait donc assez tard dans le pipeline, quelques instructions ont été chargées à tort. Mais le décodeur peut les annuler, comme il le ferait avec un branchement inconditionnel mal prédit. ==La prédiction de branchement locale== La prédiction de branchement tente de prédire si un branchement sera pris ou non-pris, et décide quelle adresse envoyer au cache en fonction du résultat. Si on prédit qu'un branchement est non pris, on laisse le ''program counter'' faire son travail. À l'inverse si le branchement est prédit comme étant pris, l'adresse de destination est envoyée au cache d'instruction et au ''program counter''. La prédiction de branchement assigne une probabilité qu'un soit branchement soit pris en fonction de ces informations : si la probabilité est de plus de 50%, le branchement est considéré comme pris. La prédiction de branchement se base avant tout sur des statistiques, c’est-à-dire qu'elle détermine la probabilité d’exécution d'un branchement en fonction d'informations assez diverses. La prédiction de branchement n'a d'intérêt que si les prédictions sont suffisamment fiables pour valoir le coup. Rappelons que la pénalité lors d'une mauvaise prédiction est importante, d'autant plus coûteuse que le pipeline est long. Il est généralement admis qu'il faut au minimum un taux de réussite proche de 90%, voire plus sur les processeurs modernes. Heureusement, la plupart des branchements sont des '''branchements biaisés''', c’est-à-dire qu'ils sont presque toujours pris ou presque toujours non-pris, sauf en de rares occasions. De tels branchements font que la prédiction des branchements est facile, bien qu'ils posent paradoxalement quelques problèmes, comme nous le verrons plus bas. Un autre point important est que les unités de prédiction de branchement doivent être très rapides. Idéalement, elles doivent fournir leur prédiction en un seul cycle d'horloge. Un seul cycle d'horloge est un temps très court, surtout sur les ordinateurs avec une fréquence élevée. Les techniques de prédiction dynamique ne peuvent donc pas utiliser des méthodes statistiques extraordinairement complexes. Cela va à l'encontre du fait que le taux de mauvaises prédictions doit être très faible, idéalement inférieur à 1% sur les processeurs modernes. Vous comprenez donc aisément que concevoir une unité de prédiction de branchement est un véritable défi d’ingénierie électronique qui requiert des prouesses technologiques sans précédent. Suivant la nature des informations utilisées, on peut distinguer plusieurs types de prédiction de branchement : locale, globale, dynamique, statique, etc. Nous allons d'abord voir les méthodes statiques avant de voir les méthodes dynamiques. La '''prédiction locale''' prédit chaque branchement indépendamment les uns des autres. Elle sépare bien les statistiques/moyennes pour chaque branchement. Et c'est autant un point fort qu'un point faible. En effet, elle n'exploite pas les corrélations entre branchements, à savoir le fait que l'exécution d'un branchement a tendance à déborder sur les suivants. Dans ce qui suit, nous allons voir les techniques de prédiction locales. ===Les ''branch hints''=== Dans son implémentation la plus explicite, la prédiction est inscrite dans le branchement lui-même. Quelques bits de l'opcode du branchement précisent si le branchement est majoritairement pris ou non pris. Ils permettent d'influencer les règles de prédiction de branchement et de passer outre les réglages par défaut. Ces bits sont appelés des '''suggestions de branchement''' (''branch hint''). Mais tous les processeurs ne gèrent pas cette fonctionnalité. Et de plus, cette solution marche assez mal. La raison est que le programmeur ou le compilateur doit déterminer si le branchement est souvent pris ou non-pris, mais qu'il n'a pas de moyen réellement fiable pour cela. Une solution possible est d’exécuter le programme sur un ensemble de données réalistes et d'analyser le comportement de chaque branchement, afin de déterminer les suggestions de branchement adéquates, mais c'est une solution lourde et peu pratique, qui a de bonnes chances de donner des résultats peu reproductibles d'une exécution à l'autre. ===La prédiction statique de branchement=== La '''prédiction statique''' prédit le résultat du branchement en fonction de caractéristiques fixes du branchement lui-même, comme son adresse, son mode d'adressage, l'adresse de destination, la position du branchement en mémoire, si le branchement est direct ou indirect, etc. Les informations en question sont connues une fois le branchement décodé, il n'y a pas besoin de mémoriser la moindre information supplémentaire. Une première implémentation, assez évidente, fait la différence entre les branchements inconditionnels toujours pris, et les branchements conditionnels au résultat variable. L'idée est de prédire les branchements inconditionnels comme toujours pris alors que les branchements conditionnels sont prédits comme non-pris (ce qui est une approximation). L'implémentation est relativement simple : on utilise un BTB qui ne mémorise que les branchements inconditionnels, et basta ! Les branchements conditionnels sont simplement ignorés, traités comme n'importe quelle autre instruction. Cette méthode est particulièrement inefficace pour les branchements de boucle, où la condition est toujours vraie, sauf en sortie de boucle ! Il a donc fallu affiner légèrement l'algorithme de prédiction statique. Une implémentation plus subtile fait la différence entre les branchements des boucles et les autres. Reste à identifier ceux-ci d'une manière ou d'une autre. Et un bon moyen est de regarder si le branchement revient en arrière ou au contraire saute une portion de code. Un programme s'exécute des adresses basses vers les adresses hautes, les instructions vont naturellement dans le sens croissant des adresses. Un branchement de boucle fait revenir en arrière, histoire de répéter un morceau de code, pas les autres branchements (du moins pas forcément). Et cela nous amène à faire la distinction entre les branchements conditionnels ascendants et descendants. Un '''branchement conditionnel ascendant''' est un branchement qui demande au processeur de reprendre plus loin dans la mémoire : l'adresse de destination est supérieure à l'adresse du branchement. Un branchement '''conditionnel descendant''' a une adresse de destination inférieure à l'adresse du branchement : le branchement demande au processeur de reprendre en arrière dans la mémoire, avant le branchement. Les branchements ascendants sont rarement pris (ils servent dans les conditions de type SI…ALORS), contrairement aux branchements descendants (qui servent à fabriquer des boucles). On peut ainsi modifier l’algorithme de prédiction statique comme suit : * les branchements inconditionnels sont toujours pris ; * les branchements descendants sont toujours pris ; * les branchements ascendants ne sont jamais pris. L'implémentation demande d'élargir le BTB. Le BTB n'a pas seulement à mémoriser l'adresse du branchement et son adresse de destination, mais aussi le type du branchement. En général, il se contente de deux bits, qui indiquent si le branchement est conditionnel, inconditionnel, ou un retour de fonction. Les retours de fonction sont traités à part, histoire de profiter du ''return stack predictor''. {|class="wikitable" |+ BTB avec prédiction de branchement statique évoluée |- ! Adresse du branchement !! Adresse de destination !! Type du branchement |- | 0x4165485 || 0x14657445541 | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Retour de fonction |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... || ... |} ===La prédiction avec des compteurs à saturation=== Les méthodes de prédiction de branchement statique sont intéressantes, mais elles montrent rapidement leurs limites. Pour plus de performances, il faut utiliser la '''prédiction dynamique''', qui extrait des statistiques sur l’exécution des branchements. Pour le reste du chapitre, toutes les techniques utilisent la prédiction dynamique, qu'elles soient locales ou globales. Sa version la plus simple se contente de mémoriser ce qui s'est passé lors de l’exécution précédente du branchement. Si le branchement a été pris, alors on suppose qu'il sera pris la prochaine fois. De même, s'il n'a pas été pris, on suppose qu'il ne le sera pas lors de sa prochaine exécution. Pour cela, chaque entrée du BTB contient un bit qui mémorise la dernière exécution du branchement : 1 s'il a été pris, 0 s'il n'a pas été pris. C'est ce qu'on appelle la '''prédiction de branchements sur 1 bit'''. Au vu de sa simplicité, il ne faut pas s'attendre à des miracles. La méthode fonctionne bien pour les branchements des boucles et les branchements inconditionnels, mais elle n'est pas assez performante. Une forme plus élaborée de prédiction utilise des '''compteurs à saturation'''. Un compteur à saturation est un compteur qui est incrémenté à chaque fois qu'un branchement est pris, décrémenté s'il est non-pris. Le compteur est initialisé à la moitié de sa valeur maximale, ce qui correspond à une probabilité proche de 50%. Les compteurs à saturation sont très utilisés et pas seulement pour de la prédiction locale, les méthodes globales en utilisent aussi. Pour faire la prédiction, l'unité de prédiction regarde le bit de poids fort du compteur. Le branchement est considéré comme pris si ce bit de poids fort est à 1, et non pris s'il vaut 0. En clair : on considère que le branchement est pris si le compteur indique une probabilité supérieure à 50%. [[File:Compteur à saturation.png|centre|vignette|upright=2|Compteur à saturation.]] : Pour la culture générale : le compteur à saturation du Pentium 1 était légèrement bogué, ce qui fait que sa prédiction de branchement était sous-optimale. La plupart des processeurs utilisent un compteur à saturation par branchement, ce qui veut dire un compteur à saturation par entrée dans le BTB. Vu chaque compteur est associé à une entrée du BTB, le phénomène d’''aliasing'' mentionné plus haut peut perturber les prédictions de branchement. Deux branchements peuvent se voir associés à la même entrée du BTB, et agir sur le même compteur à saturation, ce qui entraine l'apparition d''''interférences entre branchements'''. L'interférence peut avoir des effets positifs, neutres ou négatifs, suivant la corrélation entre les deux branchements. L’''aliasing'' n'est pas un problème si les deux branchements sont fortement corrélés, par exemple si les deux sont tout le temps pris ou tout le temps non-pris. Si les deux branchements vont dans le même sens, l'interférence est neutre, voire légèrement positive. Mais si l'un est souvent pris et l’autre souvent non-pris, l'interférence est négative. Les deux branchements vont se marcher dessus sans vergogne, chacun interférant sur les prédictions de l'autre ! Il est rare que l’''aliasing'' ait un impact significatif sur les unités de prédiction des deux paragraphes précédents. Il se manifeste surtout quand la BTB est très petite et la seule solution est d'augmenter la taille du BTB. ===La prédiction avec un historique local=== La prédiction avec des compteurs à saturation est très simple, mais il est possible de créer une implémentation alternative, très proche, avec des avantages et désavantages différents. Un compteur à saturation enregistre l'historique d'un branchement, à savoir ses dernières exécutions. L''''historique''' d'un branchement dit quel a été son résultat dans le passé, pour ses exécutions précédentes. Et il est possible d'enregistrer cet historique et l'utiliser pour faire des prédictions. Par exemple, prenons un branchement sont ses 6 dernières exécutions sont "pris, non-pris, pris, pris, pris, non-pris". Le processeur peut encoder cet historique sur 6 bits. Un branchement pris correspond à un bit à 1, un branchement non-pris correspond à un 0. Le bit de poids fort correspond à l'exécution la plus ancienne, le bit de poids faible à l'exécution la plus récente. Par exemple, si l'historique contient 010, cela veut dire que le branchement a été non pris, puis pris, puis non pris. L'historique d'un branchement est appelé son '''historique local''', mais nous parlerons simplement l'historique. Le processeur peut mémoriser l'historique de chaque branchement. Pour cela, il réserve n bits par branchements, pour mémoriser ses n exécutions précédentes. L'historique d'un branchement est mémorisé dans son entrée du BTB. Et plus précisément dans un champ séparé qui est alimentée comme un registre à décalage. Reste à exploiter cet historique local pour faire des prédictions. Une idée assez simple serait de l'utiliser pour calculer une moyenne des N exécutions précédentes. Si la probabilité d'être pris est supérieure à 50 %, le branchement est considéré comme pris. Dans le cas contraire, il est considéré comme non pris. Le calcul de la moyenne utilise une simple porte à majorité, qui prend en entrée tous les bits de l'historique. Cependant, cette méthode est équivalente à utiliser un compteur à saturation par branchement. La sortie de la porte à majorité est équivalente à ce qu'on aurait dans un compteur à saturation. La seule différence : le cout en circuit et la simplicité de conception. Prenons l'exemple des premiers processeurs PA RISC. Ils mémorisaient, pour chaque branchement, les trois dernières exécutions du branchement, avec trois bits par entrée du BTB. La prédiction était réalisée avec une porte à majorité, dont le résultat était utilisé comme prédiction. Ces processeurs ont préféré utiliser un historique de 3 bits, car il était plus simple à concevoir qu'un compteur à saturation équivalent de deux bits. Il suffisait d'alimenter un registre à décalage avec un bit, pas besoin d'incrémenter ou de décrémenter un compteur, pas besoin de signaux d'incrémentation/décrémentation séparés, etc. Mais pour des historiques plus longs, la donne s'inverse et des compteurs à saturation de deux-trois bits l’emportent. Un défaut de la méthode précédente, qui est partagée avec les compteurs à saturation, est qu'elle marche mal pour les branchements qui suivent un ''pattern''. Je veux dire par là que l'exécution d'un branchement suit un cycle, par exemple pris, pris, non-pris, pris, pris, non-pris, et ainsi de suite en boucle. Notons que l'apparition de tels motifs correspond le plus souvent à la présence de boucles dans le programme. Quand une boucle s’exécute, les branchements qui sont à l'intérieur tendent à exprimer de tels motifs. Avec de tels motifs, les compteurs à saturation feront des prédictions incorrectes, là où les prédicteurs qu'on va voir font des prédictions parfaites. Par exemple, prenons un branchement qui suit le ''pattern'' pris, pris, pris, non-pris. Si l'historique local vaut 1110, le branchement est censé refaire une boucle et être pris. Et certes, on a supposé que l'historique faisait 4 bits. Mais le''pattern'' sera visible tant que l'historique fait plus de 4 bits. Par exemple, le ''pattern'' serait 01110 pour un historique local de 5 bits, 101110 pour un historique local de 6 bits, 1101110 pour un historique local de 7 bits, etc. Un ''pattern'' correspond donc à une valeur bien précise de l'historique local. L'historique local permet de voir ces cycles, reste à les exploiter. Prédire correctement les ''pattern'' demande simplement d'associer un compteur à saturation à chaque valeur possible de l'historique local. Le compteur à saturation dira, quand tel pattern est rencontré, si le branchement sera pris ou non-pris par la suite. Au total, il faut donc un registre d'historique local de n bits couplé à 2^n compteurs à saturation. Et ce pour chaque branchement possible. Les compteurs à saturation sont regroupés dans une '''''Pattern History Table''''', que nous noterons PHT dans ce qui suit. Le résultat est une '''unité de prédiction à deux niveaux locale''', qui contient plusieurs registres d'historique et tout autant de PHT locales. En tout, il y a une PHT et un historique par branchement, donc par entrée du BTB. Quand un branchement est exécuté, son historique local indique quel compteur à saturation choisir dans la PHT locale. En général, l'historique local est mémorisé dans le BTB, reste à gérer la PHT. [[File:Unité de prédiction à deux niveaux purement locale.png|centre|vignette|upright=2.5|Unité de prédiction à deux niveaux avec des historiques et des PHT locales.]] Une unité de prédiction locale à deux niveaux contient plusieurs PHT, reste à savoir quelle PHT correspond à tel branchement. La correspondance se fait idéalement à partir de l'adresse du branchement, mais c'est là une implémentation idéale, qui demande beaucoup de circuits. Généralement, l'unité de prédiction n'utilise que les bits de poids faible de l'adresse du branchement, ce qui permet de faire un choix correct, mais avec une possibilité d'''aliasing''. Il est aussi possible de séparer l'historique local du BTB. Les historiques locaux sont alors mémorisés dans une mémoire RAM dédiée, appelé la '''''Branch History Table''''', ou encore la '''table des historiques locaux''', que nous noterons BHT dans ce qui suivra. Chaque byte de la BHT contient l'historique local associé à un branchement. Pour récupérer l'historique local voulu, l'adresse du branchement est envoyée à la BHT, sur les entrées d'adresse de la BHT, directement. Seuls les bits de poids faible de l'adresse sont utilisés. L'historique local est ensuite envoyé à la PHT adéquate, choisie en fonction des mêmes bits de poids faible du PC. Un bon moyen pour cela est d'accéder à toutes les PHT en parallèle, mais de sélectionner la bonne avec un multiplexeur, ce dernier étant commandé par les bits de poids faible du PC. [[File:Table des historiques locaux.jpg|centre|vignette|upright=2|Table des historiques locaux]] Un défaut des unités de prédiction purement locales de ce type est leur cout en termes de circuits. Utiliser un registre d'historique et une PHT par branchement a un cout en transistor élevé, avec tous les défauts que ca implique en termes de consommation électrique et thermique. Elles ont aussi des temps de calcul un peu plus important que les unités purement globales, mais pas de manière flagrante. Les gains en performance avec un historique local s'observent surtout pour les branchements qui sont dans des boucles ou pour les branchements utilisés pour concevoir des boucles, alors que l'implémentation globale marche bien pour les if..else (surtout quand ils sont imbriqués). Les deux sont donc complémentaires. Dans les faits, elles sont rarement utilisées seules du fait de leurs défauts, au profit d'unités de prédiction hybrides, qui mélangent historiques et PHT locaux et globaux. ==La prédiction de branchement globale/mixte== Utiliser un historique local avec une PHT est très puissant. Mais le cout en circuit est très important. Cependant, on peut adapter l'idée pour faire de la prédiction globale. La '''prédiction globale''' fait des prédictions holistiques, qui tiennent compte de tous les branchements, sans les traiter séparément. Elle ne sépare pas les branchements, tous sont mis dans le même panier. La prédiction globale fait donc le compromis inverse : elle exploite les corrélations entre branchements, mais perd les informations statistiques précise pour chaque branchement. L'idée est qu'au lieu d'utiliser un historique par branchement, on utilise un '''historique global''' qui mémorise les exécutions de tous les branchements conditionnels. L'historique enregistre les N exécutions de branchements conditionnels. L'historique global est mémorisé dans un registre unique, appelé le '''registre d'historique global'''. C'est un registre à décalage mis à jour à chaque fois que le processeur exécute un branchement : on fait rentrer un 1 si le branchement est pris, et un 0 sinon. Une unité de prédiction globale utilise cet historique pour faire sa prédiction. L'unité de prédiction globale utilise un compteur à saturation pour chaque valeur possible du registre d'historique. En clair, pour un registre de N bits, elle utilise 2^n compteurs à saturation. Là encore, les compteurs à saturation sont regroupés dans une ''Pattern History Table''. Nous parlerons de '''PHT globale''' pour désigner une PHT commandée par un historique global, de ''PHT locale'' si elle est adressée par un historique local. [[File:Prédiction dynamique à deux niveaux.png|centre|vignette|upright=2|Prédiction dynamique à deux niveaux.]] Il est admis que l'historique global donne de meilleures prédictions que l'historique local. Mais les deux sont complémentaires, ce qui fait qu'il existe des approches hybrides. Les approches hybrides exploitent à la fois les statistiques pour chaque branchement et les corrélations entre branchements. Les prédicteurs hybrides ont de meilleures performances que les prédicteurs purement globaux ou locaux. Avec un historique global, la prédiction tient compte d'éventuelles corrélations entre branchements consécutifs/proches pour faire les prédictions. C'est un avantage pour les applications qui enchaînent des if...else ou qui contiennent beaucoup de if...else imbriqués les uns dans les autres, qui s’exécutent plusieurs fois de suite. Le résultat de chaque if...else dépend généralement des précédents, ce qui rend l'utilisation d'un historique global intéressant. Par contre, ils sont assez mauvais pour le reste des branchements. Mais cette qualité devient un défaut quand elle détecte des corrélations fortuites ou parasites, inutiles pour la prédiction (corrélation n'est pas causalité, même pour prédire des branchements). Du fait de ce défaut, la prédiction globale a besoin de registres d'historiques globaux très larges, de plusieurs centaines de bits au mieux. [[File:2 level branch predictor.svg|centre|vignette|upright=2|Unités de prédiction de branchement à deux niveaux avec une seule PHT.]] Le circuit précédent a cependant un problème : si deux branchements différents s'exécutent et que l'historique est le même, le processeur n'y verra que du feu. Il y a alors une interférence, à savoir que les deux branchements vont agir sur le même compteur à saturation. On se retrouve alors dans une situation conceptuellement identique à l’''aliasing'' dans le BTB ou avec des compteurs à saturation. Sauf que les interférences sont alors beaucoup plus nombreuses et qu'elles sont clairement un problème pour les performances ! Mitiger les interférences devient alors bien plus important qu'avec les unités de prédiction précédentes. Heureusement, si augmenter la taille de la PHT peut mitiger le problème, d'autres solutions sont possibles sur les unités de prédiction globales. Elles sont au nombre de deux : mitiger les interférences liées aux branchements biaisés, ajouter des informations qui discriminent les branchements. Voyons ces solutions dans le détail. ===La mitigation des interférences par usage de l'adresse de branchement=== La première solution pour réduire l'impact de l’''aliasing'' est d'augmenter la taille de la PHT et de l'historique. Plus l'historique et la PHT sont grands, moins l’''aliasing'' est fréquent. Mais avoir des historiques et des PHT de très grande taille n'est pas une mince affaire et le cout en termes de performances et de portes logiques est souvent trop lourd. Une autre solution consiste à utiliser, en plus de l'historique, des informations sur le branchement lui-même pour choisir le compteur à saturation à utiliser. Typiquement, on peut utiliser l'adresse du branchement en plus de l'historique, pour choisir le compteur à saturation adéquat. Pour être précis, on n'utilise que rarement l'adresse complète du branchement, mais seulement les bits de poids faible. La raison est que cela fait moins de bits à utiliser, donc moins de circuits, mais l’''aliasing'' est juste atténué, pas éliminé. Des branchements différents ont beau avoir des adresses différentes, les bits de poids faible de leurs adresses peuvent être identiques. Des confusions sont donc possibles, mais sont assez rares pour que cela suffise en pratique. Une unité de prédiction de ce type est illustrée ci-dessous. Il y a plusieurs PHT séparées, donc plusieurs ensembles de 2^n compteurs à saturation. Le choix de la PHT est réalisé par l'adresse du branchement, alors que le choix du compteur à saturation est réalisé par l'historique. Les compteurs à saturation d'un branchement sont mis à jour seulement quand le branchement s'est chargé pendant que l'historique associé était dans le registre d'historique. Cette méthode a cependant le défaut de gâcher énormément de transistors. [[File:Unité de prédiction de branchement basée sur des compteurs à saturation sélectionnés par l'historique global.jpg|centre|vignette|upright=2|Unité de prédiction de branchement basée sur des compteurs à saturation sélectionnés par l'historique global]] D'autres unités de prédiction fonctionnent sur le même principe que l'unité précédente, sauf que les différentes PHTs sont fusionnées en une seule grosse PHT. C'est ce que font les unités de prédiction '''''gshare''''' et '''''gselect''''', ainsi que leurs dérivées. Avec les unités de prédiction '''''gselect''''', on concatène les bits de poids faible de l'adresse du branchement et l'historique pour obtenir le numéro du compteur à saturation à utiliser. [[File:Prédiction « gselect ».png|centre|vignette|upright=2|Prédiction « gselect ».]] Avec les unités de prédiction '''''gshare''''', on fait un XOR entre le registre d'historique et les bits de poids faible de l'adresse du branchement. Le résultat de ce XOR donne le numéro du compteur à utiliser. Avec cette technique, on utilise une PHT globale et un historique global, mais le registre d'historique est manipulé de manière à limiter l'''aliasing''. [[File:Prédiction « gshare ».png|centre|vignette|upright=2|Prédiction « gshare ».]] Intuitivement, on pourrait croire que les unités de prédiction gselect ont de meilleures performances. C'est vrai que les unités gshare combinent l'adresse du branchement et l'historique d'une manière qui fait perdre un peu d'information (impossible de retrouver l'adresse du branchement et l'historique à partir du résultat du XOR), alors que les unités gselect conservent ces informations. Mais il faut prendre en compte le fait que les deux unités doivent se comparer à PHT identique, de même taille. Prenons par exemple une PHT de 256 compteurs, adressée par 8 bits. Avec une unité gselect, les 8 bits sont utilisés à la fois pour l'historique et pour les bits de poids faible de l'adresse du branchement. Alors qu'avec une unité gshare, on peut utiliser un historique de 8 bits et les 8 bits de poids faible de l'adresse du branchement. L'augmentation de la taille de l'historique et du nombre de bits utilisés fait que l’''aliasing'' est réduit, et cela compense le fait que l'on fait un XOR au lieu d'une concaténation. Les unités précédentes réduisent l'''aliasing'' en segmentant la PHT : telles entrées sont allouées à un branchement, telles entrées à tel autre branchement, etc. Cependant, l'historique local n'est pas utilisé. Mais quitte à allouer des entrées à un branchement, autant en profiter pour leur associer son historique local. Il s'agit alors d'approches hybrides. Par exemple, on peut citer la '''prédiction mixte''' (''alloyed predictor''). Avec celle-ci, la table des compteurs à saturation est adressée avec la concaténation : de bits de l'adresse du branchement, du registre d'historique global et du registre d'historique local adressé par l'adresse du branchement. [[File:Prédiction mixte.jpg|centre|vignette|upright=2|Prédiction mixte.]] Une version optimisée de ce circuit permet d’accéder à la PHT et à la BHT en parallèle. Avec elle les bits de poids faible de l'adresse du branchement sont concaténés avec l'historique global. Le résultat est ensuite envoyé à plusieurs PHT locales distinctes, en parallèle. Les différences PHT envoient leurs résultats à un multiplexeur, commandé par l'historique local, qui sélectionne le résultat adéquat. L'inconvénient de cette mise en œuvre est que le circuit est assez gros, notamment en raison de la présence de plusieurs PHT séparées. [[File:Alloyed predictor optimisé.jpg|centre|vignette|upright=2|''Alloyed predictor'' optimisé]] ===La mitigation des interférences par la structuration en caches des PHTs=== Les techniques précédentes utilisent les bits de poids faible de l'adresse pour sélectionner la PHT adéquate, ou pour sélectionner le compteur dans la PHT. Mais le reste de l'adresse n'est pas utilisé. Cependant, il est théoriquement possible de conserver les bits de poids fort, afin d'identifier le branchement associé à un compteur. Pour cela, chaque compteur à saturation se voit associé à un ''tag'', qui mémorise le reste de l'adresse du branchement associé au compteur, comme pour les mémoires caches. Lorsque l'unité de prédiction démarre une prédiction, elle récupère les bits de poids fort de l'adresse du branchement et compare ceux-ci avec le ''tag''. S’il y a un ''match'', c'est signe que le compteur est bien associé au branchement à prédire. Dans le cas contraire, c'est signe qu'on fait face à une situation d’''aliasing'' et le compteur n'est pas utilisé pour la prédiction. Le résultat transforme la PHT en une sorte de cache assez particulier, qui associe un compteur à saturation à chaque couple adresse-historique. La technique du paragraphe précédent peut être encore améliorée afin de réduire l’''aliasing''. L’''aliasing'' sur une unité de prédiction de branchement est similaire aux conflits d'accès au cache, où deux données/adresses atterrissent dans la même ligne de cache. Une PHT peut formellement être vue comme un cache directement adressé. Une solution pour limiter ces conflits d'accès à de tels, est de les transformer en un cache associatif à n voies. On peut faire la même chose avec les unités de prédiction de branchement, ce qui revient à dupliquer les PHT, de manière à ce que les bits de poids faible de l'adresse se voient attribuer à plusieurs compteurs à saturation, un par branchement possible. Ce faisant, on réduit les interférences entre branchements. Mais cette technique augmente la quantité de circuits utilisés et la consommation d'énergie, ainsi que le temps d'accès, sans compter qu'elle requiert d'utilisation de ''tags'' pour fonctionner à la perfection. Pour les unités de prédiction de branchement, les mesures à ce sujet ne semblent pas montrer un impact réellement important sur les performances, comparé à une organisation de type directement adressé. Il est possible de pousser la logique encore plus loin en ajoutant des caches de victime et d'autres mécanismes courant sur les caches usuels. ===La mitigation des interférences liées aux branchements biaisés=== L’''aliasing'' a un impact négatif quand deux branchements aliasés (qui sont attribués au même compteur à saturation) vont dans des sens différents : si l'un est non-pris, l'autre a de bonnes chances d'être pris (ou inversement). Or, la plupart des branchements sont biaisés, à savoir très fortement orientés dans une direction qu'une autre (presque toujours pris ou presque toujours non-pris, à plus de 90/99% de chances). Plus un branchement a une probabilité élevée d' être pris ou non-pris, plus sa capacité à interférer avec les autres est forte. Le pire des cas est quand deux branchements sont biaisés mais dans des directions inverses : l'interférence est maximale. En conséquence, les branchements biaisés ou quasi biaisés sont les plus problématiques pour l’''aliasing''. Or, il est possible de concevoir des prédicteurs de branchements qui atténuent fortement les interférences des branchements biaisés ou quasi biaisés. C’est le cas de l’''agree predictor'' et de l'unité de prédiction bimodale, que nous allons voir dans ce qui suit. Les branchements très souvent pris vont interférer très fortement avec les branchements très souvent non-pris, et inversement. Par contre, les branchements très souvent pris n'interféreront pas entre eux, de même que les branchements non-pris iront bien ensemble. D'où l'idée de séparer ces deux types de branchements dans des PHT séparées, pour éviter le mauvais ''aliasing''. L''''unité de prédiction bimodale''' part d'une unité gshare, et sépare la PHT globale en deux : une PHT dédiée aux branchements qui sont très souvent pris et une autre pour les branchements très peu pris. Les deux PHT fournissent chacune une prédiction. La bonne prédiction est choisie par un multiplexeur commandé par une unité de sélection, qui se charge de déduire quelle unité a raison. Cette unité de sélection est une unité de prédiction basée sur des compteurs à saturation de 2 bits. On peut encore améliorer l'unité de sélection de prédiction en n'utilisant non pas des compteurs à saturation, mais une unité de prédiction dynamique à deux niveaux, ou toute autre unité vue auparavant. [[File:Prédiction bimodale.jpg|centre|vignette|upright=2|Prédiction bimodale.]] La '''prédiction par consensus''' (''agree predictor'') combine une unité de prédiction à historique global (idéalement de type gshare ou gselect, mais une unité normale marche aussi) et des compteurs à saturation. Il s'agit techniquement d'une technique de prédiction mixte, à la fois globale et locale. Le BTB intègre un compteur à saturation de un bit, qui indique la direction privilégiée du branchement. L'unité à historique global a un fonctionnement changé : elle calcule non pas la probabilité que le branchement soit pris ou non-pris, mais la probabilité que le résultat du branchement soit compatible avec le résultat de l'unité de prédiction de 1 bit. La prédiction finale vérifie que ces deux circuits sont d'accord, en faisant un NXOR entre les résultats des deux unités de prédiction de branchement. [[File:Prédiction par consensus.png|centre|vignette|upright=2|Prédiction par consensus.]] Des calculs simples de probabilités montrent que l'''agree predictor'' a des résultats assez importants. Ses résultats sont d'autant meilleurs que les branchements sont biaisés et penchent fortement vers le côté pris ou non-pris. ==La prédiction de branchement avec des perceptrons== Les méthodes précédentes utilisent au moins un compteur à saturation par historique possible. Sachant qu'il y a 2^n valeurs possibles pour un historique de n bits, le nombre de compteurs à saturation augmente exponentiellement avec la taille de l'historique. Ce qui limite grandement la taille de l'historique, alors qu'on aurait au contraire besoin d'un historique assez large pour faire des prédictions excellentes. Pour contourner le problème, des alternatives basées sur des techniques d'apprentissage artificiel (''machine learning'') existent. Un exemple est celui des unités de prédiction de branchement dites de ''neural branch prediction'', basée sur des perceptrons. Dans ce qui suit, nous les appelerons des '''unités perceptron'''. Les perceptrons sont un algorithme d'apprentissage automatique très simple, ce qui fait que les explications qui vont suivre devraient être simples. En théorie, il est possible d'utiliser des techniques d'apprentissage automatique plus élaborées, mais cela n'est pas possible en pratique. Rappelons qu'une unité de prédiction de branchement doit idéalement fournir sa prédiction en un cycle d'horloge. L'implémentation d'un perceptron est déjà problématique de ce point de vue, car elle demande d'utiliser beaucoup de circuits arithmétiques complexes (multiplication et addition). Autant dire que les autres techniques de ''machine learning'', encore plus gourmandes en calculs, ne sont pas praticables. ===Les perceptrons et leur usage en tant qu'unité de prédiction de branchements=== L'idée derrière les unités perceptron est que certains bits de l'historique sont plus importants que d'autres. En soi, ce genre de situation est réaliste. Il arrive que certains branchements soient pris uniquement si les deux branchements précédents ne le sont pas, ou que si l'avant-dernier branchement est lui aussi pris. Mais les autres résultats de branchement présents dans l'historique ne sont pas utiles. En conséquence, deux historiques semblables, qui ne sont différents que d'un ou deux bits, peuvent donner des prédictions presque identiques. Dans ce cas, on peut faire la prédiction en n'utilisant que les bits identiques entre ces historiques, ou du moins en leur donnant plus d'importance qu'aux autres. On exploite alors des corrélations avec certains bits de l'historique, plutôt qu'avec l'historique entier lui-même. Pour coder ces corrélations, on associe un coefficient à chaque bit de l'historique, qui indique à quel point ce bit est important pour déterminer le résultat final. Ce coefficient est proportionnel à la corrélation entre le bit de l'historique associé, et le résultat du branchement à prédire. Les coefficients sont des nombres entiers qui peuvent être positifs, nuls ou négatifs. Un coefficient positif signifie que si le branchement associé a été pris, alors le branchement à prédire a de bonnes chances de l'être aussi (et inversement). Un coefficient nul signifie que les deux branchements sont indépendants et que le bit de l'historique associé n'est pas à prendre en compte. Pour un coefficient négatif, cela signifie que si le branchement associé a été pris, alors le branchement à prédire a de bonnes chances d'être non-pris (et inversement). Les coefficients en question sont généralement compris entre -1 et 1. Une fois qu'on connait ces coefficients de corrélations, on peut calculer la probabilité que le branchement à prédire soit pris, avec des calculs arithmétiques simples. Dans les calculs qui vont suivre, un branchement pris est codé par un 1, alors qu'un branchement non-pris est codé par un -1 ! Ce détail permet de simplifier grandement les calculs. Dans sa version la plus simple, le perceptron calcule un nombre Z qui est positif ou nul si le branchement est pris, négatif s'il ne l'est pas. Tous les coefficients <math>p_i</math> sont alors des nombres relatifs, pouvant être positifs, nuls ou négatifs. Ils sont généralement compris entre -1 et 1. La formule devient donc celle-ci : : <math>Z = w_0 + \sum_i (h_i \times w_i)</math> [[File:ArtificialNeuronModel francais.png|centre|vignette|upright=2|Perceptron.]] : Sur le plan mathématique, le perceptron effectue un produit scalaire entre deux vecteurs : l'historique et le vecteur des poids. Choisir les coefficients adéquats est le but de l'algorithme du perceptron. L'unité de prédiction part de poids par défaut, qui sont mis à jour à chaque bonne ou mauvaise prédiction. Toute la magie de cet algorithme tient dans la manière dont sont mis à jour les poids. L'idée est de prendre le vecteur des poids, puis d'ajouter ou soustraire l'historique suivant le résultat du branchement. Les poids évoluent ainsi à chaque branchement, améliorant leurs prédictions d'une exécution à l'autre. La règle de mise à jour des poids est donc la suivante : : <math>w_i \leftarrow w_i + t \times h_i</math>, avec t = 1 pour un branchement pris, -1 pour un branchement non-pris. ===L'implémentation matérielle des perceptrons et ses optimisations=== Les calculs réalisés par un perceptrons sont simples, juste des multiplications et des additions et cela se sent dans la conception du circuit. L'unité perceptron utilise un perceptron par entrée du BTB, un par branchement pour simplifier. Pour cela, on utilise une mémoire SRAM qui mémorise les poids de chaque perceptron. La SRAM est adressée par les bits de poids faible du ''program counter'', de l'adresse du branchement. Une fois les poids récupérés, ils sont envoyés à un circuit de calcul de la prédiction, composé de circuits multiplieurs suivis par un additionneur multi-opérande. Le circuit de mise à jour des poids récupère la prédiction, les poids du perceptron, mais aussi le résultat du branchement. La mise à jour étant une simple addition, le circuit est composé d'additionneurs/soustracteurs, couplés à des registres pour mémoriser la prédiction et les poids du perceptron en attendant que le résultat du branchement soit disponible. Vu qu'il y a un délai de quelques cycles avant que le résultat du branchement soit disponible et que les prédictions doivent continuer pendant ce temps, les poids du perceptron et la prédiction sont stockés dans des FIFOs lues/écrites à chaque cycle. [[File:Unité de prédiction de branchement basée sur des perceptrons.png|centre|vignette|upright=2|Unité de prédiction de branchement basée sur des perceptrons]] Rien de bien compliqué sur le principe, mais rappelons que le perceptron doit fournir un résultat en moins d'un cycle d'horloge, et cela tient compte de l'accès au BTB. C'est un temps très court, surtout pour un circuit qui implique des multiplications. Les multiplications prennent facilement deux à trois cycles d'horloge. Autant dire que si on ajoute le temps d'accès à une petite SRAM, pour récupérer le perceptron, c'est presque impossible. Mais quelques optimisations simples permettent de rendre le calcul plus rapide. Premièrement, la taille de la SRAM est réduite grâce à quelques économies assez simples. Notamment, le perceptron utilise des poids codés sur quelques bits, généralement un octet, parfois 7 ou 5 bits, guère plus. Les poids sont encodés en complément à 1 ou en complément à 2, là où les perceptrons normaux utilisent des nombres flottants. Rien que ces deux choix simplifient les circuits et les rendent plus rapides. Le désavantage d'utiliser peu de bits par poids est une perte mineure en termes de taux de prédiction, qui est plus que compensée par l'économie en portes logiques, qui permet d'augmenter la taille de la SRAM. D'autres optimisations portent sur le circuit de calcul. Par exemple, la multiplication <math>h_i \times w_i</math> est facultative. Vu que l'historique contient les valeurs 1 et -1, il suffit d'additionner le poids associé si la valeur est 1 et de le soustraire s'il vaut -1. On peut même simplifier la soustraction et se limiter à une complémentation à un (une inversion des bits du poids). En faisant cela, les circuits multiplieurs disparaissent et le circuit de prédiction se résume à un simple additionneur multi-opérande couplé à quelques inverseurs commandables. Certains perceptrons prennent en compte à la fois l'historique global et l'historique local pour faire leur prédiction. Les deux historiques sont simplement concaténés et envoyés en entrée du perceptron. Cela marche assez bien, car les perceptrons peuvent utiliser des historiques de grande taille sans problèmes. Par contre, il y a besoin d'ajouter une ''branch history table'' au circuit, ce qui demande des portes logiques en plus, mais aussi un temps de calcul supplémentaire (l'accès à cette table n'est pas gratuit). ===Les avantages et inconvénients des perceptrons pour la prédiction de branchements=== L'avantage des unités perceptron est qu'elles utilisent un nombre de portes logiques qui est proportionnel à la taille de l'historique, et non pas exponentiel. Cela permet d'utiliser un historique de grande taille, et donc d'obtenir un taux de bonnes prédictions très élevé. Leur désavantage est que leurs performances de prédiction sont moins bonnes à historique équivalent. Les unités perceptrons ont des taux de prédiction élevés, mais seulement quand certaines conditions mathématiques sont respectées par les historiques. La condition en question est que les historiques correspondants à un branchement pris et les historiques où ce même branchement est non-pris doivent être linéairement séparables. Heureusement, les branchements des programmes informatiques sont une majorité à respecter la condition de séparation linéaire, ce qui rend les perceptrons parfaitement adaptés à la prédiction de branchements. La notion de linéairement séparable est un peu difficile à comprendre. Pour l'expliquer, imaginez que les N bits de l'historique forme un espace à N-dimensions discrets. Chaque historique est un point dans cet espace N-dimensionnel et chaque bit sert de coordonnée pour se repérer dans cet espace. Une fonction est linéairement séparable si l'espace N-dimensionnel peut être coupé en deux par un hyperplan : d'un côté de ce plan se trouvent les historiques des branchements pris, de l'autre se trouvent les historiques des branchements non-pris. Cet hyperplan est défini par l'ensemble des points qui respecte l'équation suivante : : <math>w_0 + \sum_i (h_i \times w_i) = 0</math> Un exemple où cette condition est respectée est quand le résultat d'une prédiction se calcule avec un ET entre plusieurs branchements de l'historique. Si un branchement est pris quand plusieurs branchements de l’historique sont tous pris ou tous non-pris, la condition est respectée et le perceptron donnera des prédictions correctes. Un exemple où cette condition n'est pas respectée est un banal XOR. Prenons l'exemple d'un historique global de 2 bits. Imaginons que le branchement à prédire soit pris : soit quand le premier branchement de l'historique est pris et le second non-pris, soit quand le premier est non-pris et le second pris. Concrètement, la prédiction exacte se calcule en faisant un XOR entre les deux bits de l'historique. Dans ce cas, la condition "linéairement séparable" n'est pas respectée et le perceptron n'aura pas des performances optimales. Les unités perceptron utilisent idéalement un perceptron par branchement. Cela demande d'associer, l'adresse du branchement pour chaque perceptron, généralement en donnant un perceptron par entrée du BTB. Mais une telle organisation n'est pas possible en pratique, car elle demanderait d'ajouter un ''tag'' à chaque perceptron/entrée du BTB, ce qui demanderait beaucoup de circuits et de ressources matérielles. Dans les faits l'association entre un branchement et un perceptron se fait en utilisant les bits de poids faible de l'adresse de branchement. Mais faire cela entrainera l'apparition de phénomènes d'''aliasing'', comme pour les unités de prédiction à deux niveaux. Les techniques vues précédemment peuvent en théorie être adaptées sur les unités à perceptrons, dans une certaine mesure. Notons que les perceptrons sont des algorithmes ou des circuits qui permettent de classer des données d'entrée en deux types. Ici, la donnée d'entrée est l'historique global et la sortie du perceptron indique si le branchement prédit est pris ou non-pris. Ils ne sont donc pas adaptés à d'autres tâches de prédiction, comme pour le préchargement des lignes de cache ou la prédiction de l'adresse de destination d'un branchement, ou toute autre tâche d'exécution spéculative un tant soit peu complexe. Leur utilisation reste cantonnée à la prédiction de branchement proprement dit, guère plus. Par contre, les autres techniques vues plus haut peuvent être utilisées pour le préchargement des lignes de cache, ou d'autres mécanismes de prédiction plus complexes, que nous aborderons dans la suite du cours. ==La prédiction avec plusieurs unités de branchement distinctes== Il est possible de combiner plusieurs unités de prédiction de branchement différentes et de combiner leurs résultats en une seule prédiction. On peut par exemple combiner une unité de prédiction spécialisée dans les boucles, une unité de prédiction 2 bits basée sur des compteurs à saturation, une unité de prédiction statique et une unité ''gshare''. Tout le problème est de décider quelle unité a fait la bonne prédiction, comment combiner les résultats des différentes unités de prédiction. Soit on choisit un résultat qui parait plus fiable que les autres, soit on combine les résultats pour faire une sorte de moyenne des prédictions. La méthode la plus simple de choisir le résultat d'une unité de prédiction parmi toutes les autres. Pour implémenter le tout, il suffit d'un multiplexeur qui effectue le choix de la prédiction. Reste à commander le multiplexeur, ce qui demande un '''circuit méta-prédicteur''' qui sélectionne la bonne unité de prédiction. Il arrive que le circuit méta-prédicteur soit conçu avec les mêmes techniques que les unités de prédiction de branchement elle-mêmes. Dans le cas le plus simple, il s'agit d'un simple compteur à saturation, mis à jour suivant la concordance des prédictions avec le résultat effectif du branchement. Mais c'est loin d'être systématique. Une autre solution combine le résultat des différentes unités de prédiction avec une fonction mathématique adéquate. Avec un nombre impair d'unités de prédiction, une méthode assez efficace est de simplement prendre le résultat majoritaire. Si une majorité d'unités pense que le branchement est pris, alors on le considère comme pris, et comme non pris dans le cas contraire. Par contre, avec un nombre pair d'unités, cette méthode simple ne marche pas, il y a un risque d'égalité. L'unité de prédiction de branchement « '''''e-gskew''''' » utilise trois unités gshare et combine leurs résultats avec un vote à majorité. Les trois unités gshare utilisent des XOR légèrement différents, dans le sens où les bits de l'adresse du branchement choisi ne sont pas les mêmes dans les trois unités, sans compter que le résultat du XOR peut subir une modification qui dépend de l'unité de prédiction choisie. Autrement dit, la fonction de hachage qui associe une entrée à un branchement dépend de l'unité. [[File:Unité de prédiction « e-gskew ».png|centre|vignette|upright=2|Unité de prédiction « e-gskew ».]] Enfin, une dernière solution est que certaines unités de prédiction ont la priorité sur toutes les autres. Par exemple, prenons un processeur avec un ''return stack predictor'', une unité qui détecte les boucles et une unité de prédiction avec un historique. Si le ''return stack predictor'' détecte une instruction de retour de fonction, sa prédiction passera avant toutes les autres unités de prédiction. Et dans ce qui va suivre, c'est cette solution qui sera utilisé pour les unités de prédiction qu'on va étudier. ===La prédiction des branchements des boucles=== Les boucles sont à part, pour ce qui est de la prédiction de branchement. Rappelons qu'une boucle FOR/WHILE répète une série d'instruction N fois, le nombre N étant appelé le '''compteur de boucle'''. Ce qui fait que les branchements d'une boucle FOR sont pris n − 1 fois, la dernière exécution étant non prise. La conséquence est que les unités de prédiction usuelles sont assez mauvaises pour prédire les branchements des boucles. Par contre, la prédiction statique marche particulièrement bien comparé au reste. La prédiction statique prédit que les branchements des boucles sont toujours pris, ce qui donne des prédictions qui sont d'autant meilleures que la boucle est répétée un grand nombre de fois. Reste à gérer les branchements des boucles au mieux, en sachant cela. L'idée est d'utiliser plusieurs unités de prédiction séparées : une spécialisée dans les boucles et d'autres unités de prédiction. Uen unité de méta-prédiction se charge d'activer l'unité de prédiction des boucles dès qu'une boucle est détéctée. Les unités de prédiction modernes essayent de détecter les branchements des boucles afin de les mettre à part. En soi, ce n'est pas compliqué les branchements des boucles sont presque toujours des branchements descendants et réciproquement. De plus, ces branchements sont pris à chaque répétition de la boucle, et ne sont non-pris que quand on quitte la boucle. Le compteur de boucle analyse l'exécution des branchements, pour détecter les branchements des boucles. Un branchement est considéré comme un branchement de boucle si : il est pris N fois, avant de ne pas être pris une fois. Au moment où le branchement n'est plus pris, le ''Loop Predictor'' lui attribue une entrée dédié. L'entrée mémorise mémorise le nombre de fois que ce branchement a été pris, à savoir N, et est associée à un compteur. Le compteur est incrémenté/décrémenté à chaque exécution du branchement, et sa valeur est comparée à la valeur N. Quand le compteur atteint N+1, il est réinitialisé. Il s'agit d'une implémentation simple, mais est visiblement proche de celle utilisée sur le Pentium M, si j'en crois l'article "The Intel Pentium M Processor : Microarchitecture and Performance". Il faut noter que la boucle doit s'exécuter au moins une fois pour être détectée, et seules les exécutions suivantes sont prédites correctement. La boucle doit donc s'exécuter plusieurs fois, ce qui implique qu'elle est elle-même dans une boucle plus grande qu'elle. Une telle unité est donc capable de prédire spécifiquement les branchements de boucles FOR imbriquées, à savoir où une boucle FOR est dans une autre boucle FOR. Concrètement, cela permet de répéter la première boucle FOR plusieurs fois de suite, souvent un grand nombre de fois. Avec les boucles imbriquées, le compteur de boucle peut changer d'une répétition à l'autre, mais ce n'est que rarement le cas. Dans de nombreux cas, le compteur de boucle reste le même d'une répétition à l'autre. Ce qui fait qu'une fois la boucle exécutée une première fois, on peut prédire à la perfection les exécutions suivantes. Pour cela, il suffit de déterminer le compteur de boucle après la première exécution de la boucle, puis de le mémoriser dans un compteur. Lors des exécutions suivantes de la boucle, on compte le nombre de fois que le branchement de la boucle s'exécute : il est prédit comme pris tant que le compteur est différent du compteur de boucle, mais non-pris en cas d'égalité. ===Le prédicteur L-TAGE=== Il est possible d'utiliser plusieurs unités de prédiction, chacune utilisant des historiques de taille différente. Pour donner un exemple simple, on pourrait avoir une unité avec un historique de 2 bits, une autre avec un historique de 3 bits, une autre de 4 bits, etc. Une méta-unité de branchement prédit alors quelle taille d'historique donne les meilleurs résultats. Il est fréquent que les tailles d'historiques utilisées suivent une formule mathématique précise, appelée suite géométrique, ce qui est un mot bien barbare pour dire que l'on passe d'un historique au suivant en multipliant sa taille par une constante bien précise. Par exemple, on pourrait avoir des historiques de taille 2, 4, 8, 16, 32. C'est d'ailleurs le principe derrière les '''unités de branchement TAGE (''TAgged GEometric length predictor'')'''. Elles utilisent plusieurs registres d'historiques, chacun avec sa propre PHT, dont la taille suit une suite géométrique. Avec cette méthode, les branchements qui ont un motif répétitif sont alors parfaitement détectés, peu importe sa taille. Par exemple, un branchement avec un historique dont le cycle est pris, non-pris, non-pris, sera parfaitement prédit avec un historique de 3 bits, 6 bits, ou de 3*n bits, mais pas forcément avec un historique de 4 ou 8 bits. De ce fait, utiliser plusieurs unités de branchement avec plusieurs tailles d'historique fonctionne plutôt bien. Le prédicteur TAGE a été raffiné avec le temps, sur plusieurs points, que je ne vais pas détailler dans ce qui suit. Précisons cependant que les PHT sont structurées en caches, afin d'éliminer les phénomènes d'''aliasing''. L'ajout d'une unité de prédiction pour les boucles, a donné le '''prédicteur L-TAGE''', qui a lui-même été amélioré par la suite. Les unités de branchement TAGE sont les plus utilisées au moment où j'écris ces lignes (2026), et la majorité des processeurs x86 commerciaux utilisent une variante de ce prédicteur, à quelques exceptions près. Le '''prédicteur ITTAGE''' peut être adapté pour prédire des branchements indirects. La seule différence est que chaque table d'historique est associée non seulement à une prédiction pris/non-pris, mais aussi à une adresse de destination. L'idée derrière l'ITTAGE est que l'adresse de destination dépend de l'historique. L'adresse de destination dépend de ce qui se passe avant qu'on exécute le branchement indirect, donc de l'historique global. Il est donc fréquent que des historiques différents donne un résultat différent, que ce soit pour la prédiction pris/non-pris, mais aussi pour l'adresse de destination. L'ITTAGE associe donc une adresse de destination par ''pattern'' prédit. ===La prédiction de branchement à deux niveaux=== Les processeurs modernes utilisent une prédiction de branchement à deux niveaux. Une première unité de branchement fournit des résultats rapides et peu précis, la seconde a des résultats plus précis mais fournit ses résultats après quelques cycles d'horloge. La première unité envoie les adresses prédites au cache d'instruction, pour charger les instructions prédites et met à jour le ''program counter'' en conséquence. La seconde envoie les adresses prédites à l'étage de décodage d'instruction, histoire d'annuler des branchements considérés à tort comme pris/non-pris. Il y a donc une unité de prédiction rapide, dont le résultat est corrigé par une seconde unité. La seconde unité a le dernier mot, elle corrige ou annule les prédictions de la première. Ce qui lui vaut le nom, en anglais, d'''overiding predictor''. La correction se fait simplement en corrigeant le ''program counter'' et en forçant le décodeur à émettre des NOPs pendant quelques cycles, le temps d'éliminer les instructions chargées à tort. [[File:Double BTB sur les CPU modernes.png|centre|vignette|upright=2|Double BTB sur les CPU modernes.]] Les deux unités de prédiction n'utilisent pas le même algorithme. Par exemple, la microarchitecture Silvermont d'Intel utilisait deux unités de prédiction : une unité rapide basée sur des compteurs à saturation, et un prédicteur ''gshare'' lent. Les deux unités de prédiction utilisent des BTB séparés, de taille différente, au moins deux. Vu que les BTB sont des caches, on les appelle des '''BTB de niveau L1 et L2'''. Par exemple, les processeurs Bulldozer d'AMD disposent de deux BTBs : un BTB L1 de 512 entrées, et un BTB L2 de 5120 entrées. Le BTB de niveau L2 est souvent un cache de victime, qui mémorise les branchements retirés de la BTB de niveau L1. La BTB pour les branchements indirects vient en plus de ces deux BTBs, elle complète souvent la BTB de niveau L2. Le ''return stack predictor'' vient en plus des BTB L1 et L2, mais sa petite taille et sa rapidité font qu'il complète l'unité/BTB de niveau L1. Quelques processeurs ont trois niveaux de BTB, avec un niveau L0, L1 et L2. La BTB L0 aliment le cache d'instruction, alors que la L1 et la L2 préviennent les décodeurs d'instruction. La différence entre la L1 et la L2 est que la L2 met plus de temps à prévenir les décodeurs. Les branchements peuvent donc être annulés/forcés à deux étages de décodage différents. Pour donner quelques exemples, les processeurs Zen d'AMD disposent de plusieurs BTBs, avec des niveaux L0, L1 et L2. Le tableau ci-dessous vous donne la taille et la latence de chaque niveau de BTB. La BTB de niveau L0 alimente le cache d'instruction, la L1 permet d'annuler les instructions au premier cycle de décodage, la BTB L2 annule les instructions vers le milieu/fin du décodage. {|class="wikitable" |- ! rowspan="3" | Zen 2 | 16 || 0 |- | 512 || 1 |- | 7168 || 4 |- ! rowspan="3" | Zen 3 | 16 || 0 |- | 1024 || 1 |- | 6656 + 1536 (branchements indirects)|| 4 |} ==Les optimisations de la prédiction de branchements== La prédiction de branchement est complexe et toute économie est bonne à prendre. Pour améliorer les performances, ou simplement améliorer l'utilisation du BTB et des PHT, diverses techniques ont été inventées. Ces techniques marchent quel que soit l'unité de prédiction prise en compte. Elles peuvent s'appliquer aussi bien aux unités basées sur des perceptrons, que sur des unités à deux niveaux ou des unités de prédiction dynamique en général. Ces techniques sont assez variés : certaines profitent du fait que de nombreux branchements sont biaisés, d'autres tentent de réduire les interférences entre branchements sans utiliser l'historique global/local, etc. ===Le filtrage de branchements=== Une première optimisation permet d'économiser l'usage des différentes ressources matérielles, comme la BTB ou les PHT, en les réservant aux branchements non-biaisés. Idéalement, les branchements biaisés peuvent être prédits en utilisant des techniques très simples. D'où l'idée d'utiliser deux unités de prédiction spécialisées : une pour les branchements biaisés et une autre plus complexe. L'unité de prédiction pour les branchements biaisés est une unité de prédiction à 1 bit qui indique si le branchement est pris ou non, ce qui suffit largement pour de tels branchements. Cette technique s'appelle le '''filtrage de branchement''' et son nom est assez parlant. On filtre les branchements biaisés pour éviter qu'ils utilisent des PHT et d'autres ressources qui gagneraient à être utilisées pour des branchements plus difficiles à prédire. Appliquer cette idée demande de reconnaître les branchements fortement biaisés d'une manière ou d'une autre, ce qui est plus facile à dire qu'à faire. Une solution similaire enregistre quels branchements sont biaisés dans le BTB, et utilise cette information pour faire des prédictions. ===L'usage de fonctions de hashage pour indexer les diverses SRAM/tables=== Plus haut, nous avons vu que les unités de prédiction de branchement contiennent des structures qui sont adressées par l'adresse de branchement. Tel est le cas du ''Branch Target Buffer'', de certaines PHT globales ou locales, de la ''Branch History Table'' qui stocke les historiques locaux, ou encore de la SRAM des poids des unités à base de perceptrons. En pratique, ces structures sont adressées non pas par l'adresse de branchement complète, mais pas les bits de poids faible de cette adresse. Cela a pour conséquence l'apparition d'un ''aliasing'' lié au fait que ces structures vont confondre deux branchements pour lesquels les bits de poids faible de l'adresse sont identiques. Il est possible d'utiliser autre chose que les bits de poids faible de l'adresse du branchement, afin de limiter les interférences. Et les possibilités sont multiples. Les possibilités en question s'inspirent des traitements effectués sur les adresses des banques dans les mémoires évoluées. Une première solution serait de faire un XOR entre les bits de poids faible de l'adresse du branchement, et d'autres bits de cette même adresse. Ainsi, deux branchements éloignés en mémoire donneraient des résultats différents, même si leurs bits de poids faible sont identiques. Une autre possibilité serait de diviser l'adresse du branchement par un nombre et de garder le reste. Ce calcul de modulo n'est en soi pas très différent du fait de conserver seulement les bits de poids faible. Conserver les N bits de poids faible consiste en effet à prendre le modulo par 2^N de l'adresse. Ici, l'idée serait de faire un modulo par un nombre P, qui serait un nombre premier proche de 2^N. Le fait que le nombre soit premier limite les cas où deux adresses différentes donneraient le même reste, ce qui réduit l'''aliasing''. Le fait de prendre un nombre proche de 2^N pour une entrée de N bits est que le résultat reste assez proche de ce qu'on obtiendrait en gardant les bits de poids faible, cette dernière étant une solution pas trop mauvaise. D'autres possibilités similaires se basent sur des réductions polynomiales ou d'autres astuces impliquant des nombres premiers. L'efficacité de ces méthodes dépend grandement de la taille de la SRAM/table considérée, des adresses des branchements et de beaucoup d'autres paramètres. La réduction des interférences par telle ou telle méthode dépend aussi de l'unité de prédiction considérée. Les résultats ne sont pas les mêmes selon que l'on parle d'une unité à perceptrons ou d'une unité à deux niveaux ou d'unités à base de compteurs à saturation, ou que l'on parle du BTB. Par contre, toutes les méthodes ne sont pas équivalentes en termes de temps de calcul ou de portes logiques. Autant la première solution avec un XOR ajoute un temps de calcul négligeable et quelques portes logiques, autant les autres méthodes requièrent l'ajout d'un diviseur très lent et gourmand en portes logiques pour calculer des modulos. Elles ne sont généralement pas praticables pour ces raisons, le temps de calcul serait trop élevé. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Le pipeline | prevText=Le pipeline | next=Les optimisations du chargement des instructions | nextText=Les optimisations du chargement des instructions }} </noinclude> suipf7w1hmy4anhqp4f59oi9q1g7kh5 774410 774409 2026-10-07T22:30:09Z Mewtow 31375 /* La prédiction de branchement */ 774410 wikitext text/x-wiki Les branchements sont pris en charge par plusieurs étages du pipeline. Exécuter un branchement demande de savoir deux choses : s'il est pris ou non, quelle est l'adresse de destination. L'adresse de destination est localisée grâce au mode d'adressage du branchement. Avec le mode d'adressage direct, elle est extraite de l'instruction par le séquenceur. Avec l'adressage indirect, elle est lue dans un registre. Avec les branchements relatifs, elle est calculée via un additionneur, qui additionne un décalage au ''program counter''. L'additionneur peut être soit dans l'étage de ''PC Update'', soit dans l'unité de calcul de l'étage d'exécution. Passons maintenant au résultat du branchement, à savoir s'il est pris ou non. Pour savoir si le branchement est pris, il faut distinguer les branchements conditionnels et inconditionnels. Pour rappel, les branchements inconditionnels regroupent les appels de fonction, les simples instructions de saut, et quelques autres. Ils sont toujours pris, ils s'exécutent toujours. À partir du moment où le décodeur a décodé un branchement inconditionnel, il sait que le branchement est pris. Pour les branchements conditionnels, ils sont pris seulement si une condition précise est respectée. On sait s'ils sont exécutés une fois que la condition est calculée par l'unité de calcul, ou par une unité de branchement spécialisée. [[File:Gestion des branchements sur un pipeline.png|centre|vignette|upright=2.0|Gestion des branchements conditionnels sur un pipeline.]] Sur le schéma précédent, les branchements inconditionnels ne sont pas montrés. Pour cela, il faudrait rajouter une connexion du décodeur vers le multiplexeur de branchement. De plus, il faudrait aussi rajouter de quoi retarder l'exécution du branchement seulement une fois que l'adresse est connue. Par exemple, elle est connue un cycle d'horloge plus si l'adresse est lue depuis les registres, comparé à un branchement direct. ==Les branchements et les exceptions imprécises/précises== Un branchement est censé altérer le ''program counter'' immédiatement, en un cycle d'horloge. Mais sur un pipeline, Le branchement fait bien reprendre le processeur au bon endroit, mais avec du retard. La raison est qu'un branchement met plusieurs cycles pour s'exécuter. Il faut plusieurs cycles pour déterminer quelle est l'adresse de destination, temps qui dépend de là où est lue l'adresse. Et on sait si le branchement est pris seulement au niveau du décodeur, voire au niveau de l'unité de calcul. Le branchement doit traverser plusieurs étages avant qu'on connaisse son résultat. Il y a bien 3 à 4 cycles avant cela. Et pendant ce temps de trajet, des instructions seront chargées dans le pipeline, potentiellement à tort. Si le branchement n'est pas pris, ce n'est pas grave : les instructions n'ont pas été chargées à tort. Mais dans le cas contraire, les instructions n'auraient pas dû rentrer dans le pipeline. Le problème est similaire à celui observé avec les exceptions précises/imprécises. La différence est que les branchements sont des instructions machines, pas les exceptions matérielles. Mais le problème des instructions chargées à tort est le même dans les grandes lignes. Une différence est que les branchements sont résolus plus ou moins au même étage, alors que les exceptions peuvent survenir dans tout le pipeline. Une autre différence, bien plus importante, est que les branchements non-pris ne chargent pas d'instructions à tort et qu'il faut faire la différence entre branchement pris et non-pris. Dans la suite, nous parlerons d'instructions fautives pour parler des instructions chargées à tort. Pour corriger ce problème, il existe une solution logicielle. Le compilateur fait suivre les branchements par des instructions qui ne font rien, des NOP (No OPeration) : c'est ce qu'on appelle un '''délai de branchement'''. L'idée est que toutes les instructions chargées à tort sont des NOPs, qui ne font rien. Mais le nombre de NOP à ajouter dépend du pipeline du processeur, ce qui peut poser des problèmes de compatibilité. Mais d'autres solutions, matérielles cette fois, sont utilisées de nos jours. ===L'annulation des instructions au décodage=== Les branchements inconditionnels sont dans un cas plus favorable : ils sont identifiés dans l'unité de décodage et sont toujours pris. D'autres branchements sont dans ce cas : les appels de fonctions, les interruptions logicielles. Avec eux, le décodeur annule les instructions fautives, il les remplace par des instructions NOP. L'étage de décodage sait que les instructions suivant un branchement inconditionnel ont été chargées à tort. Précisément N instructions, avec N le nombre d'étages entre le chargement et le décodage (inclus). Les N instructions suivantes sont tout simplement annulés, elles sont remplacées par des NOPs. L'implémentation demande juste un simple compteur et quelques circuits annexes. Cependant, la technique ne marche pas du tout pour les branchements conditionnels. En effet, on ne sait pas s'ils sont pris ou non à l'étage de décodage. Du moins pas avec un pipeline normal. Mais certains processeurs, basés sur le pipeline RISC classique, calculaient les branchements pendant l'étape de décodage, dans une ALU spécialisée qui travaille en parallèle de l'unité de décodage. Le défaut de cette technique est que l'ALU en question doit lire les registres. Pour rappel, sur ce pipeline, l'étape de décodage effectue la lecture des opérandes dans les registres, en parallèle de l'unité de décodage. Mais ce qu'on n'a pas dit dans le chapitre précédent, c'est qu'elle effectue aussi le calcul des branchements dans une mini-ALU séparée ! Ce faisant, le résultat du branchement est connu dès la fin de l'étape de décodage. ===L'usage du système d'exceptions précises pour les branchements conditionnels=== La solution gère le problème en empêchant les instructions chargées à tort d'enregistrer leurs résultats, afin qu'elles n'aient pas le moindre effet sur l'état du processeur. Le hardware pour les exceptions précises, décrit quelques paragraphes avant, est réutilisé pour cela. Les branchements conditionnels peuvent être pris ou non-pris et on doit tenir compte de ces deux cas. Si le branchement n'est pas pris, les instructions n'ont pas été chargées à tort, elles peuvent enregistrer leur résultat dans les registres. Par contre, ce n'est pas le cas pour les branchements pris, les mécanismes d'exceptions précise doivent alors d'activer. Pour cela, la meilleure solution est que l'unité de calcul génère un indicateur d'exception si le branchement n'est pas pris. L'ALU effectue la comparaison et génère ou non l'indicateur d'exception adéquat suivant le résultat de celle-ci. L'idée est d'utiliser un indicateur d'exception particulier, qui précise qu'un branchement a été pris, signe que des instructions ont été chargées à tort. L'indicateur est propagé dans le pipeline, jusqu'à atteindre l'étage d'enregistrement dans les registres. Cet étage décide s'il faut autoriser ou non les écritures qu'il recoit, pour annuler les instructions chargées à tort. Le ''program counter'' est corrigé immédiatement, simplement en exécutant le branchement. Reste à savoir qui génère cet indicateur d'exception de branchement pris. Diverses optimisations visent à éliminer les instructions chargées à tort, à faire en sorte que des instructions utiles soient chargées. Le chapitre suivant va détailler ces optimisations dans le détail, qui sont regroupées sous le terme de prédiction de branchement. Elles ne fonctionnent pas pour les exceptions précises, seulement les branchements. ===La prédiction de branchement=== Les techniques précédentes ont pour défaut que de nombreux cycles d'horloge sont gâchés à cause des instructions fautives. Pour éviter ce gâchis, les concepteurs de processeurs ont inventé l'''exécution spéculative de branchement'', qui consiste à deviner l'adresse de destination du branchement et charger les bonnes instructions dès le départ. Le but est de ne pas charger d'instruction à tort, en prédisant quelles seront les bonnes instructions à charger. Et cela demande de résoudre trois problèmes : * reconnaître les branchements ; * savoir si un branchement sera exécuté ou non : c'est la '''prédiction de branchement''' ; * dans le cas où un branchement serait exécuté, il faut aussi savoir quelle est l'adresse de destination : c'est la '''prédiction de l'adresse de destination''' d'un branchement, aussi appelée ''branch target prediction''. Pour résoudre le second problème, le processeur contient un circuit qui déterminer si le branchement est pris (on doit brancher vers l'adresse de destination) ou non pris (on poursuit l’exécution du programme immédiatement après le branchement) : c'est l''''unité de prédiction de branchement'''. La prédiction de direction de branchement est elle aussi déléguée à un circuit spécialisé : l’'''unité de prédiction de direction de branchement'''. C'est l'unité de prédiction de branchement qui autorise l'unité de prédiction de destination de branchement à modifier le ''program counter''. Dans certains processeurs, les deux unités sont regroupées dans le même circuit. [[File:Relation entre prédiction de branchement et de direction de branchement.png|centre|vignette|upright=2|Relation entre prédiction de branchement et de direction de branchement.]] : Au lieu de parler d'unité de prédiction de branchement, j'utiliserais parfois le terme '''prédicteur''', sous-entendu ''prédicteur de branchement''. Avant de poursuivre, faisons une remarque très importante. J'ai parlé d'unité de prédiction de branchement, comme s'il n'y en avait qu'une seule. Mais en réalité, n'en avoir qu'une seule est l'exception. La règle est d'avoir plusieurs unités de branchements spécialisées. Un processeur moderne utilise plusieurs prédicteurs séparés, chacun prédisant un type de branchement bien précis. Il y a pas exemple un prédicteur spécialisé pour les boucles, un autre pour les instructions de retour de fonctions, un autre pour les branchements directs, un autre pour les branchements indirects. De plus, les processeurs modernes mélangent des prédicteurs rapides et imprécis, avec des prédicteurs lents mais précis. Les prédicteurs de premier type envoient les adresses prédites au cache d'instruction, pour charger les instructions prédites et mettre à jour le ''program counter'' en conséquence. Les prédicteurs lents et précis envoient leurs résultats au décodeur d'instruction, histoire d'annuler des branchements considérés à tort comme pris/non-pris. Il y a donc des prédicteurs rapides, dont le résultat est corrigé par des prédicteurs plus lents. La seconde unité a le dernier mot, elle corrige ou annule les prédictions de la première. Ce qui lui vaut le nom, en anglais, d'''overiding predictor''. Ce détail aura rapidement son imortance. ==Les erreurs de prédiction de branchement== Le processeur peut parfaitement se tromper en faisant ses prédictions, ce qui charge des instructions par erreur dans le pipeline. Dans ce cas, on parle d''''erreur de prédiction'''. Les erreurs de prédiction sont corrigées par le système d'exception précises, ou un équivalent. En clair, les instructions chargées à tort se propagent jusqu'à la sortie du pipeline, où leurs résultats sont alors annulés. N'importe quelle technique de gestion des exceptions précise fait le travail, mais les processeurs modernes utilisent un tampon de réordonnancement (''reorder buffer'') qu'on détaillera dans quelques chapitres. Détecter les erreurs de prédiction demande d'ajouter un circuit dans le processeur : l’''unité de vérification de branchement'' (''branch verification unit''). Elle génère un indicateur d'exception, qui est géré par le système d'exception précise. Pour générer l'indicateur, elle compare l'adresse de destination prédite et celle calculée en exécutant le branchement. Pour cela, l'adresse prédite doit être propagée dans le pipeline jusqu’à ce que l'adresse de destination du branchement soit calculée. [[File:Unité de vérification de branchement.png|centre|vignette|upright=2|Unité de vérification de branchement]] ===La pénalité de mauvaise prédiction=== Le temps pour vider le pipeline des instructions fautives dépend du pipeline. La dernière instruction à être chargée dans le pipeline le sera en même temps qu'on détecte l'erreur de prédiction. Il faudra attendre que cette instruction quitte le pipeline, et donc qu'elle traverse tous les étages. Il y a donc un temps d'attente pour collecter les résultats de ces instructions et la décision de ne pas enregistrer leurs résultats dans les registres Le nombre d'instructions chargées à tort dépend de où est détectée l'erreur de prédiction. Plus le résultat du branchement est connu tard, plus le nombre d'instructions chargées à tort sera grand, et plus le nombre de cycles d'horloge gâchés le sera aussi. Le nombre de cycles gâchés s'appelle la '''pénalité de mauvaise prédiction'''. Elle correspond précisément au nombre de cycles/étages entre le premier étage du pipeline, et l'étage où le branchement est résolu (on connait l'adresse à laquelle brancher). Plus celui-ci est éloigné du début du pipeline, plus la pénalité est importante. [[File:MIT 6.823 Computer System Architecture, Fall 2005 - P-3 vs. P-4 Pipelines - 4473425702.jpg|centre|vignette|upright=2.5|Comparaison de la pénalité de mauvaise prédiction entre le Pentium 3 et le Pentium 4 d'Intel.]] Elle est généralement d'autant plus longue que le pipeline lui-même est long. Les concepteurs de processeurs font donc face à un compromis : un pipeline long permet d'exécuter plus d'instructions en parallèle, mais cela augmente aussi la pénalité de mauvaise prédiction. Un pipeline de 10-15 étapes est un bon compromis. Les pipelines plus longs ont besoin d'une prédiction de branchement élaborée pour fonctionner correctement. Plus la prédiction de branchement est performante, plus le pipeline peut être long. Les pénalités de mauvaise prédiction seront plus importantes, mais les mauvaises prédictions seront plus rares, ce qui compense complétement. ===La ''minimal control dependency''=== En utilisant une technique du nom de ''minimal control dependency'', seules les instructions qui dépendent du résultat du branchement sont supprimées du pipeline en cas de mauvaise prédiction, les autres n'étant pas annulées. Sans ''minimal control dependency'', toutes les instructions qui suivent un branchement dans notre pipeline sont annulées. Pourtant, certaines d'entre elles pourraient être utiles. Prenons un exemple : supposons que l'on dispose d'un processeur de 31 étages (un Pentium 4 par exemple). Supposons que l'adresse du branchement est connue au 9éme étage. On fait face à un branchement qui envoie le processeur seulement 6 instructions plus loin. Si toutes les instructions qui suivent le branchement sont supprimées, les instructions en rouge sont celles qui sont chargées incorrectement. On remarque pourtant que certaines instructions chargées sont potentiellement correctes : celles qui suivent le point d'arrivée du branchement. Elles ne le sont pas forcément : il se peut qu'elles aient des dépendances avec les instructions supprimées. Mais si elles n'en ont pas, alors ces instructions auraient dûes être exécutées. Il serait donc plus efficace de les laisser enregistrer leurs résultats au lieu de les ré-exécuter à l’identique un peu plus tard. Ce genre de choses est possible sur les processeurs qui implémentent une technique du nom de ''Minimal Control Dependancy''. En gros, cette technique fait en sorte que seules les instructions qui dépendent du résultat du branchement soient supprimées du pipeline en cas de mauvaise prédiction. [[File:Minimal cntrol dependency.png|centre|vignette|upright=2|Minimal control dependency]] ==La reconnaissance des branchements== Pour prédire des branchements, le processeur doit faire la différence entre branchements et autres instructions, dès l'étage de chargement. Or, un processeur « normal », sans prédiction de branchement, ne peut faire cette différence qu'à l'étage de décodage, et pas avant. Les chercheurs ont donc dû trouver une solution. La première solution se base sur les techniques de prédécodage vues dans le chapitre sur le cache. Pour rappel, ce prédécodage consiste à décoder partiellement les instructions lors de leur chargement dans le cache d'instructions et à mémoriser des informations utiles dans la ligne de cache. Dans le cas des branchements, les circuits de prédécodage peuvent identifier les branchements et mémoriser cette information dans la ligne de cache. Une autre solution mémorise les branchements déjà rencontrés, dans un cache dédié. Ce cache mémorise l'adresse (le ''program counter'') des branchements déjà rencontrés : il s'appelle le '''tampon d’adresse de branchement''' (''branch adress buffer''). Il est intégré à l'unité de prédiction de branchement, mais est alimenté par le décodeur d'instruction. Reconnaitre un branchement demande l'implication du décodeur d'instruction, qui sait si l'instruction qu'il vient de décoder est un branchement ou non. Il peut même savoir si le branchement est conditionnel, inconditionnel, un appel de retour de fonction, etc. Lorsque le décodeur d'instruction reconnait un branchement, il ajoute ce branchement dans le cache Le tapon d'adresse de branchement contient une adresse mémoire par branchement, et éventuellement d'autres informations. Et pour comprendre quelle adresse est mémorisée, il faut faire attention à un détail. Les explications qui vont suivre vont faire intervenir deux adresses. * La première est l''''adresse de destination''' du branchement, à savoir l'adresse à laquelle le processeur doit reprendre son exécution si le branchement est pris. * L'autre adresse est l''''adresse du branchement''' lui-même. Elle correspond à sa position en mémoire RAM. L'adresse du branchement est la valeur du ''program counter'' quand le branchement est chargé dans le cache, alors que l'adresse de destination est celle du ''program Counter'' une fois le branchement exécuté. Il faudra faire bien attention à ne pas confondre les deux adresses dans ce qui suit. L'adresse du branchement est celle qui est utilisée pour la reconnaissance du branchement. Le tampon d'adresse de branchement mémorise, pour chaque branchement, son adresse de branchement (d'où son nom). À chaque cycle d'horloge, l'unité de chargement envoie le ''program counter'' en entrée du tampon d’adresse de branchement. Un succès dans ce cache indique qu'un branchement déjà rencontré est ré-executé, on peut utiliser la prédiction de branchement. Dans le cas contraire, la prédiction de branchement n'est pas utilisée, et l'instruction est chargée normalement. ==La prédiction de l'adresse de destination== La '''prédiction de l'adresse de destination d'un branchement''' détermine l'adresse de destination d'un branchement. Rappelons qu'il existe plusieurs modes d'adressage pour les branchements et que chacun d'entre eux précise l'adresse de destination à sa manière. Suivant le mode d'adressage, l'adresse de destination est soit dans l'instruction elle-même (adressage direct), soit calculée à l’exécution en additionnant un ''offset'' au ''program counter'' (adressage relatif), soit dans un registre du processeur (branchement indirect), soit précisée de manière implicite (retour de fonction, adresse au sommet de la pile). : Histoire de simplifier la terminologie, je parlerais parfois de '''prédiction de destination''' pour parler de la ''prédiction de l'adresse de destination d'un branchement''. Pour rappel, les branchements se classent en trois types : * les branchements directs pour lequel l'adresse de destination est toujours la même ; * les branchements indirects où l'adresse de destination est dans un registre et peut changer ; * les instructions de retour de fonction. Les branchements directs sont facilement prévisibles, vu que l'adresse vers laquelle il faut brancher est toujours la même. La prédiction des branchements directs et relatifs se fait globalement de la même manière, ce qui fait que nous ferons la confusion dans ce qui suit. Pour les instructions de retour de fonction, une prédiction parfaite est possible. Et c'est ce qui explique qu'elles sont traitées à part, par une unité de branchement dédiée. Pour les branchements indirects, vu que cette adresse change, la prédire celle-ci est particulièrement compliqué (quand c'est possible). Les branchements indirects sont eux aussi traités par une unité de prédiction dédiée rien que pour eux. ===La prédiction de destination pour les branchements directs=== Lorsqu'un branchement est exécuté, on mémoriser l'adresse de destination et la réutiliser lors d'exécutions ultérieures du branchement. Cette technique marche à la perfection pour les branchements directs, pour lesquels cette adresse est toujours la même, mais donne des résultats mitigés pour les branchements indirects. Pour mémoriser l'adresse de destination, on utilise un cache qui mémorise les correspondances entre l'adresse du branchement et l'adresse de destination. Ce cache est appelé le '''''branch target buffer''''' en anglais, qui sera abrévié en BTB dans ce qui suit. Le BTB est une amélioration du tampon d'adresse de branchement vu plus haut, auquel on aurait ajouté les adresses de destination des branchements. Le BTB est utilisé comme suit : on envoie en entrée l'adresse du branchement, le BTB répond au cycle suivant en précisant : s’il reconnaît l'adresse d'entrée, et quelle est l'adresse de destination si c'est le cas. Le BTB peut être un cache totalement associatif, associatif par voie, ou directement adressé, mais ces derniers sont les plus fréquents. Les BTB sont généralement des caches de type directement adressés, où seuls les bits de poids faible de l'adresse du branchement adressent le cache, le reste de l'adresse est tout simplement ignoré. Il existe aussi des BTB qui sont construits comme les caches associatifs à plusieurs voies, mais ceux-ci impliquent généralement la présence de ''tags'', ce qui fait qu'ils sont un peu moins fréquents. [[File:Branch target buffer directement adressé.png|centre|vignette|upright=2|Branch target buffer directement adressé.]] Comme pour tous les caches, un accès au BTB peut entraîner un défaut de cache, c’est-à-dire qu'un branchement censé être dans le BTB n'y est pas. Comme pour les autres caches, les défauts de cache peuvent se classer en trois types : les défauts de cache à froid (''cold miss'') causés par la première exécution d'un branchement, les défauts liés à la capacité du BTB/cache, et les défauts par conflit d'accès au cache. Les défauts liés à la capacité du BTB sont les plus simples à comprendre. La capacité limitée du BTB fait que d'anciens branchements sont éliminées pour laisser la place à de nouveaux. En conséquence, ces branchements peuvent donner des erreurs de prédiction si leur correspondance a été éliminée du cache. On peut réduire des défauts en augmentant la taille du BTB. Les défauts liés aux conflits d'accès au BTB sont eux beaucoup plus intéressants, car la conception du BTB est assez spéciale dans la manière dont sont gérés ce genre de défauts. Le BTB est un cache partiellement associatif, ce qui fait que des défauts de cache par conflit d'accès peuvent avoir lieu. Pour rappel, les défauts par conflit d'accès ont lieu quand deux adresses mémoires se voient attribuer la même ligne de cache. Pour un BTB, cela correspond au cas où deux branchements se voient attribuer la même entrée dans le BTB, ce qui porte le nom d’''aliasing''. Ne pas détecter ce genre de situation sur un cache normal entraînerait des problèmes : la donnée lue/écrite ne serait pas forcément la bonne. Les caches normaux utilisent un système de tags pour éviter ce problème et on s'attendrait à ce que les BTB fassent de même. Mais pour un BTB, l'absence de ''tags'' n'est pas un problème : la seule conséquence est une augmentation du taux de mauvaises prédictions, dont les conséquences en termes de performance peuvent être facilement compensées. Les BTB peuvent donc se passer de tags, ce qui a de nombreux avantages : le circuit est plus rapide, prend moins de portes logiques, consomme moins d'énergie, etc. Ces économies de portes logiques et de performances peuvent être utilisées pour augmenter la taille du BTB, ce qui compense, voire surcompense les mauvaises prédictions induites par l’''aliasing''. Mais ce n'est pas systématique : d'autres BTBs utilisent des ''tags'' pour améliorer le taux de prédiction. Nous ferons donc la différence entre '''BTB tagués''' et '''BTB non-tagués'''. Des processeurs se passent de BTB. À la place, ils mémorisent les correspondances entre branchement et adresse de destination dans les bits de contrôle du cache d'instructions. Mais nous verrons cela en détail dans le chapitre suivant. ===La prédiction de destination pour les branchements indirects=== Un BTB classique peut fonctionner pour les branchements indirects. Son seul défaut est qu'il mémorisera la dernière adresse de destination prise par le branchement. Et cela peut fonctionner pour les branchements indirects fortement biaisés, qui ont presque toujours la même adresse de destination. Mais la majorité des branchements indirects change d'adresse de destination régulièrement. Tous les processeurs commerciaux datant d'avant le Pentium M utilisaient un BTB normal pour prédire les branchements indirects. Les processeurs qui les ont suivi sont capables de prédire l'adresse de destination d'un branchement indirect. , grâce à une '''unité de prédiction des branchements indirects''', séparée de l'unité de prédiction de branchement normale. Elle utilise un BTB amélioré, qui mémorise plusieurs adresses de destination pour un seul branchement, en compagnie d'informations pour prédire laquelle est la bonne. Il est possible d'utiliser un BTB unique, pour tous types de branchements, appelé un '''BTB unifié'''. Un BTB unifié a plusieurs adresse de destination par entrée du BTB. Mais une seule est utilisée pour les branchements directs. Vu que les branchements directs sont majoritaires, cela fait beaucoup d'entrées inutilisées. En conséquence, elle est tout sauf optimale, et n'est presque jamais utilisée en pratique. {|class="wikitable" |+ Branch Target Buffer unifié |- ! Adresse du branchement !! Informations de prédiction !! Adresse de destination !! Seconde adresse de destination |- | 0x54462146 || 11101011 || 0x52495674 || 0x24987461 |- | 0x54142446 || 11101101 || 0x15646956 || x0x4158746 |- | 0x25456542 || 10011101 || 0x52495674 || 0x24157461 |- | 0x15455855 || 11010101 || 0x52485754 || 0x24024511 |- | ... || ... || ... || ... |- | ... || ... || ... || ... |- | 0x54462146 || 1001101 || 0x52495674 || 0x24987461 |} Une seconde solution utilise un BTB séparé, distinct du BTB pour les branchements directs. En séparant les deux, on a un BTB simple qui ne mémorise qu'une adresse de destination par branchement, et un BTB pour branchements indirects avec plusieurs adresses de destination par branchement. [[File:BTB avec prédiction de branchements indirects.png|centre|vignette|upright=2.5|BTB avec prédiction de branchements indirects.]] L'idée est que l'on économise des circuits à nombre d'entrées égales. Par exemple, comparons ce que cela donne avec 64 entrées. D'un côté, on a un BTB à 64 entrées, capable de gérer deux adresse de destination par branchement. En pratique, la majorité des branchements sont des branchements directs, qui n'utiliseraient qu'une seule case mémoire. Les circuits du BTB seraient donc sous-utilisés. De l'autre, on a un BTB à 48 entrées pour branchements directs, et un second BTB avec 16 entrées, capable de mémoriser deux adresses par branchements. Le premier BTB utilise moins de circuits, et est utilisé à pleine capacité, il ne gaspille pas de circuits pour mémoriser des adresses de destination vides. Par contre, on perd un peu en flexibilité. L'organisation précédente marche bien si 1/4 des branchements rencontrés sont des branchements indirects. Mais si le ratio est différent, la performance est légèrement sous-optimale. Attention, cela ne veut pas dire qu'il y aura des entrées vides dans un BTB, non. Simplement, le premier BTB risque de manquer de place pour les branchements directs récents, alors que les branchements indirects seront eux bien prédits, même les anciens. À l'opposé, un BTB unique permet de mémoriser les 64 derniers branchements, peu importe qu'ils soient indirects ou non. En clair : un BTB unique est plus flexible, ce qui donne un gain de performance dans certaines situations. Cependant, cet avantage théorique ne tient pas compte de l'économie en circuits, qui peut être utilisée pour faire grossir le BTB pour branchements directs. Par exemple, à budget à transistor équivalent, on a le choix entre BTB à 64 entrées uniques, et un BTB pour branchements directs de 80 entrées avec un BTB indirect de 16 entrées. Le fait qu'un BTB soit plus gros permet des gains de performances qui compensent la perte de flexibilité. On peut ainsi avoir le meilleur des deux monde, à savoir une économie de circuits et les gains en performance qui en découlent, malgré une petite perte de flexibilité. Une troisième solution utilise un BTB normal, mais réserve plusieurs entrées pour un branchement indirect. Par exemple, un branchement indirect qui a trois adresses de destination prendra trois entrées : une par adresse de destination. Mais quelques difficultés surviennent alors. Comment placer trois copies d'un même branchement, alors que ces trois copies ont le même ''program counter'' ? Et que faire pour savoir quelle copie sera prise ? Que faire si l'unité de prédiction de branchement prédit les trois copies comme prises ? La technique du '''''Program Counter'' virtuel''' (''Virtual Program Counter'') résoud ces problèmes d'une manière assez élégante. L'idée est la même que précédemment, à savoir qu'il y a une copie du branchement par adresse de destination, dans le BTB. Mais ces copies n'ont pas exactement le même ''Program counter''. Chaque copie se voit attribuer un ''Program Counter'' virtuel. Et comprendre pourquoi demande de comprendre l'idée sous-jacente. La technique a été inspirée par une optimisation des compilateurs, qui transforme un branchement indirects en une séquence de branchements conditionnels. Par exemple, un branchement indirects qui peut brancher vers les adresses A, B et C est transformé en trois branchements conditionnels : un qui branche vers A, un autre qui branche vers B et un autre vers C. Elle ne marche pas pour tous les branchements indirects, elle demande des circonstances particulières pour fonctionner. Mais l'idée est adaptée pour le ''design'' de l'unité de prédiction de branchement. L'idée est qu'un branchement indirect est déroulé en plusieurs branchements dans le BTB. Les branchements sont traités en séquence : l'unité de prédiction prédit si le premier branchement est pris ou non. S'il est pris, son adresse de destination est envoyée au cache d'instruction. S'il n'est pas pris, l'unité de prédiction tester la seconde copie du branchement, et rebelote. Il teste toutes les copies, une par une, et s’arrête quand il tombe sur un branchement pris. Si aucune copie n'est prise, alors le branchement est prédit comme non-pris. Première remarque : il s'agit d'un processus itératif, où on test un branchement à la fois. Et oui, c'est une prédiction par cycle d'horloge. Prédire un branchement indirect peut donc demander plusieurs cycles d'horloge. Mais ce n'est pas un problème, car la prédiction peut être utilisée par l'unité de décodage, pour annuler les instructions chargées à tord. Deuxième remarque : comment passer d'un branchement à l'autre ? La première copie a un ''program counter'' normal, qui correspond à l'adresse du branchement. La seconde a un ''program counter'' qui est calculé à partir de cette première adresse, la troisième copie a une adresse calculée à partir de la seconde, etc. Concrètement, n'importe quelle fonction un tant soit peu simple suffit, tant qu'elle est déterministe. L'article qui a proposé cette techniques (''Virtual Program Counter (VPC) Prediction : Very Low Cost Indirect Branch Prediction Using Conditional Branch Prediction Hardware'') se contenait de faire un XOR entre l'adresse précédente et une constante, avec une constante différente par itération. Troisième remarque : l'algorithme demande de faire une parenthèse dans le fonctionnement normal du BTB. Quand un branchement indirect est détecté, il lance une succession de prédiction pour prédire un branchement. L'incrémentation du ''program counter'' normale cesse pendant ce processus. Et cela ne doit être fait que quand un branchement indirect est détecté. Pour cela, le BTB mémorise le type de chaque branchement, sans l'entrée du BTBT : est-ce qu'il est conditionnel, inconditionnel, mais aussi direct ou indirect. Et le type est accesible en sortie du BTB, sur une sortie de quelques bit. Quand l'unité de prédiction remarque qu'un branchement indirect vient de sortir du BTB, alors elle lance le processus. [[File:Virtual program counter.png|centre|vignette|upright=2.5|Virtual program counter]] L'avantage de cette technique est qu'elle s’implémente facilement, son cout en transistors est faible. Elle demande juste d'ajouter quelques circuits autour d'un BTB classique. Elle utilise l'unité de prédiction classique, pas besoin d'une unité de prédiction séparée. Vu qu'il y a un seul BTB, on n'a pas les désavantages d'un BTB séparé pour les branchements indirects. Les entrées du BTB sont réparties entre branchements directs et indirects de manière optimale. Niveau performance, elle est un peu moins bonne que les unités de prédiction ITTAGE qu'on détaillera dans la suite du chapitre. Mais elle est suffisante pour être utilisée sur certains processeurs commerciaux. ===La prédiction de l'adresse de retour d'une fonction=== Presque tous les processeurs conçus après les années 2000 peuvent prévoir l'adresse à laquelle il faudra reprendre lorsqu'une fonction/sous-programme a fini de s’exécuter. Ils font de la '''prédiction de retour de fonction'''. Pour cela, ils possèdent pour une unité de prédiction de branchement spécialisée, appelée le ''return stack predictor'', ou '''''pile de prédiction de retour de fonction'''''. Le terme ''return stack predictor'' étant plus court, nous l'utiliserons dans la suite du cours. Le ''return stack predictor'' est capable de reconnaitre les instructions de retour de fonction, et de prédire l'adresse de retour. Pour comprendre comment elle fait, rappelez-vous le chapitre sur la pile d'appel. Dans ce chapitre, j'avais introduit le concept de ''pile d'adresse de retour'', à savoir une pile d'appel très simplifiée, qui ne contient que les adresses de retour des fonctions en cours d'exécution. Le ''return stack predictor'' est essentiellement composé d'une telle pile d'adresse de retour, avec des circuits pour empiler et dépiler les adresses de retour. Le ''return stack predictor'' doit reconstruire une pile d'adresses de retour, à partir du programme exécuté. La pile d'adresse est mémorisée dans le ''return stack predictor'', elle est totalement séparée de la pile d'appel normale. Le programme exécuté continue d'utiliser la même pile d'appel qu'avant, ce qui fait que les adresses de retour sont dispersées dans la pile d'appel, parfois dans des registres spéciaux du processeur. Mais cela ne pose pas de problèmes pour le processeur, qui reconstruit la pile d'adresse de retour avec l'aide du décodeur. La reconstruction se fait à partir des instructions d'appel et de retour de fonction. Une instruction d'appel de fonction empile une adresse de retour, une instruction de retour de fonction la dépile. Lorsque le décodeur d'instruction reconnait une instruction d'appel de fonction, il extrait l'adresse de retour et l'envoie au ''return stack predictor''. L'adresse de retour est alors empilée, ce qui fait qu'elle se retrouve au sommet de la pile d'adresse de retour. Dépiler une adresse de retour revient à prédire un branchement. Lorsque le ''return stack predictor'' détecte une instruction de retour de fonction, il dépile l'adresse au sommet de la pile. L'adresse est alors envoyée du cache d'instruction, pour l'ire l'instruction adéquate, et le ''program counter'' est mis à jour avec cette adresse. Toute la difficulté revient à détecter les instructions de retour de fonction, sans quoi on ne sait pas quand dépiler l'adresse. La documentation là-dessus n'est pas très fournie, il est difficile de savoir ce qui est fait dans les processeurs modernes. Mais on peut faire quelques suppositions. La première possibilité est l'usage du ''prédécodage'', une technique qui décode partiellement les instructions lors de leur chargement dans le cache d'instruction. Elle sera vue dans le chapitre suivant, mais parlons-en rapidement. Le prédécodage ajoute quelques bits au début de chaque instruction pour préciser si c'est un branchement conditionnel, inconditionnel, un retour de fonction. On peut ainsi savoir rapidement quand dépiler : dès qu'une instruction est lue dans le cache d'instruction, le ''return stack predictor'' vérifie les bits ajoutés et agit en fonction. La seconde possibilité est que le BTB reconnait l'instruction de retour de fonction. Pour chaque branchement, il mémorise non seulement son adresse de retour, mais aussi son type. Par type, on veut dire : si c'est un branchement conditionnel, inconditionnel, un retour de fonction. Les retours de fonction sont donc reconnus à condition qu'ils aient été exécutés auparavant. Il est aussi possible d'avoir un BTB spécialisé dans les retours de fonction, qui est alimenté par les décodeurs. Dès que le décodeur reconnait une instruction de retour de fonction, il ajoute son ''program counter'' dans ce BTB spécialisé. Une dernière possibilité est que le ''return stack predictor'' agit une fois l'instruction partiellement décodée. Le décodeur d'instruction reconnait les instructions de retour de fonction rapidement, en un cycle, et consulte alors le ''return stack predictor''. L'adresse est dépilée, et le décodeur sait où brancher. Il n'a pas besoin de devoir lire l'adresse de retour en mémoire, il la lit depuis le ''return stack predictor'', directement. La prédiction se fait donc assez tard dans le pipeline, quelques instructions ont été chargées à tort. Mais le décodeur peut les annuler, comme il le ferait avec un branchement inconditionnel mal prédit. ==La prédiction de branchement locale== La prédiction de branchement tente de prédire si un branchement sera pris ou non-pris, et décide quelle adresse envoyer au cache en fonction du résultat. Si on prédit qu'un branchement est non pris, on laisse le ''program counter'' faire son travail. À l'inverse si le branchement est prédit comme étant pris, l'adresse de destination est envoyée au cache d'instruction et au ''program counter''. La prédiction de branchement assigne une probabilité qu'un soit branchement soit pris en fonction de ces informations : si la probabilité est de plus de 50%, le branchement est considéré comme pris. La prédiction de branchement se base avant tout sur des statistiques, c’est-à-dire qu'elle détermine la probabilité d’exécution d'un branchement en fonction d'informations assez diverses. La prédiction de branchement n'a d'intérêt que si les prédictions sont suffisamment fiables pour valoir le coup. Rappelons que la pénalité lors d'une mauvaise prédiction est importante, d'autant plus coûteuse que le pipeline est long. Il est généralement admis qu'il faut au minimum un taux de réussite proche de 90%, voire plus sur les processeurs modernes. Heureusement, la plupart des branchements sont des '''branchements biaisés''', c’est-à-dire qu'ils sont presque toujours pris ou presque toujours non-pris, sauf en de rares occasions. De tels branchements font que la prédiction des branchements est facile, bien qu'ils posent paradoxalement quelques problèmes, comme nous le verrons plus bas. Un autre point important est que les unités de prédiction de branchement doivent être très rapides. Idéalement, elles doivent fournir leur prédiction en un seul cycle d'horloge. Un seul cycle d'horloge est un temps très court, surtout sur les ordinateurs avec une fréquence élevée. Les techniques de prédiction dynamique ne peuvent donc pas utiliser des méthodes statistiques extraordinairement complexes. Cela va à l'encontre du fait que le taux de mauvaises prédictions doit être très faible, idéalement inférieur à 1% sur les processeurs modernes. Vous comprenez donc aisément que concevoir une unité de prédiction de branchement est un véritable défi d’ingénierie électronique qui requiert des prouesses technologiques sans précédent. Suivant la nature des informations utilisées, on peut distinguer plusieurs types de prédiction de branchement : locale, globale, dynamique, statique, etc. Nous allons d'abord voir les méthodes statiques avant de voir les méthodes dynamiques. La '''prédiction locale''' prédit chaque branchement indépendamment les uns des autres. Elle sépare bien les statistiques/moyennes pour chaque branchement. Et c'est autant un point fort qu'un point faible. En effet, elle n'exploite pas les corrélations entre branchements, à savoir le fait que l'exécution d'un branchement a tendance à déborder sur les suivants. Dans ce qui suit, nous allons voir les techniques de prédiction locales. ===Les ''branch hints''=== Dans son implémentation la plus explicite, la prédiction est inscrite dans le branchement lui-même. Quelques bits de l'opcode du branchement précisent si le branchement est majoritairement pris ou non pris. Ils permettent d'influencer les règles de prédiction de branchement et de passer outre les réglages par défaut. Ces bits sont appelés des '''suggestions de branchement''' (''branch hint''). Mais tous les processeurs ne gèrent pas cette fonctionnalité. Et de plus, cette solution marche assez mal. La raison est que le programmeur ou le compilateur doit déterminer si le branchement est souvent pris ou non-pris, mais qu'il n'a pas de moyen réellement fiable pour cela. Une solution possible est d’exécuter le programme sur un ensemble de données réalistes et d'analyser le comportement de chaque branchement, afin de déterminer les suggestions de branchement adéquates, mais c'est une solution lourde et peu pratique, qui a de bonnes chances de donner des résultats peu reproductibles d'une exécution à l'autre. ===La prédiction statique de branchement=== La '''prédiction statique''' prédit le résultat du branchement en fonction de caractéristiques fixes du branchement lui-même, comme son adresse, son mode d'adressage, l'adresse de destination, la position du branchement en mémoire, si le branchement est direct ou indirect, etc. Les informations en question sont connues une fois le branchement décodé, il n'y a pas besoin de mémoriser la moindre information supplémentaire. Une première implémentation, assez évidente, fait la différence entre les branchements inconditionnels toujours pris, et les branchements conditionnels au résultat variable. L'idée est de prédire les branchements inconditionnels comme toujours pris alors que les branchements conditionnels sont prédits comme non-pris (ce qui est une approximation). L'implémentation est relativement simple : on utilise un BTB qui ne mémorise que les branchements inconditionnels, et basta ! Les branchements conditionnels sont simplement ignorés, traités comme n'importe quelle autre instruction. Cette méthode est particulièrement inefficace pour les branchements de boucle, où la condition est toujours vraie, sauf en sortie de boucle ! Il a donc fallu affiner légèrement l'algorithme de prédiction statique. Une implémentation plus subtile fait la différence entre les branchements des boucles et les autres. Reste à identifier ceux-ci d'une manière ou d'une autre. Et un bon moyen est de regarder si le branchement revient en arrière ou au contraire saute une portion de code. Un programme s'exécute des adresses basses vers les adresses hautes, les instructions vont naturellement dans le sens croissant des adresses. Un branchement de boucle fait revenir en arrière, histoire de répéter un morceau de code, pas les autres branchements (du moins pas forcément). Et cela nous amène à faire la distinction entre les branchements conditionnels ascendants et descendants. Un '''branchement conditionnel ascendant''' est un branchement qui demande au processeur de reprendre plus loin dans la mémoire : l'adresse de destination est supérieure à l'adresse du branchement. Un branchement '''conditionnel descendant''' a une adresse de destination inférieure à l'adresse du branchement : le branchement demande au processeur de reprendre en arrière dans la mémoire, avant le branchement. Les branchements ascendants sont rarement pris (ils servent dans les conditions de type SI…ALORS), contrairement aux branchements descendants (qui servent à fabriquer des boucles). On peut ainsi modifier l’algorithme de prédiction statique comme suit : * les branchements inconditionnels sont toujours pris ; * les branchements descendants sont toujours pris ; * les branchements ascendants ne sont jamais pris. L'implémentation demande d'élargir le BTB. Le BTB n'a pas seulement à mémoriser l'adresse du branchement et son adresse de destination, mais aussi le type du branchement. En général, il se contente de deux bits, qui indiquent si le branchement est conditionnel, inconditionnel, ou un retour de fonction. Les retours de fonction sont traités à part, histoire de profiter du ''return stack predictor''. {|class="wikitable" |+ BTB avec prédiction de branchement statique évoluée |- ! Adresse du branchement !! Adresse de destination !! Type du branchement |- | 0x4165485 || 0x14657445541 | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Retour de fonction |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... || ... |} ===La prédiction avec des compteurs à saturation=== Les méthodes de prédiction de branchement statique sont intéressantes, mais elles montrent rapidement leurs limites. Pour plus de performances, il faut utiliser la '''prédiction dynamique''', qui extrait des statistiques sur l’exécution des branchements. Pour le reste du chapitre, toutes les techniques utilisent la prédiction dynamique, qu'elles soient locales ou globales. Sa version la plus simple se contente de mémoriser ce qui s'est passé lors de l’exécution précédente du branchement. Si le branchement a été pris, alors on suppose qu'il sera pris la prochaine fois. De même, s'il n'a pas été pris, on suppose qu'il ne le sera pas lors de sa prochaine exécution. Pour cela, chaque entrée du BTB contient un bit qui mémorise la dernière exécution du branchement : 1 s'il a été pris, 0 s'il n'a pas été pris. C'est ce qu'on appelle la '''prédiction de branchements sur 1 bit'''. Au vu de sa simplicité, il ne faut pas s'attendre à des miracles. La méthode fonctionne bien pour les branchements des boucles et les branchements inconditionnels, mais elle n'est pas assez performante. Une forme plus élaborée de prédiction utilise des '''compteurs à saturation'''. Un compteur à saturation est un compteur qui est incrémenté à chaque fois qu'un branchement est pris, décrémenté s'il est non-pris. Le compteur est initialisé à la moitié de sa valeur maximale, ce qui correspond à une probabilité proche de 50%. Les compteurs à saturation sont très utilisés et pas seulement pour de la prédiction locale, les méthodes globales en utilisent aussi. Pour faire la prédiction, l'unité de prédiction regarde le bit de poids fort du compteur. Le branchement est considéré comme pris si ce bit de poids fort est à 1, et non pris s'il vaut 0. En clair : on considère que le branchement est pris si le compteur indique une probabilité supérieure à 50%. [[File:Compteur à saturation.png|centre|vignette|upright=2|Compteur à saturation.]] : Pour la culture générale : le compteur à saturation du Pentium 1 était légèrement bogué, ce qui fait que sa prédiction de branchement était sous-optimale. La plupart des processeurs utilisent un compteur à saturation par branchement, ce qui veut dire un compteur à saturation par entrée dans le BTB. Vu chaque compteur est associé à une entrée du BTB, le phénomène d’''aliasing'' mentionné plus haut peut perturber les prédictions de branchement. Deux branchements peuvent se voir associés à la même entrée du BTB, et agir sur le même compteur à saturation, ce qui entraine l'apparition d''''interférences entre branchements'''. L'interférence peut avoir des effets positifs, neutres ou négatifs, suivant la corrélation entre les deux branchements. L’''aliasing'' n'est pas un problème si les deux branchements sont fortement corrélés, par exemple si les deux sont tout le temps pris ou tout le temps non-pris. Si les deux branchements vont dans le même sens, l'interférence est neutre, voire légèrement positive. Mais si l'un est souvent pris et l’autre souvent non-pris, l'interférence est négative. Les deux branchements vont se marcher dessus sans vergogne, chacun interférant sur les prédictions de l'autre ! Il est rare que l’''aliasing'' ait un impact significatif sur les unités de prédiction des deux paragraphes précédents. Il se manifeste surtout quand la BTB est très petite et la seule solution est d'augmenter la taille du BTB. ===La prédiction avec un historique local=== La prédiction avec des compteurs à saturation est très simple, mais il est possible de créer une implémentation alternative, très proche, avec des avantages et désavantages différents. Un compteur à saturation enregistre l'historique d'un branchement, à savoir ses dernières exécutions. L''''historique''' d'un branchement dit quel a été son résultat dans le passé, pour ses exécutions précédentes. Et il est possible d'enregistrer cet historique et l'utiliser pour faire des prédictions. Par exemple, prenons un branchement sont ses 6 dernières exécutions sont "pris, non-pris, pris, pris, pris, non-pris". Le processeur peut encoder cet historique sur 6 bits. Un branchement pris correspond à un bit à 1, un branchement non-pris correspond à un 0. Le bit de poids fort correspond à l'exécution la plus ancienne, le bit de poids faible à l'exécution la plus récente. Par exemple, si l'historique contient 010, cela veut dire que le branchement a été non pris, puis pris, puis non pris. L'historique d'un branchement est appelé son '''historique local''', mais nous parlerons simplement l'historique. Le processeur peut mémoriser l'historique de chaque branchement. Pour cela, il réserve n bits par branchements, pour mémoriser ses n exécutions précédentes. L'historique d'un branchement est mémorisé dans son entrée du BTB. Et plus précisément dans un champ séparé qui est alimentée comme un registre à décalage. Reste à exploiter cet historique local pour faire des prédictions. Une idée assez simple serait de l'utiliser pour calculer une moyenne des N exécutions précédentes. Si la probabilité d'être pris est supérieure à 50 %, le branchement est considéré comme pris. Dans le cas contraire, il est considéré comme non pris. Le calcul de la moyenne utilise une simple porte à majorité, qui prend en entrée tous les bits de l'historique. Cependant, cette méthode est équivalente à utiliser un compteur à saturation par branchement. La sortie de la porte à majorité est équivalente à ce qu'on aurait dans un compteur à saturation. La seule différence : le cout en circuit et la simplicité de conception. Prenons l'exemple des premiers processeurs PA RISC. Ils mémorisaient, pour chaque branchement, les trois dernières exécutions du branchement, avec trois bits par entrée du BTB. La prédiction était réalisée avec une porte à majorité, dont le résultat était utilisé comme prédiction. Ces processeurs ont préféré utiliser un historique de 3 bits, car il était plus simple à concevoir qu'un compteur à saturation équivalent de deux bits. Il suffisait d'alimenter un registre à décalage avec un bit, pas besoin d'incrémenter ou de décrémenter un compteur, pas besoin de signaux d'incrémentation/décrémentation séparés, etc. Mais pour des historiques plus longs, la donne s'inverse et des compteurs à saturation de deux-trois bits l’emportent. Un défaut de la méthode précédente, qui est partagée avec les compteurs à saturation, est qu'elle marche mal pour les branchements qui suivent un ''pattern''. Je veux dire par là que l'exécution d'un branchement suit un cycle, par exemple pris, pris, non-pris, pris, pris, non-pris, et ainsi de suite en boucle. Notons que l'apparition de tels motifs correspond le plus souvent à la présence de boucles dans le programme. Quand une boucle s’exécute, les branchements qui sont à l'intérieur tendent à exprimer de tels motifs. Avec de tels motifs, les compteurs à saturation feront des prédictions incorrectes, là où les prédicteurs qu'on va voir font des prédictions parfaites. Par exemple, prenons un branchement qui suit le ''pattern'' pris, pris, pris, non-pris. Si l'historique local vaut 1110, le branchement est censé refaire une boucle et être pris. Et certes, on a supposé que l'historique faisait 4 bits. Mais le''pattern'' sera visible tant que l'historique fait plus de 4 bits. Par exemple, le ''pattern'' serait 01110 pour un historique local de 5 bits, 101110 pour un historique local de 6 bits, 1101110 pour un historique local de 7 bits, etc. Un ''pattern'' correspond donc à une valeur bien précise de l'historique local. L'historique local permet de voir ces cycles, reste à les exploiter. Prédire correctement les ''pattern'' demande simplement d'associer un compteur à saturation à chaque valeur possible de l'historique local. Le compteur à saturation dira, quand tel pattern est rencontré, si le branchement sera pris ou non-pris par la suite. Au total, il faut donc un registre d'historique local de n bits couplé à 2^n compteurs à saturation. Et ce pour chaque branchement possible. Les compteurs à saturation sont regroupés dans une '''''Pattern History Table''''', que nous noterons PHT dans ce qui suit. Le résultat est une '''unité de prédiction à deux niveaux locale''', qui contient plusieurs registres d'historique et tout autant de PHT locales. En tout, il y a une PHT et un historique par branchement, donc par entrée du BTB. Quand un branchement est exécuté, son historique local indique quel compteur à saturation choisir dans la PHT locale. En général, l'historique local est mémorisé dans le BTB, reste à gérer la PHT. [[File:Unité de prédiction à deux niveaux purement locale.png|centre|vignette|upright=2.5|Unité de prédiction à deux niveaux avec des historiques et des PHT locales.]] Une unité de prédiction locale à deux niveaux contient plusieurs PHT, reste à savoir quelle PHT correspond à tel branchement. La correspondance se fait idéalement à partir de l'adresse du branchement, mais c'est là une implémentation idéale, qui demande beaucoup de circuits. Généralement, l'unité de prédiction n'utilise que les bits de poids faible de l'adresse du branchement, ce qui permet de faire un choix correct, mais avec une possibilité d'''aliasing''. Il est aussi possible de séparer l'historique local du BTB. Les historiques locaux sont alors mémorisés dans une mémoire RAM dédiée, appelé la '''''Branch History Table''''', ou encore la '''table des historiques locaux''', que nous noterons BHT dans ce qui suivra. Chaque byte de la BHT contient l'historique local associé à un branchement. Pour récupérer l'historique local voulu, l'adresse du branchement est envoyée à la BHT, sur les entrées d'adresse de la BHT, directement. Seuls les bits de poids faible de l'adresse sont utilisés. L'historique local est ensuite envoyé à la PHT adéquate, choisie en fonction des mêmes bits de poids faible du PC. Un bon moyen pour cela est d'accéder à toutes les PHT en parallèle, mais de sélectionner la bonne avec un multiplexeur, ce dernier étant commandé par les bits de poids faible du PC. [[File:Table des historiques locaux.jpg|centre|vignette|upright=2|Table des historiques locaux]] Un défaut des unités de prédiction purement locales de ce type est leur cout en termes de circuits. Utiliser un registre d'historique et une PHT par branchement a un cout en transistor élevé, avec tous les défauts que ca implique en termes de consommation électrique et thermique. Elles ont aussi des temps de calcul un peu plus important que les unités purement globales, mais pas de manière flagrante. Les gains en performance avec un historique local s'observent surtout pour les branchements qui sont dans des boucles ou pour les branchements utilisés pour concevoir des boucles, alors que l'implémentation globale marche bien pour les if..else (surtout quand ils sont imbriqués). Les deux sont donc complémentaires. Dans les faits, elles sont rarement utilisées seules du fait de leurs défauts, au profit d'unités de prédiction hybrides, qui mélangent historiques et PHT locaux et globaux. ==La prédiction de branchement globale/mixte== Utiliser un historique local avec une PHT est très puissant. Mais le cout en circuit est très important. Cependant, on peut adapter l'idée pour faire de la prédiction globale. La '''prédiction globale''' fait des prédictions holistiques, qui tiennent compte de tous les branchements, sans les traiter séparément. Elle ne sépare pas les branchements, tous sont mis dans le même panier. La prédiction globale fait donc le compromis inverse : elle exploite les corrélations entre branchements, mais perd les informations statistiques précise pour chaque branchement. L'idée est qu'au lieu d'utiliser un historique par branchement, on utilise un '''historique global''' qui mémorise les exécutions de tous les branchements conditionnels. L'historique enregistre les N exécutions de branchements conditionnels. L'historique global est mémorisé dans un registre unique, appelé le '''registre d'historique global'''. C'est un registre à décalage mis à jour à chaque fois que le processeur exécute un branchement : on fait rentrer un 1 si le branchement est pris, et un 0 sinon. Une unité de prédiction globale utilise cet historique pour faire sa prédiction. L'unité de prédiction globale utilise un compteur à saturation pour chaque valeur possible du registre d'historique. En clair, pour un registre de N bits, elle utilise 2^n compteurs à saturation. Là encore, les compteurs à saturation sont regroupés dans une ''Pattern History Table''. Nous parlerons de '''PHT globale''' pour désigner une PHT commandée par un historique global, de ''PHT locale'' si elle est adressée par un historique local. [[File:Prédiction dynamique à deux niveaux.png|centre|vignette|upright=2|Prédiction dynamique à deux niveaux.]] Il est admis que l'historique global donne de meilleures prédictions que l'historique local. Mais les deux sont complémentaires, ce qui fait qu'il existe des approches hybrides. Les approches hybrides exploitent à la fois les statistiques pour chaque branchement et les corrélations entre branchements. Les prédicteurs hybrides ont de meilleures performances que les prédicteurs purement globaux ou locaux. Avec un historique global, la prédiction tient compte d'éventuelles corrélations entre branchements consécutifs/proches pour faire les prédictions. C'est un avantage pour les applications qui enchaînent des if...else ou qui contiennent beaucoup de if...else imbriqués les uns dans les autres, qui s’exécutent plusieurs fois de suite. Le résultat de chaque if...else dépend généralement des précédents, ce qui rend l'utilisation d'un historique global intéressant. Par contre, ils sont assez mauvais pour le reste des branchements. Mais cette qualité devient un défaut quand elle détecte des corrélations fortuites ou parasites, inutiles pour la prédiction (corrélation n'est pas causalité, même pour prédire des branchements). Du fait de ce défaut, la prédiction globale a besoin de registres d'historiques globaux très larges, de plusieurs centaines de bits au mieux. [[File:2 level branch predictor.svg|centre|vignette|upright=2|Unités de prédiction de branchement à deux niveaux avec une seule PHT.]] Le circuit précédent a cependant un problème : si deux branchements différents s'exécutent et que l'historique est le même, le processeur n'y verra que du feu. Il y a alors une interférence, à savoir que les deux branchements vont agir sur le même compteur à saturation. On se retrouve alors dans une situation conceptuellement identique à l’''aliasing'' dans le BTB ou avec des compteurs à saturation. Sauf que les interférences sont alors beaucoup plus nombreuses et qu'elles sont clairement un problème pour les performances ! Mitiger les interférences devient alors bien plus important qu'avec les unités de prédiction précédentes. Heureusement, si augmenter la taille de la PHT peut mitiger le problème, d'autres solutions sont possibles sur les unités de prédiction globales. Elles sont au nombre de deux : mitiger les interférences liées aux branchements biaisés, ajouter des informations qui discriminent les branchements. Voyons ces solutions dans le détail. ===La mitigation des interférences par usage de l'adresse de branchement=== La première solution pour réduire l'impact de l’''aliasing'' est d'augmenter la taille de la PHT et de l'historique. Plus l'historique et la PHT sont grands, moins l’''aliasing'' est fréquent. Mais avoir des historiques et des PHT de très grande taille n'est pas une mince affaire et le cout en termes de performances et de portes logiques est souvent trop lourd. Une autre solution consiste à utiliser, en plus de l'historique, des informations sur le branchement lui-même pour choisir le compteur à saturation à utiliser. Typiquement, on peut utiliser l'adresse du branchement en plus de l'historique, pour choisir le compteur à saturation adéquat. Pour être précis, on n'utilise que rarement l'adresse complète du branchement, mais seulement les bits de poids faible. La raison est que cela fait moins de bits à utiliser, donc moins de circuits, mais l’''aliasing'' est juste atténué, pas éliminé. Des branchements différents ont beau avoir des adresses différentes, les bits de poids faible de leurs adresses peuvent être identiques. Des confusions sont donc possibles, mais sont assez rares pour que cela suffise en pratique. Une unité de prédiction de ce type est illustrée ci-dessous. Il y a plusieurs PHT séparées, donc plusieurs ensembles de 2^n compteurs à saturation. Le choix de la PHT est réalisé par l'adresse du branchement, alors que le choix du compteur à saturation est réalisé par l'historique. Les compteurs à saturation d'un branchement sont mis à jour seulement quand le branchement s'est chargé pendant que l'historique associé était dans le registre d'historique. Cette méthode a cependant le défaut de gâcher énormément de transistors. [[File:Unité de prédiction de branchement basée sur des compteurs à saturation sélectionnés par l'historique global.jpg|centre|vignette|upright=2|Unité de prédiction de branchement basée sur des compteurs à saturation sélectionnés par l'historique global]] D'autres unités de prédiction fonctionnent sur le même principe que l'unité précédente, sauf que les différentes PHTs sont fusionnées en une seule grosse PHT. C'est ce que font les unités de prédiction '''''gshare''''' et '''''gselect''''', ainsi que leurs dérivées. Avec les unités de prédiction '''''gselect''''', on concatène les bits de poids faible de l'adresse du branchement et l'historique pour obtenir le numéro du compteur à saturation à utiliser. [[File:Prédiction « gselect ».png|centre|vignette|upright=2|Prédiction « gselect ».]] Avec les unités de prédiction '''''gshare''''', on fait un XOR entre le registre d'historique et les bits de poids faible de l'adresse du branchement. Le résultat de ce XOR donne le numéro du compteur à utiliser. Avec cette technique, on utilise une PHT globale et un historique global, mais le registre d'historique est manipulé de manière à limiter l'''aliasing''. [[File:Prédiction « gshare ».png|centre|vignette|upright=2|Prédiction « gshare ».]] Intuitivement, on pourrait croire que les unités de prédiction gselect ont de meilleures performances. C'est vrai que les unités gshare combinent l'adresse du branchement et l'historique d'une manière qui fait perdre un peu d'information (impossible de retrouver l'adresse du branchement et l'historique à partir du résultat du XOR), alors que les unités gselect conservent ces informations. Mais il faut prendre en compte le fait que les deux unités doivent se comparer à PHT identique, de même taille. Prenons par exemple une PHT de 256 compteurs, adressée par 8 bits. Avec une unité gselect, les 8 bits sont utilisés à la fois pour l'historique et pour les bits de poids faible de l'adresse du branchement. Alors qu'avec une unité gshare, on peut utiliser un historique de 8 bits et les 8 bits de poids faible de l'adresse du branchement. L'augmentation de la taille de l'historique et du nombre de bits utilisés fait que l’''aliasing'' est réduit, et cela compense le fait que l'on fait un XOR au lieu d'une concaténation. Les unités précédentes réduisent l'''aliasing'' en segmentant la PHT : telles entrées sont allouées à un branchement, telles entrées à tel autre branchement, etc. Cependant, l'historique local n'est pas utilisé. Mais quitte à allouer des entrées à un branchement, autant en profiter pour leur associer son historique local. Il s'agit alors d'approches hybrides. Par exemple, on peut citer la '''prédiction mixte''' (''alloyed predictor''). Avec celle-ci, la table des compteurs à saturation est adressée avec la concaténation : de bits de l'adresse du branchement, du registre d'historique global et du registre d'historique local adressé par l'adresse du branchement. [[File:Prédiction mixte.jpg|centre|vignette|upright=2|Prédiction mixte.]] Une version optimisée de ce circuit permet d’accéder à la PHT et à la BHT en parallèle. Avec elle les bits de poids faible de l'adresse du branchement sont concaténés avec l'historique global. Le résultat est ensuite envoyé à plusieurs PHT locales distinctes, en parallèle. Les différences PHT envoient leurs résultats à un multiplexeur, commandé par l'historique local, qui sélectionne le résultat adéquat. L'inconvénient de cette mise en œuvre est que le circuit est assez gros, notamment en raison de la présence de plusieurs PHT séparées. [[File:Alloyed predictor optimisé.jpg|centre|vignette|upright=2|''Alloyed predictor'' optimisé]] ===La mitigation des interférences par la structuration en caches des PHTs=== Les techniques précédentes utilisent les bits de poids faible de l'adresse pour sélectionner la PHT adéquate, ou pour sélectionner le compteur dans la PHT. Mais le reste de l'adresse n'est pas utilisé. Cependant, il est théoriquement possible de conserver les bits de poids fort, afin d'identifier le branchement associé à un compteur. Pour cela, chaque compteur à saturation se voit associé à un ''tag'', qui mémorise le reste de l'adresse du branchement associé au compteur, comme pour les mémoires caches. Lorsque l'unité de prédiction démarre une prédiction, elle récupère les bits de poids fort de l'adresse du branchement et compare ceux-ci avec le ''tag''. S’il y a un ''match'', c'est signe que le compteur est bien associé au branchement à prédire. Dans le cas contraire, c'est signe qu'on fait face à une situation d’''aliasing'' et le compteur n'est pas utilisé pour la prédiction. Le résultat transforme la PHT en une sorte de cache assez particulier, qui associe un compteur à saturation à chaque couple adresse-historique. La technique du paragraphe précédent peut être encore améliorée afin de réduire l’''aliasing''. L’''aliasing'' sur une unité de prédiction de branchement est similaire aux conflits d'accès au cache, où deux données/adresses atterrissent dans la même ligne de cache. Une PHT peut formellement être vue comme un cache directement adressé. Une solution pour limiter ces conflits d'accès à de tels, est de les transformer en un cache associatif à n voies. On peut faire la même chose avec les unités de prédiction de branchement, ce qui revient à dupliquer les PHT, de manière à ce que les bits de poids faible de l'adresse se voient attribuer à plusieurs compteurs à saturation, un par branchement possible. Ce faisant, on réduit les interférences entre branchements. Mais cette technique augmente la quantité de circuits utilisés et la consommation d'énergie, ainsi que le temps d'accès, sans compter qu'elle requiert d'utilisation de ''tags'' pour fonctionner à la perfection. Pour les unités de prédiction de branchement, les mesures à ce sujet ne semblent pas montrer un impact réellement important sur les performances, comparé à une organisation de type directement adressé. Il est possible de pousser la logique encore plus loin en ajoutant des caches de victime et d'autres mécanismes courant sur les caches usuels. ===La mitigation des interférences liées aux branchements biaisés=== L’''aliasing'' a un impact négatif quand deux branchements aliasés (qui sont attribués au même compteur à saturation) vont dans des sens différents : si l'un est non-pris, l'autre a de bonnes chances d'être pris (ou inversement). Or, la plupart des branchements sont biaisés, à savoir très fortement orientés dans une direction qu'une autre (presque toujours pris ou presque toujours non-pris, à plus de 90/99% de chances). Plus un branchement a une probabilité élevée d' être pris ou non-pris, plus sa capacité à interférer avec les autres est forte. Le pire des cas est quand deux branchements sont biaisés mais dans des directions inverses : l'interférence est maximale. En conséquence, les branchements biaisés ou quasi biaisés sont les plus problématiques pour l’''aliasing''. Or, il est possible de concevoir des prédicteurs de branchements qui atténuent fortement les interférences des branchements biaisés ou quasi biaisés. C’est le cas de l’''agree predictor'' et de l'unité de prédiction bimodale, que nous allons voir dans ce qui suit. Les branchements très souvent pris vont interférer très fortement avec les branchements très souvent non-pris, et inversement. Par contre, les branchements très souvent pris n'interféreront pas entre eux, de même que les branchements non-pris iront bien ensemble. D'où l'idée de séparer ces deux types de branchements dans des PHT séparées, pour éviter le mauvais ''aliasing''. L''''unité de prédiction bimodale''' part d'une unité gshare, et sépare la PHT globale en deux : une PHT dédiée aux branchements qui sont très souvent pris et une autre pour les branchements très peu pris. Les deux PHT fournissent chacune une prédiction. La bonne prédiction est choisie par un multiplexeur commandé par une unité de sélection, qui se charge de déduire quelle unité a raison. Cette unité de sélection est une unité de prédiction basée sur des compteurs à saturation de 2 bits. On peut encore améliorer l'unité de sélection de prédiction en n'utilisant non pas des compteurs à saturation, mais une unité de prédiction dynamique à deux niveaux, ou toute autre unité vue auparavant. [[File:Prédiction bimodale.jpg|centre|vignette|upright=2|Prédiction bimodale.]] La '''prédiction par consensus''' (''agree predictor'') combine une unité de prédiction à historique global (idéalement de type gshare ou gselect, mais une unité normale marche aussi) et des compteurs à saturation. Il s'agit techniquement d'une technique de prédiction mixte, à la fois globale et locale. Le BTB intègre un compteur à saturation de un bit, qui indique la direction privilégiée du branchement. L'unité à historique global a un fonctionnement changé : elle calcule non pas la probabilité que le branchement soit pris ou non-pris, mais la probabilité que le résultat du branchement soit compatible avec le résultat de l'unité de prédiction de 1 bit. La prédiction finale vérifie que ces deux circuits sont d'accord, en faisant un NXOR entre les résultats des deux unités de prédiction de branchement. [[File:Prédiction par consensus.png|centre|vignette|upright=2|Prédiction par consensus.]] Des calculs simples de probabilités montrent que l'''agree predictor'' a des résultats assez importants. Ses résultats sont d'autant meilleurs que les branchements sont biaisés et penchent fortement vers le côté pris ou non-pris. ==La prédiction de branchement avec des perceptrons== Les méthodes précédentes utilisent au moins un compteur à saturation par historique possible. Sachant qu'il y a 2^n valeurs possibles pour un historique de n bits, le nombre de compteurs à saturation augmente exponentiellement avec la taille de l'historique. Ce qui limite grandement la taille de l'historique, alors qu'on aurait au contraire besoin d'un historique assez large pour faire des prédictions excellentes. Pour contourner le problème, des alternatives basées sur des techniques d'apprentissage artificiel (''machine learning'') existent. Un exemple est celui des unités de prédiction de branchement dites de ''neural branch prediction'', basée sur des perceptrons. Dans ce qui suit, nous les appelerons des '''unités perceptron'''. Les perceptrons sont un algorithme d'apprentissage automatique très simple, ce qui fait que les explications qui vont suivre devraient être simples. En théorie, il est possible d'utiliser des techniques d'apprentissage automatique plus élaborées, mais cela n'est pas possible en pratique. Rappelons qu'une unité de prédiction de branchement doit idéalement fournir sa prédiction en un cycle d'horloge. L'implémentation d'un perceptron est déjà problématique de ce point de vue, car elle demande d'utiliser beaucoup de circuits arithmétiques complexes (multiplication et addition). Autant dire que les autres techniques de ''machine learning'', encore plus gourmandes en calculs, ne sont pas praticables. ===Les perceptrons et leur usage en tant qu'unité de prédiction de branchements=== L'idée derrière les unités perceptron est que certains bits de l'historique sont plus importants que d'autres. En soi, ce genre de situation est réaliste. Il arrive que certains branchements soient pris uniquement si les deux branchements précédents ne le sont pas, ou que si l'avant-dernier branchement est lui aussi pris. Mais les autres résultats de branchement présents dans l'historique ne sont pas utiles. En conséquence, deux historiques semblables, qui ne sont différents que d'un ou deux bits, peuvent donner des prédictions presque identiques. Dans ce cas, on peut faire la prédiction en n'utilisant que les bits identiques entre ces historiques, ou du moins en leur donnant plus d'importance qu'aux autres. On exploite alors des corrélations avec certains bits de l'historique, plutôt qu'avec l'historique entier lui-même. Pour coder ces corrélations, on associe un coefficient à chaque bit de l'historique, qui indique à quel point ce bit est important pour déterminer le résultat final. Ce coefficient est proportionnel à la corrélation entre le bit de l'historique associé, et le résultat du branchement à prédire. Les coefficients sont des nombres entiers qui peuvent être positifs, nuls ou négatifs. Un coefficient positif signifie que si le branchement associé a été pris, alors le branchement à prédire a de bonnes chances de l'être aussi (et inversement). Un coefficient nul signifie que les deux branchements sont indépendants et que le bit de l'historique associé n'est pas à prendre en compte. Pour un coefficient négatif, cela signifie que si le branchement associé a été pris, alors le branchement à prédire a de bonnes chances d'être non-pris (et inversement). Les coefficients en question sont généralement compris entre -1 et 1. Une fois qu'on connait ces coefficients de corrélations, on peut calculer la probabilité que le branchement à prédire soit pris, avec des calculs arithmétiques simples. Dans les calculs qui vont suivre, un branchement pris est codé par un 1, alors qu'un branchement non-pris est codé par un -1 ! Ce détail permet de simplifier grandement les calculs. Dans sa version la plus simple, le perceptron calcule un nombre Z qui est positif ou nul si le branchement est pris, négatif s'il ne l'est pas. Tous les coefficients <math>p_i</math> sont alors des nombres relatifs, pouvant être positifs, nuls ou négatifs. Ils sont généralement compris entre -1 et 1. La formule devient donc celle-ci : : <math>Z = w_0 + \sum_i (h_i \times w_i)</math> [[File:ArtificialNeuronModel francais.png|centre|vignette|upright=2|Perceptron.]] : Sur le plan mathématique, le perceptron effectue un produit scalaire entre deux vecteurs : l'historique et le vecteur des poids. Choisir les coefficients adéquats est le but de l'algorithme du perceptron. L'unité de prédiction part de poids par défaut, qui sont mis à jour à chaque bonne ou mauvaise prédiction. Toute la magie de cet algorithme tient dans la manière dont sont mis à jour les poids. L'idée est de prendre le vecteur des poids, puis d'ajouter ou soustraire l'historique suivant le résultat du branchement. Les poids évoluent ainsi à chaque branchement, améliorant leurs prédictions d'une exécution à l'autre. La règle de mise à jour des poids est donc la suivante : : <math>w_i \leftarrow w_i + t \times h_i</math>, avec t = 1 pour un branchement pris, -1 pour un branchement non-pris. ===L'implémentation matérielle des perceptrons et ses optimisations=== Les calculs réalisés par un perceptrons sont simples, juste des multiplications et des additions et cela se sent dans la conception du circuit. L'unité perceptron utilise un perceptron par entrée du BTB, un par branchement pour simplifier. Pour cela, on utilise une mémoire SRAM qui mémorise les poids de chaque perceptron. La SRAM est adressée par les bits de poids faible du ''program counter'', de l'adresse du branchement. Une fois les poids récupérés, ils sont envoyés à un circuit de calcul de la prédiction, composé de circuits multiplieurs suivis par un additionneur multi-opérande. Le circuit de mise à jour des poids récupère la prédiction, les poids du perceptron, mais aussi le résultat du branchement. La mise à jour étant une simple addition, le circuit est composé d'additionneurs/soustracteurs, couplés à des registres pour mémoriser la prédiction et les poids du perceptron en attendant que le résultat du branchement soit disponible. Vu qu'il y a un délai de quelques cycles avant que le résultat du branchement soit disponible et que les prédictions doivent continuer pendant ce temps, les poids du perceptron et la prédiction sont stockés dans des FIFOs lues/écrites à chaque cycle. [[File:Unité de prédiction de branchement basée sur des perceptrons.png|centre|vignette|upright=2|Unité de prédiction de branchement basée sur des perceptrons]] Rien de bien compliqué sur le principe, mais rappelons que le perceptron doit fournir un résultat en moins d'un cycle d'horloge, et cela tient compte de l'accès au BTB. C'est un temps très court, surtout pour un circuit qui implique des multiplications. Les multiplications prennent facilement deux à trois cycles d'horloge. Autant dire que si on ajoute le temps d'accès à une petite SRAM, pour récupérer le perceptron, c'est presque impossible. Mais quelques optimisations simples permettent de rendre le calcul plus rapide. Premièrement, la taille de la SRAM est réduite grâce à quelques économies assez simples. Notamment, le perceptron utilise des poids codés sur quelques bits, généralement un octet, parfois 7 ou 5 bits, guère plus. Les poids sont encodés en complément à 1 ou en complément à 2, là où les perceptrons normaux utilisent des nombres flottants. Rien que ces deux choix simplifient les circuits et les rendent plus rapides. Le désavantage d'utiliser peu de bits par poids est une perte mineure en termes de taux de prédiction, qui est plus que compensée par l'économie en portes logiques, qui permet d'augmenter la taille de la SRAM. D'autres optimisations portent sur le circuit de calcul. Par exemple, la multiplication <math>h_i \times w_i</math> est facultative. Vu que l'historique contient les valeurs 1 et -1, il suffit d'additionner le poids associé si la valeur est 1 et de le soustraire s'il vaut -1. On peut même simplifier la soustraction et se limiter à une complémentation à un (une inversion des bits du poids). En faisant cela, les circuits multiplieurs disparaissent et le circuit de prédiction se résume à un simple additionneur multi-opérande couplé à quelques inverseurs commandables. Certains perceptrons prennent en compte à la fois l'historique global et l'historique local pour faire leur prédiction. Les deux historiques sont simplement concaténés et envoyés en entrée du perceptron. Cela marche assez bien, car les perceptrons peuvent utiliser des historiques de grande taille sans problèmes. Par contre, il y a besoin d'ajouter une ''branch history table'' au circuit, ce qui demande des portes logiques en plus, mais aussi un temps de calcul supplémentaire (l'accès à cette table n'est pas gratuit). ===Les avantages et inconvénients des perceptrons pour la prédiction de branchements=== L'avantage des unités perceptron est qu'elles utilisent un nombre de portes logiques qui est proportionnel à la taille de l'historique, et non pas exponentiel. Cela permet d'utiliser un historique de grande taille, et donc d'obtenir un taux de bonnes prédictions très élevé. Leur désavantage est que leurs performances de prédiction sont moins bonnes à historique équivalent. Les unités perceptrons ont des taux de prédiction élevés, mais seulement quand certaines conditions mathématiques sont respectées par les historiques. La condition en question est que les historiques correspondants à un branchement pris et les historiques où ce même branchement est non-pris doivent être linéairement séparables. Heureusement, les branchements des programmes informatiques sont une majorité à respecter la condition de séparation linéaire, ce qui rend les perceptrons parfaitement adaptés à la prédiction de branchements. La notion de linéairement séparable est un peu difficile à comprendre. Pour l'expliquer, imaginez que les N bits de l'historique forme un espace à N-dimensions discrets. Chaque historique est un point dans cet espace N-dimensionnel et chaque bit sert de coordonnée pour se repérer dans cet espace. Une fonction est linéairement séparable si l'espace N-dimensionnel peut être coupé en deux par un hyperplan : d'un côté de ce plan se trouvent les historiques des branchements pris, de l'autre se trouvent les historiques des branchements non-pris. Cet hyperplan est défini par l'ensemble des points qui respecte l'équation suivante : : <math>w_0 + \sum_i (h_i \times w_i) = 0</math> Un exemple où cette condition est respectée est quand le résultat d'une prédiction se calcule avec un ET entre plusieurs branchements de l'historique. Si un branchement est pris quand plusieurs branchements de l’historique sont tous pris ou tous non-pris, la condition est respectée et le perceptron donnera des prédictions correctes. Un exemple où cette condition n'est pas respectée est un banal XOR. Prenons l'exemple d'un historique global de 2 bits. Imaginons que le branchement à prédire soit pris : soit quand le premier branchement de l'historique est pris et le second non-pris, soit quand le premier est non-pris et le second pris. Concrètement, la prédiction exacte se calcule en faisant un XOR entre les deux bits de l'historique. Dans ce cas, la condition "linéairement séparable" n'est pas respectée et le perceptron n'aura pas des performances optimales. Les unités perceptron utilisent idéalement un perceptron par branchement. Cela demande d'associer, l'adresse du branchement pour chaque perceptron, généralement en donnant un perceptron par entrée du BTB. Mais une telle organisation n'est pas possible en pratique, car elle demanderait d'ajouter un ''tag'' à chaque perceptron/entrée du BTB, ce qui demanderait beaucoup de circuits et de ressources matérielles. Dans les faits l'association entre un branchement et un perceptron se fait en utilisant les bits de poids faible de l'adresse de branchement. Mais faire cela entrainera l'apparition de phénomènes d'''aliasing'', comme pour les unités de prédiction à deux niveaux. Les techniques vues précédemment peuvent en théorie être adaptées sur les unités à perceptrons, dans une certaine mesure. Notons que les perceptrons sont des algorithmes ou des circuits qui permettent de classer des données d'entrée en deux types. Ici, la donnée d'entrée est l'historique global et la sortie du perceptron indique si le branchement prédit est pris ou non-pris. Ils ne sont donc pas adaptés à d'autres tâches de prédiction, comme pour le préchargement des lignes de cache ou la prédiction de l'adresse de destination d'un branchement, ou toute autre tâche d'exécution spéculative un tant soit peu complexe. Leur utilisation reste cantonnée à la prédiction de branchement proprement dit, guère plus. Par contre, les autres techniques vues plus haut peuvent être utilisées pour le préchargement des lignes de cache, ou d'autres mécanismes de prédiction plus complexes, que nous aborderons dans la suite du cours. ==La prédiction avec plusieurs unités de branchement distinctes== Il est possible de combiner plusieurs unités de prédiction de branchement différentes et de combiner leurs résultats en une seule prédiction. On peut par exemple combiner une unité de prédiction spécialisée dans les boucles, une unité de prédiction 2 bits basée sur des compteurs à saturation, une unité de prédiction statique et une unité ''gshare''. Tout le problème est de décider quelle unité a fait la bonne prédiction, comment combiner les résultats des différentes unités de prédiction. Soit on choisit un résultat qui parait plus fiable que les autres, soit on combine les résultats pour faire une sorte de moyenne des prédictions. La méthode la plus simple de choisir le résultat d'une unité de prédiction parmi toutes les autres. Pour implémenter le tout, il suffit d'un multiplexeur qui effectue le choix de la prédiction. Reste à commander le multiplexeur, ce qui demande un '''circuit méta-prédicteur''' qui sélectionne la bonne unité de prédiction. Il arrive que le circuit méta-prédicteur soit conçu avec les mêmes techniques que les unités de prédiction de branchement elle-mêmes. Dans le cas le plus simple, il s'agit d'un simple compteur à saturation, mis à jour suivant la concordance des prédictions avec le résultat effectif du branchement. Mais c'est loin d'être systématique. Une autre solution combine le résultat des différentes unités de prédiction avec une fonction mathématique adéquate. Avec un nombre impair d'unités de prédiction, une méthode assez efficace est de simplement prendre le résultat majoritaire. Si une majorité d'unités pense que le branchement est pris, alors on le considère comme pris, et comme non pris dans le cas contraire. Par contre, avec un nombre pair d'unités, cette méthode simple ne marche pas, il y a un risque d'égalité. L'unité de prédiction de branchement « '''''e-gskew''''' » utilise trois unités gshare et combine leurs résultats avec un vote à majorité. Les trois unités gshare utilisent des XOR légèrement différents, dans le sens où les bits de l'adresse du branchement choisi ne sont pas les mêmes dans les trois unités, sans compter que le résultat du XOR peut subir une modification qui dépend de l'unité de prédiction choisie. Autrement dit, la fonction de hachage qui associe une entrée à un branchement dépend de l'unité. [[File:Unité de prédiction « e-gskew ».png|centre|vignette|upright=2|Unité de prédiction « e-gskew ».]] Enfin, une dernière solution est que certaines unités de prédiction ont la priorité sur toutes les autres. Par exemple, prenons un processeur avec un ''return stack predictor'', une unité qui détecte les boucles et une unité de prédiction avec un historique. Si le ''return stack predictor'' détecte une instruction de retour de fonction, sa prédiction passera avant toutes les autres unités de prédiction. Et dans ce qui va suivre, c'est cette solution qui sera utilisé pour les unités de prédiction qu'on va étudier. ===La prédiction des branchements des boucles=== Les boucles sont à part, pour ce qui est de la prédiction de branchement. Rappelons qu'une boucle FOR/WHILE répète une série d'instruction N fois, le nombre N étant appelé le '''compteur de boucle'''. Ce qui fait que les branchements d'une boucle FOR sont pris n − 1 fois, la dernière exécution étant non prise. La conséquence est que les unités de prédiction usuelles sont assez mauvaises pour prédire les branchements des boucles. Par contre, la prédiction statique marche particulièrement bien comparé au reste. La prédiction statique prédit que les branchements des boucles sont toujours pris, ce qui donne des prédictions qui sont d'autant meilleures que la boucle est répétée un grand nombre de fois. Reste à gérer les branchements des boucles au mieux, en sachant cela. L'idée est d'utiliser plusieurs unités de prédiction séparées : une spécialisée dans les boucles et d'autres unités de prédiction. Uen unité de méta-prédiction se charge d'activer l'unité de prédiction des boucles dès qu'une boucle est détéctée. Les unités de prédiction modernes essayent de détecter les branchements des boucles afin de les mettre à part. En soi, ce n'est pas compliqué les branchements des boucles sont presque toujours des branchements descendants et réciproquement. De plus, ces branchements sont pris à chaque répétition de la boucle, et ne sont non-pris que quand on quitte la boucle. Le compteur de boucle analyse l'exécution des branchements, pour détecter les branchements des boucles. Un branchement est considéré comme un branchement de boucle si : il est pris N fois, avant de ne pas être pris une fois. Au moment où le branchement n'est plus pris, le ''Loop Predictor'' lui attribue une entrée dédié. L'entrée mémorise mémorise le nombre de fois que ce branchement a été pris, à savoir N, et est associée à un compteur. Le compteur est incrémenté/décrémenté à chaque exécution du branchement, et sa valeur est comparée à la valeur N. Quand le compteur atteint N+1, il est réinitialisé. Il s'agit d'une implémentation simple, mais est visiblement proche de celle utilisée sur le Pentium M, si j'en crois l'article "The Intel Pentium M Processor : Microarchitecture and Performance". Il faut noter que la boucle doit s'exécuter au moins une fois pour être détectée, et seules les exécutions suivantes sont prédites correctement. La boucle doit donc s'exécuter plusieurs fois, ce qui implique qu'elle est elle-même dans une boucle plus grande qu'elle. Une telle unité est donc capable de prédire spécifiquement les branchements de boucles FOR imbriquées, à savoir où une boucle FOR est dans une autre boucle FOR. Concrètement, cela permet de répéter la première boucle FOR plusieurs fois de suite, souvent un grand nombre de fois. Avec les boucles imbriquées, le compteur de boucle peut changer d'une répétition à l'autre, mais ce n'est que rarement le cas. Dans de nombreux cas, le compteur de boucle reste le même d'une répétition à l'autre. Ce qui fait qu'une fois la boucle exécutée une première fois, on peut prédire à la perfection les exécutions suivantes. Pour cela, il suffit de déterminer le compteur de boucle après la première exécution de la boucle, puis de le mémoriser dans un compteur. Lors des exécutions suivantes de la boucle, on compte le nombre de fois que le branchement de la boucle s'exécute : il est prédit comme pris tant que le compteur est différent du compteur de boucle, mais non-pris en cas d'égalité. ===Le prédicteur L-TAGE=== Il est possible d'utiliser plusieurs unités de prédiction, chacune utilisant des historiques de taille différente. Pour donner un exemple simple, on pourrait avoir une unité avec un historique de 2 bits, une autre avec un historique de 3 bits, une autre de 4 bits, etc. Une méta-unité de branchement prédit alors quelle taille d'historique donne les meilleurs résultats. Il est fréquent que les tailles d'historiques utilisées suivent une formule mathématique précise, appelée suite géométrique, ce qui est un mot bien barbare pour dire que l'on passe d'un historique au suivant en multipliant sa taille par une constante bien précise. Par exemple, on pourrait avoir des historiques de taille 2, 4, 8, 16, 32. C'est d'ailleurs le principe derrière les '''unités de branchement TAGE (''TAgged GEometric length predictor'')'''. Elles utilisent plusieurs registres d'historiques, chacun avec sa propre PHT, dont la taille suit une suite géométrique. Avec cette méthode, les branchements qui ont un motif répétitif sont alors parfaitement détectés, peu importe sa taille. Par exemple, un branchement avec un historique dont le cycle est pris, non-pris, non-pris, sera parfaitement prédit avec un historique de 3 bits, 6 bits, ou de 3*n bits, mais pas forcément avec un historique de 4 ou 8 bits. De ce fait, utiliser plusieurs unités de branchement avec plusieurs tailles d'historique fonctionne plutôt bien. Le prédicteur TAGE a été raffiné avec le temps, sur plusieurs points, que je ne vais pas détailler dans ce qui suit. Précisons cependant que les PHT sont structurées en caches, afin d'éliminer les phénomènes d'''aliasing''. L'ajout d'une unité de prédiction pour les boucles, a donné le '''prédicteur L-TAGE''', qui a lui-même été amélioré par la suite. Les unités de branchement TAGE sont les plus utilisées au moment où j'écris ces lignes (2026), et la majorité des processeurs x86 commerciaux utilisent une variante de ce prédicteur, à quelques exceptions près. Le '''prédicteur ITTAGE''' peut être adapté pour prédire des branchements indirects. La seule différence est que chaque table d'historique est associée non seulement à une prédiction pris/non-pris, mais aussi à une adresse de destination. L'idée derrière l'ITTAGE est que l'adresse de destination dépend de l'historique. L'adresse de destination dépend de ce qui se passe avant qu'on exécute le branchement indirect, donc de l'historique global. Il est donc fréquent que des historiques différents donne un résultat différent, que ce soit pour la prédiction pris/non-pris, mais aussi pour l'adresse de destination. L'ITTAGE associe donc une adresse de destination par ''pattern'' prédit. ===La prédiction de branchement à deux niveaux=== Les processeurs modernes utilisent une prédiction de branchement à deux niveaux. Une première unité de branchement fournit des résultats rapides et peu précis, la seconde a des résultats plus précis mais fournit ses résultats après quelques cycles d'horloge. La première unité envoie les adresses prédites au cache d'instruction, pour charger les instructions prédites et met à jour le ''program counter'' en conséquence. La seconde envoie les adresses prédites à l'étage de décodage d'instruction, histoire d'annuler des branchements considérés à tort comme pris/non-pris. Il y a donc une unité de prédiction rapide, dont le résultat est corrigé par une seconde unité. La seconde unité a le dernier mot, elle corrige ou annule les prédictions de la première. Ce qui lui vaut le nom, en anglais, d'''overiding predictor''. La correction se fait simplement en corrigeant le ''program counter'' et en forçant le décodeur à émettre des NOPs pendant quelques cycles, le temps d'éliminer les instructions chargées à tort. [[File:Double BTB sur les CPU modernes.png|centre|vignette|upright=2|Double BTB sur les CPU modernes.]] Les deux unités de prédiction n'utilisent pas le même algorithme. Par exemple, la microarchitecture Silvermont d'Intel utilisait deux unités de prédiction : une unité rapide basée sur des compteurs à saturation, et un prédicteur ''gshare'' lent. Les deux unités de prédiction utilisent des BTB séparés, de taille différente, au moins deux. Vu que les BTB sont des caches, on les appelle des '''BTB de niveau L1 et L2'''. Par exemple, les processeurs Bulldozer d'AMD disposent de deux BTBs : un BTB L1 de 512 entrées, et un BTB L2 de 5120 entrées. Le BTB de niveau L2 est souvent un cache de victime, qui mémorise les branchements retirés de la BTB de niveau L1. La BTB pour les branchements indirects vient en plus de ces deux BTBs, elle complète souvent la BTB de niveau L2. Le ''return stack predictor'' vient en plus des BTB L1 et L2, mais sa petite taille et sa rapidité font qu'il complète l'unité/BTB de niveau L1. Quelques processeurs ont trois niveaux de BTB, avec un niveau L0, L1 et L2. La BTB L0 aliment le cache d'instruction, alors que la L1 et la L2 préviennent les décodeurs d'instruction. La différence entre la L1 et la L2 est que la L2 met plus de temps à prévenir les décodeurs. Les branchements peuvent donc être annulés/forcés à deux étages de décodage différents. Pour donner quelques exemples, les processeurs Zen d'AMD disposent de plusieurs BTBs, avec des niveaux L0, L1 et L2. Le tableau ci-dessous vous donne la taille et la latence de chaque niveau de BTB. La BTB de niveau L0 alimente le cache d'instruction, la L1 permet d'annuler les instructions au premier cycle de décodage, la BTB L2 annule les instructions vers le milieu/fin du décodage. {|class="wikitable" |- ! rowspan="3" | Zen 2 | 16 || 0 |- | 512 || 1 |- | 7168 || 4 |- ! rowspan="3" | Zen 3 | 16 || 0 |- | 1024 || 1 |- | 6656 + 1536 (branchements indirects)|| 4 |} ==Les optimisations de la prédiction de branchements== La prédiction de branchement est complexe et toute économie est bonne à prendre. Pour améliorer les performances, ou simplement améliorer l'utilisation du BTB et des PHT, diverses techniques ont été inventées. Ces techniques marchent quel que soit l'unité de prédiction prise en compte. Elles peuvent s'appliquer aussi bien aux unités basées sur des perceptrons, que sur des unités à deux niveaux ou des unités de prédiction dynamique en général. Ces techniques sont assez variés : certaines profitent du fait que de nombreux branchements sont biaisés, d'autres tentent de réduire les interférences entre branchements sans utiliser l'historique global/local, etc. ===Le filtrage de branchements=== Une première optimisation permet d'économiser l'usage des différentes ressources matérielles, comme la BTB ou les PHT, en les réservant aux branchements non-biaisés. Idéalement, les branchements biaisés peuvent être prédits en utilisant des techniques très simples. D'où l'idée d'utiliser deux unités de prédiction spécialisées : une pour les branchements biaisés et une autre plus complexe. L'unité de prédiction pour les branchements biaisés est une unité de prédiction à 1 bit qui indique si le branchement est pris ou non, ce qui suffit largement pour de tels branchements. Cette technique s'appelle le '''filtrage de branchement''' et son nom est assez parlant. On filtre les branchements biaisés pour éviter qu'ils utilisent des PHT et d'autres ressources qui gagneraient à être utilisées pour des branchements plus difficiles à prédire. Appliquer cette idée demande de reconnaître les branchements fortement biaisés d'une manière ou d'une autre, ce qui est plus facile à dire qu'à faire. Une solution similaire enregistre quels branchements sont biaisés dans le BTB, et utilise cette information pour faire des prédictions. ===L'usage de fonctions de hashage pour indexer les diverses SRAM/tables=== Plus haut, nous avons vu que les unités de prédiction de branchement contiennent des structures qui sont adressées par l'adresse de branchement. Tel est le cas du ''Branch Target Buffer'', de certaines PHT globales ou locales, de la ''Branch History Table'' qui stocke les historiques locaux, ou encore de la SRAM des poids des unités à base de perceptrons. En pratique, ces structures sont adressées non pas par l'adresse de branchement complète, mais pas les bits de poids faible de cette adresse. Cela a pour conséquence l'apparition d'un ''aliasing'' lié au fait que ces structures vont confondre deux branchements pour lesquels les bits de poids faible de l'adresse sont identiques. Il est possible d'utiliser autre chose que les bits de poids faible de l'adresse du branchement, afin de limiter les interférences. Et les possibilités sont multiples. Les possibilités en question s'inspirent des traitements effectués sur les adresses des banques dans les mémoires évoluées. Une première solution serait de faire un XOR entre les bits de poids faible de l'adresse du branchement, et d'autres bits de cette même adresse. Ainsi, deux branchements éloignés en mémoire donneraient des résultats différents, même si leurs bits de poids faible sont identiques. Une autre possibilité serait de diviser l'adresse du branchement par un nombre et de garder le reste. Ce calcul de modulo n'est en soi pas très différent du fait de conserver seulement les bits de poids faible. Conserver les N bits de poids faible consiste en effet à prendre le modulo par 2^N de l'adresse. Ici, l'idée serait de faire un modulo par un nombre P, qui serait un nombre premier proche de 2^N. Le fait que le nombre soit premier limite les cas où deux adresses différentes donneraient le même reste, ce qui réduit l'''aliasing''. Le fait de prendre un nombre proche de 2^N pour une entrée de N bits est que le résultat reste assez proche de ce qu'on obtiendrait en gardant les bits de poids faible, cette dernière étant une solution pas trop mauvaise. D'autres possibilités similaires se basent sur des réductions polynomiales ou d'autres astuces impliquant des nombres premiers. L'efficacité de ces méthodes dépend grandement de la taille de la SRAM/table considérée, des adresses des branchements et de beaucoup d'autres paramètres. La réduction des interférences par telle ou telle méthode dépend aussi de l'unité de prédiction considérée. Les résultats ne sont pas les mêmes selon que l'on parle d'une unité à perceptrons ou d'une unité à deux niveaux ou d'unités à base de compteurs à saturation, ou que l'on parle du BTB. Par contre, toutes les méthodes ne sont pas équivalentes en termes de temps de calcul ou de portes logiques. Autant la première solution avec un XOR ajoute un temps de calcul négligeable et quelques portes logiques, autant les autres méthodes requièrent l'ajout d'un diviseur très lent et gourmand en portes logiques pour calculer des modulos. Elles ne sont généralement pas praticables pour ces raisons, le temps de calcul serait trop élevé. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Le pipeline | prevText=Le pipeline | next=Les optimisations du chargement des instructions | nextText=Les optimisations du chargement des instructions }} </noinclude> 9vftenimy0zfoaqtz1ziiu9lt05ptt4 774411 774410 2026-10-07T22:30:59Z Mewtow 31375 /* La prédiction de destination pour les branchements indirects */ 774411 wikitext text/x-wiki Les branchements sont pris en charge par plusieurs étages du pipeline. Exécuter un branchement demande de savoir deux choses : s'il est pris ou non, quelle est l'adresse de destination. L'adresse de destination est localisée grâce au mode d'adressage du branchement. Avec le mode d'adressage direct, elle est extraite de l'instruction par le séquenceur. Avec l'adressage indirect, elle est lue dans un registre. Avec les branchements relatifs, elle est calculée via un additionneur, qui additionne un décalage au ''program counter''. L'additionneur peut être soit dans l'étage de ''PC Update'', soit dans l'unité de calcul de l'étage d'exécution. Passons maintenant au résultat du branchement, à savoir s'il est pris ou non. Pour savoir si le branchement est pris, il faut distinguer les branchements conditionnels et inconditionnels. Pour rappel, les branchements inconditionnels regroupent les appels de fonction, les simples instructions de saut, et quelques autres. Ils sont toujours pris, ils s'exécutent toujours. À partir du moment où le décodeur a décodé un branchement inconditionnel, il sait que le branchement est pris. Pour les branchements conditionnels, ils sont pris seulement si une condition précise est respectée. On sait s'ils sont exécutés une fois que la condition est calculée par l'unité de calcul, ou par une unité de branchement spécialisée. [[File:Gestion des branchements sur un pipeline.png|centre|vignette|upright=2.0|Gestion des branchements conditionnels sur un pipeline.]] Sur le schéma précédent, les branchements inconditionnels ne sont pas montrés. Pour cela, il faudrait rajouter une connexion du décodeur vers le multiplexeur de branchement. De plus, il faudrait aussi rajouter de quoi retarder l'exécution du branchement seulement une fois que l'adresse est connue. Par exemple, elle est connue un cycle d'horloge plus si l'adresse est lue depuis les registres, comparé à un branchement direct. ==Les branchements et les exceptions imprécises/précises== Un branchement est censé altérer le ''program counter'' immédiatement, en un cycle d'horloge. Mais sur un pipeline, Le branchement fait bien reprendre le processeur au bon endroit, mais avec du retard. La raison est qu'un branchement met plusieurs cycles pour s'exécuter. Il faut plusieurs cycles pour déterminer quelle est l'adresse de destination, temps qui dépend de là où est lue l'adresse. Et on sait si le branchement est pris seulement au niveau du décodeur, voire au niveau de l'unité de calcul. Le branchement doit traverser plusieurs étages avant qu'on connaisse son résultat. Il y a bien 3 à 4 cycles avant cela. Et pendant ce temps de trajet, des instructions seront chargées dans le pipeline, potentiellement à tort. Si le branchement n'est pas pris, ce n'est pas grave : les instructions n'ont pas été chargées à tort. Mais dans le cas contraire, les instructions n'auraient pas dû rentrer dans le pipeline. Le problème est similaire à celui observé avec les exceptions précises/imprécises. La différence est que les branchements sont des instructions machines, pas les exceptions matérielles. Mais le problème des instructions chargées à tort est le même dans les grandes lignes. Une différence est que les branchements sont résolus plus ou moins au même étage, alors que les exceptions peuvent survenir dans tout le pipeline. Une autre différence, bien plus importante, est que les branchements non-pris ne chargent pas d'instructions à tort et qu'il faut faire la différence entre branchement pris et non-pris. Dans la suite, nous parlerons d'instructions fautives pour parler des instructions chargées à tort. Pour corriger ce problème, il existe une solution logicielle. Le compilateur fait suivre les branchements par des instructions qui ne font rien, des NOP (No OPeration) : c'est ce qu'on appelle un '''délai de branchement'''. L'idée est que toutes les instructions chargées à tort sont des NOPs, qui ne font rien. Mais le nombre de NOP à ajouter dépend du pipeline du processeur, ce qui peut poser des problèmes de compatibilité. Mais d'autres solutions, matérielles cette fois, sont utilisées de nos jours. ===L'annulation des instructions au décodage=== Les branchements inconditionnels sont dans un cas plus favorable : ils sont identifiés dans l'unité de décodage et sont toujours pris. D'autres branchements sont dans ce cas : les appels de fonctions, les interruptions logicielles. Avec eux, le décodeur annule les instructions fautives, il les remplace par des instructions NOP. L'étage de décodage sait que les instructions suivant un branchement inconditionnel ont été chargées à tort. Précisément N instructions, avec N le nombre d'étages entre le chargement et le décodage (inclus). Les N instructions suivantes sont tout simplement annulés, elles sont remplacées par des NOPs. L'implémentation demande juste un simple compteur et quelques circuits annexes. Cependant, la technique ne marche pas du tout pour les branchements conditionnels. En effet, on ne sait pas s'ils sont pris ou non à l'étage de décodage. Du moins pas avec un pipeline normal. Mais certains processeurs, basés sur le pipeline RISC classique, calculaient les branchements pendant l'étape de décodage, dans une ALU spécialisée qui travaille en parallèle de l'unité de décodage. Le défaut de cette technique est que l'ALU en question doit lire les registres. Pour rappel, sur ce pipeline, l'étape de décodage effectue la lecture des opérandes dans les registres, en parallèle de l'unité de décodage. Mais ce qu'on n'a pas dit dans le chapitre précédent, c'est qu'elle effectue aussi le calcul des branchements dans une mini-ALU séparée ! Ce faisant, le résultat du branchement est connu dès la fin de l'étape de décodage. ===L'usage du système d'exceptions précises pour les branchements conditionnels=== La solution gère le problème en empêchant les instructions chargées à tort d'enregistrer leurs résultats, afin qu'elles n'aient pas le moindre effet sur l'état du processeur. Le hardware pour les exceptions précises, décrit quelques paragraphes avant, est réutilisé pour cela. Les branchements conditionnels peuvent être pris ou non-pris et on doit tenir compte de ces deux cas. Si le branchement n'est pas pris, les instructions n'ont pas été chargées à tort, elles peuvent enregistrer leur résultat dans les registres. Par contre, ce n'est pas le cas pour les branchements pris, les mécanismes d'exceptions précise doivent alors d'activer. Pour cela, la meilleure solution est que l'unité de calcul génère un indicateur d'exception si le branchement n'est pas pris. L'ALU effectue la comparaison et génère ou non l'indicateur d'exception adéquat suivant le résultat de celle-ci. L'idée est d'utiliser un indicateur d'exception particulier, qui précise qu'un branchement a été pris, signe que des instructions ont été chargées à tort. L'indicateur est propagé dans le pipeline, jusqu'à atteindre l'étage d'enregistrement dans les registres. Cet étage décide s'il faut autoriser ou non les écritures qu'il recoit, pour annuler les instructions chargées à tort. Le ''program counter'' est corrigé immédiatement, simplement en exécutant le branchement. Reste à savoir qui génère cet indicateur d'exception de branchement pris. Diverses optimisations visent à éliminer les instructions chargées à tort, à faire en sorte que des instructions utiles soient chargées. Le chapitre suivant va détailler ces optimisations dans le détail, qui sont regroupées sous le terme de prédiction de branchement. Elles ne fonctionnent pas pour les exceptions précises, seulement les branchements. ===La prédiction de branchement=== Les techniques précédentes ont pour défaut que de nombreux cycles d'horloge sont gâchés à cause des instructions fautives. Pour éviter ce gâchis, les concepteurs de processeurs ont inventé l'''exécution spéculative de branchement'', qui consiste à deviner l'adresse de destination du branchement et charger les bonnes instructions dès le départ. Le but est de ne pas charger d'instruction à tort, en prédisant quelles seront les bonnes instructions à charger. Et cela demande de résoudre trois problèmes : * reconnaître les branchements ; * savoir si un branchement sera exécuté ou non : c'est la '''prédiction de branchement''' ; * dans le cas où un branchement serait exécuté, il faut aussi savoir quelle est l'adresse de destination : c'est la '''prédiction de l'adresse de destination''' d'un branchement, aussi appelée ''branch target prediction''. Pour résoudre le second problème, le processeur contient un circuit qui déterminer si le branchement est pris (on doit brancher vers l'adresse de destination) ou non pris (on poursuit l’exécution du programme immédiatement après le branchement) : c'est l''''unité de prédiction de branchement'''. La prédiction de direction de branchement est elle aussi déléguée à un circuit spécialisé : l’'''unité de prédiction de direction de branchement'''. C'est l'unité de prédiction de branchement qui autorise l'unité de prédiction de destination de branchement à modifier le ''program counter''. Dans certains processeurs, les deux unités sont regroupées dans le même circuit. [[File:Relation entre prédiction de branchement et de direction de branchement.png|centre|vignette|upright=2|Relation entre prédiction de branchement et de direction de branchement.]] : Au lieu de parler d'unité de prédiction de branchement, j'utiliserais parfois le terme '''prédicteur''', sous-entendu ''prédicteur de branchement''. Avant de poursuivre, faisons une remarque très importante. J'ai parlé d'unité de prédiction de branchement, comme s'il n'y en avait qu'une seule. Mais en réalité, n'en avoir qu'une seule est l'exception. La règle est d'avoir plusieurs unités de branchements spécialisées. Un processeur moderne utilise plusieurs prédicteurs séparés, chacun prédisant un type de branchement bien précis. Il y a pas exemple un prédicteur spécialisé pour les boucles, un autre pour les instructions de retour de fonctions, un autre pour les branchements directs, un autre pour les branchements indirects. De plus, les processeurs modernes mélangent des prédicteurs rapides et imprécis, avec des prédicteurs lents mais précis. Les prédicteurs de premier type envoient les adresses prédites au cache d'instruction, pour charger les instructions prédites et mettre à jour le ''program counter'' en conséquence. Les prédicteurs lents et précis envoient leurs résultats au décodeur d'instruction, histoire d'annuler des branchements considérés à tort comme pris/non-pris. Il y a donc des prédicteurs rapides, dont le résultat est corrigé par des prédicteurs plus lents. La seconde unité a le dernier mot, elle corrige ou annule les prédictions de la première. Ce qui lui vaut le nom, en anglais, d'''overiding predictor''. Ce détail aura rapidement son imortance. ==Les erreurs de prédiction de branchement== Le processeur peut parfaitement se tromper en faisant ses prédictions, ce qui charge des instructions par erreur dans le pipeline. Dans ce cas, on parle d''''erreur de prédiction'''. Les erreurs de prédiction sont corrigées par le système d'exception précises, ou un équivalent. En clair, les instructions chargées à tort se propagent jusqu'à la sortie du pipeline, où leurs résultats sont alors annulés. N'importe quelle technique de gestion des exceptions précise fait le travail, mais les processeurs modernes utilisent un tampon de réordonnancement (''reorder buffer'') qu'on détaillera dans quelques chapitres. Détecter les erreurs de prédiction demande d'ajouter un circuit dans le processeur : l’''unité de vérification de branchement'' (''branch verification unit''). Elle génère un indicateur d'exception, qui est géré par le système d'exception précise. Pour générer l'indicateur, elle compare l'adresse de destination prédite et celle calculée en exécutant le branchement. Pour cela, l'adresse prédite doit être propagée dans le pipeline jusqu’à ce que l'adresse de destination du branchement soit calculée. [[File:Unité de vérification de branchement.png|centre|vignette|upright=2|Unité de vérification de branchement]] ===La pénalité de mauvaise prédiction=== Le temps pour vider le pipeline des instructions fautives dépend du pipeline. La dernière instruction à être chargée dans le pipeline le sera en même temps qu'on détecte l'erreur de prédiction. Il faudra attendre que cette instruction quitte le pipeline, et donc qu'elle traverse tous les étages. Il y a donc un temps d'attente pour collecter les résultats de ces instructions et la décision de ne pas enregistrer leurs résultats dans les registres Le nombre d'instructions chargées à tort dépend de où est détectée l'erreur de prédiction. Plus le résultat du branchement est connu tard, plus le nombre d'instructions chargées à tort sera grand, et plus le nombre de cycles d'horloge gâchés le sera aussi. Le nombre de cycles gâchés s'appelle la '''pénalité de mauvaise prédiction'''. Elle correspond précisément au nombre de cycles/étages entre le premier étage du pipeline, et l'étage où le branchement est résolu (on connait l'adresse à laquelle brancher). Plus celui-ci est éloigné du début du pipeline, plus la pénalité est importante. [[File:MIT 6.823 Computer System Architecture, Fall 2005 - P-3 vs. P-4 Pipelines - 4473425702.jpg|centre|vignette|upright=2.5|Comparaison de la pénalité de mauvaise prédiction entre le Pentium 3 et le Pentium 4 d'Intel.]] Elle est généralement d'autant plus longue que le pipeline lui-même est long. Les concepteurs de processeurs font donc face à un compromis : un pipeline long permet d'exécuter plus d'instructions en parallèle, mais cela augmente aussi la pénalité de mauvaise prédiction. Un pipeline de 10-15 étapes est un bon compromis. Les pipelines plus longs ont besoin d'une prédiction de branchement élaborée pour fonctionner correctement. Plus la prédiction de branchement est performante, plus le pipeline peut être long. Les pénalités de mauvaise prédiction seront plus importantes, mais les mauvaises prédictions seront plus rares, ce qui compense complétement. ===La ''minimal control dependency''=== En utilisant une technique du nom de ''minimal control dependency'', seules les instructions qui dépendent du résultat du branchement sont supprimées du pipeline en cas de mauvaise prédiction, les autres n'étant pas annulées. Sans ''minimal control dependency'', toutes les instructions qui suivent un branchement dans notre pipeline sont annulées. Pourtant, certaines d'entre elles pourraient être utiles. Prenons un exemple : supposons que l'on dispose d'un processeur de 31 étages (un Pentium 4 par exemple). Supposons que l'adresse du branchement est connue au 9éme étage. On fait face à un branchement qui envoie le processeur seulement 6 instructions plus loin. Si toutes les instructions qui suivent le branchement sont supprimées, les instructions en rouge sont celles qui sont chargées incorrectement. On remarque pourtant que certaines instructions chargées sont potentiellement correctes : celles qui suivent le point d'arrivée du branchement. Elles ne le sont pas forcément : il se peut qu'elles aient des dépendances avec les instructions supprimées. Mais si elles n'en ont pas, alors ces instructions auraient dûes être exécutées. Il serait donc plus efficace de les laisser enregistrer leurs résultats au lieu de les ré-exécuter à l’identique un peu plus tard. Ce genre de choses est possible sur les processeurs qui implémentent une technique du nom de ''Minimal Control Dependancy''. En gros, cette technique fait en sorte que seules les instructions qui dépendent du résultat du branchement soient supprimées du pipeline en cas de mauvaise prédiction. [[File:Minimal cntrol dependency.png|centre|vignette|upright=2|Minimal control dependency]] ==La reconnaissance des branchements== Pour prédire des branchements, le processeur doit faire la différence entre branchements et autres instructions, dès l'étage de chargement. Or, un processeur « normal », sans prédiction de branchement, ne peut faire cette différence qu'à l'étage de décodage, et pas avant. Les chercheurs ont donc dû trouver une solution. La première solution se base sur les techniques de prédécodage vues dans le chapitre sur le cache. Pour rappel, ce prédécodage consiste à décoder partiellement les instructions lors de leur chargement dans le cache d'instructions et à mémoriser des informations utiles dans la ligne de cache. Dans le cas des branchements, les circuits de prédécodage peuvent identifier les branchements et mémoriser cette information dans la ligne de cache. Une autre solution mémorise les branchements déjà rencontrés, dans un cache dédié. Ce cache mémorise l'adresse (le ''program counter'') des branchements déjà rencontrés : il s'appelle le '''tampon d’adresse de branchement''' (''branch adress buffer''). Il est intégré à l'unité de prédiction de branchement, mais est alimenté par le décodeur d'instruction. Reconnaitre un branchement demande l'implication du décodeur d'instruction, qui sait si l'instruction qu'il vient de décoder est un branchement ou non. Il peut même savoir si le branchement est conditionnel, inconditionnel, un appel de retour de fonction, etc. Lorsque le décodeur d'instruction reconnait un branchement, il ajoute ce branchement dans le cache Le tapon d'adresse de branchement contient une adresse mémoire par branchement, et éventuellement d'autres informations. Et pour comprendre quelle adresse est mémorisée, il faut faire attention à un détail. Les explications qui vont suivre vont faire intervenir deux adresses. * La première est l''''adresse de destination''' du branchement, à savoir l'adresse à laquelle le processeur doit reprendre son exécution si le branchement est pris. * L'autre adresse est l''''adresse du branchement''' lui-même. Elle correspond à sa position en mémoire RAM. L'adresse du branchement est la valeur du ''program counter'' quand le branchement est chargé dans le cache, alors que l'adresse de destination est celle du ''program Counter'' une fois le branchement exécuté. Il faudra faire bien attention à ne pas confondre les deux adresses dans ce qui suit. L'adresse du branchement est celle qui est utilisée pour la reconnaissance du branchement. Le tampon d'adresse de branchement mémorise, pour chaque branchement, son adresse de branchement (d'où son nom). À chaque cycle d'horloge, l'unité de chargement envoie le ''program counter'' en entrée du tampon d’adresse de branchement. Un succès dans ce cache indique qu'un branchement déjà rencontré est ré-executé, on peut utiliser la prédiction de branchement. Dans le cas contraire, la prédiction de branchement n'est pas utilisée, et l'instruction est chargée normalement. ==La prédiction de l'adresse de destination== La '''prédiction de l'adresse de destination d'un branchement''' détermine l'adresse de destination d'un branchement. Rappelons qu'il existe plusieurs modes d'adressage pour les branchements et que chacun d'entre eux précise l'adresse de destination à sa manière. Suivant le mode d'adressage, l'adresse de destination est soit dans l'instruction elle-même (adressage direct), soit calculée à l’exécution en additionnant un ''offset'' au ''program counter'' (adressage relatif), soit dans un registre du processeur (branchement indirect), soit précisée de manière implicite (retour de fonction, adresse au sommet de la pile). : Histoire de simplifier la terminologie, je parlerais parfois de '''prédiction de destination''' pour parler de la ''prédiction de l'adresse de destination d'un branchement''. Pour rappel, les branchements se classent en trois types : * les branchements directs pour lequel l'adresse de destination est toujours la même ; * les branchements indirects où l'adresse de destination est dans un registre et peut changer ; * les instructions de retour de fonction. Les branchements directs sont facilement prévisibles, vu que l'adresse vers laquelle il faut brancher est toujours la même. La prédiction des branchements directs et relatifs se fait globalement de la même manière, ce qui fait que nous ferons la confusion dans ce qui suit. Pour les instructions de retour de fonction, une prédiction parfaite est possible. Et c'est ce qui explique qu'elles sont traitées à part, par une unité de branchement dédiée. Pour les branchements indirects, vu que cette adresse change, la prédire celle-ci est particulièrement compliqué (quand c'est possible). Les branchements indirects sont eux aussi traités par une unité de prédiction dédiée rien que pour eux. ===La prédiction de destination pour les branchements directs=== Lorsqu'un branchement est exécuté, on mémoriser l'adresse de destination et la réutiliser lors d'exécutions ultérieures du branchement. Cette technique marche à la perfection pour les branchements directs, pour lesquels cette adresse est toujours la même, mais donne des résultats mitigés pour les branchements indirects. Pour mémoriser l'adresse de destination, on utilise un cache qui mémorise les correspondances entre l'adresse du branchement et l'adresse de destination. Ce cache est appelé le '''''branch target buffer''''' en anglais, qui sera abrévié en BTB dans ce qui suit. Le BTB est une amélioration du tampon d'adresse de branchement vu plus haut, auquel on aurait ajouté les adresses de destination des branchements. Le BTB est utilisé comme suit : on envoie en entrée l'adresse du branchement, le BTB répond au cycle suivant en précisant : s’il reconnaît l'adresse d'entrée, et quelle est l'adresse de destination si c'est le cas. Le BTB peut être un cache totalement associatif, associatif par voie, ou directement adressé, mais ces derniers sont les plus fréquents. Les BTB sont généralement des caches de type directement adressés, où seuls les bits de poids faible de l'adresse du branchement adressent le cache, le reste de l'adresse est tout simplement ignoré. Il existe aussi des BTB qui sont construits comme les caches associatifs à plusieurs voies, mais ceux-ci impliquent généralement la présence de ''tags'', ce qui fait qu'ils sont un peu moins fréquents. [[File:Branch target buffer directement adressé.png|centre|vignette|upright=2|Branch target buffer directement adressé.]] Comme pour tous les caches, un accès au BTB peut entraîner un défaut de cache, c’est-à-dire qu'un branchement censé être dans le BTB n'y est pas. Comme pour les autres caches, les défauts de cache peuvent se classer en trois types : les défauts de cache à froid (''cold miss'') causés par la première exécution d'un branchement, les défauts liés à la capacité du BTB/cache, et les défauts par conflit d'accès au cache. Les défauts liés à la capacité du BTB sont les plus simples à comprendre. La capacité limitée du BTB fait que d'anciens branchements sont éliminées pour laisser la place à de nouveaux. En conséquence, ces branchements peuvent donner des erreurs de prédiction si leur correspondance a été éliminée du cache. On peut réduire des défauts en augmentant la taille du BTB. Les défauts liés aux conflits d'accès au BTB sont eux beaucoup plus intéressants, car la conception du BTB est assez spéciale dans la manière dont sont gérés ce genre de défauts. Le BTB est un cache partiellement associatif, ce qui fait que des défauts de cache par conflit d'accès peuvent avoir lieu. Pour rappel, les défauts par conflit d'accès ont lieu quand deux adresses mémoires se voient attribuer la même ligne de cache. Pour un BTB, cela correspond au cas où deux branchements se voient attribuer la même entrée dans le BTB, ce qui porte le nom d’''aliasing''. Ne pas détecter ce genre de situation sur un cache normal entraînerait des problèmes : la donnée lue/écrite ne serait pas forcément la bonne. Les caches normaux utilisent un système de tags pour éviter ce problème et on s'attendrait à ce que les BTB fassent de même. Mais pour un BTB, l'absence de ''tags'' n'est pas un problème : la seule conséquence est une augmentation du taux de mauvaises prédictions, dont les conséquences en termes de performance peuvent être facilement compensées. Les BTB peuvent donc se passer de tags, ce qui a de nombreux avantages : le circuit est plus rapide, prend moins de portes logiques, consomme moins d'énergie, etc. Ces économies de portes logiques et de performances peuvent être utilisées pour augmenter la taille du BTB, ce qui compense, voire surcompense les mauvaises prédictions induites par l’''aliasing''. Mais ce n'est pas systématique : d'autres BTBs utilisent des ''tags'' pour améliorer le taux de prédiction. Nous ferons donc la différence entre '''BTB tagués''' et '''BTB non-tagués'''. Des processeurs se passent de BTB. À la place, ils mémorisent les correspondances entre branchement et adresse de destination dans les bits de contrôle du cache d'instructions. Mais nous verrons cela en détail dans le chapitre suivant. ===La prédiction de destination pour les branchements indirects=== Un BTB classique peut fonctionner pour les branchements indirects. Son seul défaut est qu'il mémorisera la dernière adresse de destination prise par le branchement. Et cela peut fonctionner pour les branchements indirects fortement biaisés, qui ont presque toujours la même adresse de destination. Mais la majorité des branchements indirects change d'adresse de destination régulièrement. Tous les processeurs commerciaux datant d'avant le Pentium M utilisaient un BTB normal pour prédire les branchements indirects. Les processeurs qui les ont suivi sont capables de prédire l'adresse de destination d'un branchement indirect. , grâce à une '''unité de prédiction des branchements indirects''', séparée de l'unité de prédiction de branchement normale. Elle utilise un BTB amélioré, qui mémorise plusieurs adresses de destination pour un seul branchement, en compagnie d'informations pour prédire laquelle est la bonne. Il est possible d'utiliser un BTB unique, pour tous types de branchements, appelé un '''BTB unifié'''. Un BTB unifié a plusieurs adresse de destination par entrée du BTB. Mais une seule est utilisée pour les branchements directs. Vu que les branchements directs sont majoritaires, cela fait beaucoup d'entrées inutilisées. En conséquence, elle est tout sauf optimale, et n'est presque jamais utilisée en pratique. {|class="wikitable" |+ Branch Target Buffer unifié |- ! Adresse du branchement !! Informations de prédiction !! Adresse de destination !! Seconde adresse de destination |- | 0x54462146 || 11101011 || 0x52495674 || 0x24987461 |- | 0x54142446 || 11101101 || 0x15646956 || x0x4158746 |- | 0x25456542 || 10011101 || 0x52495674 || 0x24157461 |- | 0x15455855 || 11010101 || 0x52485754 || 0x24024511 |- | ... || ... || ... || ... |- | ... || ... || ... || ... |- | 0x54462146 || 1001101 || 0x52495674 || 0x24987461 |} Une seconde solution utilise un BTB séparé, distinct du BTB pour les branchements directs. En séparant les deux, on a un BTB simple qui ne mémorise qu'une adresse de destination par branchement, et un BTB pour branchements indirects avec plusieurs adresses de destination par branchement. [[File:BTB avec prédiction de branchements indirects.png|centre|vignette|upright=2.5|BTB avec prédiction de branchements indirects.]] L'idée est que l'on économise des circuits à nombre d'entrées égales. Par exemple, comparons ce que cela donne avec 64 entrées. D'un côté, on a un BTB à 64 entrées, capable de gérer deux adresse de destination par branchement. En pratique, la majorité des branchements sont des branchements directs, qui n'utiliseraient qu'une seule case mémoire. Les circuits du BTB seraient donc sous-utilisés. De l'autre, on a un BTB à 48 entrées pour branchements directs, et un second BTB avec 16 entrées, capable de mémoriser deux adresses par branchements. Le premier BTB utilise moins de circuits, et est utilisé à pleine capacité, il ne gaspille pas de circuits pour mémoriser des adresses de destination vides. Par contre, on perd un peu en flexibilité. L'organisation précédente marche bien si 1/4 des branchements rencontrés sont des branchements indirects. Mais si le ratio est différent, la performance est légèrement sous-optimale. Attention, cela ne veut pas dire qu'il y aura des entrées vides dans un BTB, non. Simplement, le premier BTB risque de manquer de place pour les branchements directs récents, alors que les branchements indirects seront eux bien prédits, même les anciens. À l'opposé, un BTB unique permet de mémoriser les 64 derniers branchements, peu importe qu'ils soient indirects ou non. En clair : un BTB unique est plus flexible, ce qui donne un gain de performance dans certaines situations. Cependant, cet avantage théorique ne tient pas compte de l'économie en circuits, qui peut être utilisée pour faire grossir le BTB pour branchements directs. Par exemple, à budget à transistor équivalent, on a le choix entre BTB à 64 entrées uniques, et un BTB pour branchements directs de 80 entrées avec un BTB indirect de 16 entrées. Le fait qu'un BTB soit plus gros permet des gains de performances qui compensent la perte de flexibilité. On peut ainsi avoir le meilleur des deux monde, à savoir une économie de circuits et les gains en performance qui en découlent, malgré une petite perte de flexibilité. Une troisième solution utilise un BTB normal, mais réserve plusieurs entrées pour un branchement indirect. Par exemple, un branchement indirect qui a trois adresses de destination prendra trois entrées : une par adresse de destination. Mais quelques difficultés surviennent alors. Comment placer trois copies d'un même branchement, alors que ces trois copies ont le même ''program counter'' ? Et que faire pour savoir quelle copie sera prise ? Que faire si l'unité de prédiction de branchement prédit les trois copies comme prises ? La technique du '''''Program Counter'' virtuel''' (''Virtual Program Counter'') résoud ces problèmes d'une manière assez élégante. L'idée est la même que précédemment, à savoir qu'il y a une copie du branchement par adresse de destination, dans le BTB. Mais ces copies n'ont pas exactement le même ''Program counter''. Chaque copie se voit attribuer un ''Program Counter'' virtuel. Et comprendre pourquoi demande de comprendre l'idée sous-jacente. La technique a été inspirée par une optimisation des compilateurs, qui transforme un branchement indirects en une séquence de branchements conditionnels. Par exemple, un branchement indirects qui peut brancher vers les adresses A, B et C est transformé en trois branchements conditionnels : un qui branche vers A, un autre qui branche vers B et un autre vers C. Elle ne marche pas pour tous les branchements indirects, elle demande des circonstances particulières pour fonctionner. Mais l'idée est adaptée pour le ''design'' de l'unité de prédiction de branchement. L'idée est qu'un branchement indirect est déroulé en plusieurs branchements dans le BTB. Les branchements sont traités en séquence : l'unité de prédiction prédit si le premier branchement est pris ou non. S'il est pris, son adresse de destination est envoyée au cache d'instruction. S'il n'est pas pris, l'unité de prédiction tester la seconde copie du branchement, et rebelote. Il teste toutes les copies, une par une, et s’arrête quand il tombe sur un branchement pris. Si aucune copie n'est prise, alors le branchement est prédit comme non-pris. Première remarque : il s'agit d'un processus itératif, où on test un branchement à la fois. Et oui, c'est une prédiction par cycle d'horloge. Prédire un branchement indirect peut donc demander plusieurs cycles d'horloge. Mais ce n'est pas un problème, car le prédicteur peut servir d'''overriding predictor'', d'unité de prédiction lente mais précise. La prédiction peut être utilisée par l'unité de décodage, pour annuler les instructions chargées à tord. Deuxième remarque : comment passer d'un branchement à l'autre ? La première copie a un ''program counter'' normal, qui correspond à l'adresse du branchement. La seconde a un ''program counter'' qui est calculé à partir de cette première adresse, la troisième copie a une adresse calculée à partir de la seconde, etc. Concrètement, n'importe quelle fonction un tant soit peu simple suffit, tant qu'elle est déterministe. L'article qui a proposé cette techniques (''Virtual Program Counter (VPC) Prediction : Very Low Cost Indirect Branch Prediction Using Conditional Branch Prediction Hardware'') se contenait de faire un XOR entre l'adresse précédente et une constante, avec une constante différente par itération. Troisième remarque : l'algorithme demande de faire une parenthèse dans le fonctionnement normal du BTB. Quand un branchement indirect est détecté, il lance une succession de prédiction pour prédire un branchement. L'incrémentation du ''program counter'' normale cesse pendant ce processus. Et cela ne doit être fait que quand un branchement indirect est détecté. Pour cela, le BTB mémorise le type de chaque branchement, sans l'entrée du BTBT : est-ce qu'il est conditionnel, inconditionnel, mais aussi direct ou indirect. Et le type est accesible en sortie du BTB, sur une sortie de quelques bit. Quand l'unité de prédiction remarque qu'un branchement indirect vient de sortir du BTB, alors elle lance le processus. [[File:Virtual program counter.png|centre|vignette|upright=2.5|Virtual program counter]] L'avantage de cette technique est qu'elle s’implémente facilement, son cout en transistors est faible. Elle demande juste d'ajouter quelques circuits autour d'un BTB classique. Elle utilise l'unité de prédiction classique, pas besoin d'une unité de prédiction séparée. Vu qu'il y a un seul BTB, on n'a pas les désavantages d'un BTB séparé pour les branchements indirects. Les entrées du BTB sont réparties entre branchements directs et indirects de manière optimale. Niveau performance, elle est un peu moins bonne que les unités de prédiction ITTAGE qu'on détaillera dans la suite du chapitre. Mais elle est suffisante pour être utilisée sur certains processeurs commerciaux. ===La prédiction de l'adresse de retour d'une fonction=== Presque tous les processeurs conçus après les années 2000 peuvent prévoir l'adresse à laquelle il faudra reprendre lorsqu'une fonction/sous-programme a fini de s’exécuter. Ils font de la '''prédiction de retour de fonction'''. Pour cela, ils possèdent pour une unité de prédiction de branchement spécialisée, appelée le ''return stack predictor'', ou '''''pile de prédiction de retour de fonction'''''. Le terme ''return stack predictor'' étant plus court, nous l'utiliserons dans la suite du cours. Le ''return stack predictor'' est capable de reconnaitre les instructions de retour de fonction, et de prédire l'adresse de retour. Pour comprendre comment elle fait, rappelez-vous le chapitre sur la pile d'appel. Dans ce chapitre, j'avais introduit le concept de ''pile d'adresse de retour'', à savoir une pile d'appel très simplifiée, qui ne contient que les adresses de retour des fonctions en cours d'exécution. Le ''return stack predictor'' est essentiellement composé d'une telle pile d'adresse de retour, avec des circuits pour empiler et dépiler les adresses de retour. Le ''return stack predictor'' doit reconstruire une pile d'adresses de retour, à partir du programme exécuté. La pile d'adresse est mémorisée dans le ''return stack predictor'', elle est totalement séparée de la pile d'appel normale. Le programme exécuté continue d'utiliser la même pile d'appel qu'avant, ce qui fait que les adresses de retour sont dispersées dans la pile d'appel, parfois dans des registres spéciaux du processeur. Mais cela ne pose pas de problèmes pour le processeur, qui reconstruit la pile d'adresse de retour avec l'aide du décodeur. La reconstruction se fait à partir des instructions d'appel et de retour de fonction. Une instruction d'appel de fonction empile une adresse de retour, une instruction de retour de fonction la dépile. Lorsque le décodeur d'instruction reconnait une instruction d'appel de fonction, il extrait l'adresse de retour et l'envoie au ''return stack predictor''. L'adresse de retour est alors empilée, ce qui fait qu'elle se retrouve au sommet de la pile d'adresse de retour. Dépiler une adresse de retour revient à prédire un branchement. Lorsque le ''return stack predictor'' détecte une instruction de retour de fonction, il dépile l'adresse au sommet de la pile. L'adresse est alors envoyée du cache d'instruction, pour l'ire l'instruction adéquate, et le ''program counter'' est mis à jour avec cette adresse. Toute la difficulté revient à détecter les instructions de retour de fonction, sans quoi on ne sait pas quand dépiler l'adresse. La documentation là-dessus n'est pas très fournie, il est difficile de savoir ce qui est fait dans les processeurs modernes. Mais on peut faire quelques suppositions. La première possibilité est l'usage du ''prédécodage'', une technique qui décode partiellement les instructions lors de leur chargement dans le cache d'instruction. Elle sera vue dans le chapitre suivant, mais parlons-en rapidement. Le prédécodage ajoute quelques bits au début de chaque instruction pour préciser si c'est un branchement conditionnel, inconditionnel, un retour de fonction. On peut ainsi savoir rapidement quand dépiler : dès qu'une instruction est lue dans le cache d'instruction, le ''return stack predictor'' vérifie les bits ajoutés et agit en fonction. La seconde possibilité est que le BTB reconnait l'instruction de retour de fonction. Pour chaque branchement, il mémorise non seulement son adresse de retour, mais aussi son type. Par type, on veut dire : si c'est un branchement conditionnel, inconditionnel, un retour de fonction. Les retours de fonction sont donc reconnus à condition qu'ils aient été exécutés auparavant. Il est aussi possible d'avoir un BTB spécialisé dans les retours de fonction, qui est alimenté par les décodeurs. Dès que le décodeur reconnait une instruction de retour de fonction, il ajoute son ''program counter'' dans ce BTB spécialisé. Une dernière possibilité est que le ''return stack predictor'' agit une fois l'instruction partiellement décodée. Le décodeur d'instruction reconnait les instructions de retour de fonction rapidement, en un cycle, et consulte alors le ''return stack predictor''. L'adresse est dépilée, et le décodeur sait où brancher. Il n'a pas besoin de devoir lire l'adresse de retour en mémoire, il la lit depuis le ''return stack predictor'', directement. La prédiction se fait donc assez tard dans le pipeline, quelques instructions ont été chargées à tort. Mais le décodeur peut les annuler, comme il le ferait avec un branchement inconditionnel mal prédit. ==La prédiction de branchement locale== La prédiction de branchement tente de prédire si un branchement sera pris ou non-pris, et décide quelle adresse envoyer au cache en fonction du résultat. Si on prédit qu'un branchement est non pris, on laisse le ''program counter'' faire son travail. À l'inverse si le branchement est prédit comme étant pris, l'adresse de destination est envoyée au cache d'instruction et au ''program counter''. La prédiction de branchement assigne une probabilité qu'un soit branchement soit pris en fonction de ces informations : si la probabilité est de plus de 50%, le branchement est considéré comme pris. La prédiction de branchement se base avant tout sur des statistiques, c’est-à-dire qu'elle détermine la probabilité d’exécution d'un branchement en fonction d'informations assez diverses. La prédiction de branchement n'a d'intérêt que si les prédictions sont suffisamment fiables pour valoir le coup. Rappelons que la pénalité lors d'une mauvaise prédiction est importante, d'autant plus coûteuse que le pipeline est long. Il est généralement admis qu'il faut au minimum un taux de réussite proche de 90%, voire plus sur les processeurs modernes. Heureusement, la plupart des branchements sont des '''branchements biaisés''', c’est-à-dire qu'ils sont presque toujours pris ou presque toujours non-pris, sauf en de rares occasions. De tels branchements font que la prédiction des branchements est facile, bien qu'ils posent paradoxalement quelques problèmes, comme nous le verrons plus bas. Un autre point important est que les unités de prédiction de branchement doivent être très rapides. Idéalement, elles doivent fournir leur prédiction en un seul cycle d'horloge. Un seul cycle d'horloge est un temps très court, surtout sur les ordinateurs avec une fréquence élevée. Les techniques de prédiction dynamique ne peuvent donc pas utiliser des méthodes statistiques extraordinairement complexes. Cela va à l'encontre du fait que le taux de mauvaises prédictions doit être très faible, idéalement inférieur à 1% sur les processeurs modernes. Vous comprenez donc aisément que concevoir une unité de prédiction de branchement est un véritable défi d’ingénierie électronique qui requiert des prouesses technologiques sans précédent. Suivant la nature des informations utilisées, on peut distinguer plusieurs types de prédiction de branchement : locale, globale, dynamique, statique, etc. Nous allons d'abord voir les méthodes statiques avant de voir les méthodes dynamiques. La '''prédiction locale''' prédit chaque branchement indépendamment les uns des autres. Elle sépare bien les statistiques/moyennes pour chaque branchement. Et c'est autant un point fort qu'un point faible. En effet, elle n'exploite pas les corrélations entre branchements, à savoir le fait que l'exécution d'un branchement a tendance à déborder sur les suivants. Dans ce qui suit, nous allons voir les techniques de prédiction locales. ===Les ''branch hints''=== Dans son implémentation la plus explicite, la prédiction est inscrite dans le branchement lui-même. Quelques bits de l'opcode du branchement précisent si le branchement est majoritairement pris ou non pris. Ils permettent d'influencer les règles de prédiction de branchement et de passer outre les réglages par défaut. Ces bits sont appelés des '''suggestions de branchement''' (''branch hint''). Mais tous les processeurs ne gèrent pas cette fonctionnalité. Et de plus, cette solution marche assez mal. La raison est que le programmeur ou le compilateur doit déterminer si le branchement est souvent pris ou non-pris, mais qu'il n'a pas de moyen réellement fiable pour cela. Une solution possible est d’exécuter le programme sur un ensemble de données réalistes et d'analyser le comportement de chaque branchement, afin de déterminer les suggestions de branchement adéquates, mais c'est une solution lourde et peu pratique, qui a de bonnes chances de donner des résultats peu reproductibles d'une exécution à l'autre. ===La prédiction statique de branchement=== La '''prédiction statique''' prédit le résultat du branchement en fonction de caractéristiques fixes du branchement lui-même, comme son adresse, son mode d'adressage, l'adresse de destination, la position du branchement en mémoire, si le branchement est direct ou indirect, etc. Les informations en question sont connues une fois le branchement décodé, il n'y a pas besoin de mémoriser la moindre information supplémentaire. Une première implémentation, assez évidente, fait la différence entre les branchements inconditionnels toujours pris, et les branchements conditionnels au résultat variable. L'idée est de prédire les branchements inconditionnels comme toujours pris alors que les branchements conditionnels sont prédits comme non-pris (ce qui est une approximation). L'implémentation est relativement simple : on utilise un BTB qui ne mémorise que les branchements inconditionnels, et basta ! Les branchements conditionnels sont simplement ignorés, traités comme n'importe quelle autre instruction. Cette méthode est particulièrement inefficace pour les branchements de boucle, où la condition est toujours vraie, sauf en sortie de boucle ! Il a donc fallu affiner légèrement l'algorithme de prédiction statique. Une implémentation plus subtile fait la différence entre les branchements des boucles et les autres. Reste à identifier ceux-ci d'une manière ou d'une autre. Et un bon moyen est de regarder si le branchement revient en arrière ou au contraire saute une portion de code. Un programme s'exécute des adresses basses vers les adresses hautes, les instructions vont naturellement dans le sens croissant des adresses. Un branchement de boucle fait revenir en arrière, histoire de répéter un morceau de code, pas les autres branchements (du moins pas forcément). Et cela nous amène à faire la distinction entre les branchements conditionnels ascendants et descendants. Un '''branchement conditionnel ascendant''' est un branchement qui demande au processeur de reprendre plus loin dans la mémoire : l'adresse de destination est supérieure à l'adresse du branchement. Un branchement '''conditionnel descendant''' a une adresse de destination inférieure à l'adresse du branchement : le branchement demande au processeur de reprendre en arrière dans la mémoire, avant le branchement. Les branchements ascendants sont rarement pris (ils servent dans les conditions de type SI…ALORS), contrairement aux branchements descendants (qui servent à fabriquer des boucles). On peut ainsi modifier l’algorithme de prédiction statique comme suit : * les branchements inconditionnels sont toujours pris ; * les branchements descendants sont toujours pris ; * les branchements ascendants ne sont jamais pris. L'implémentation demande d'élargir le BTB. Le BTB n'a pas seulement à mémoriser l'adresse du branchement et son adresse de destination, mais aussi le type du branchement. En général, il se contente de deux bits, qui indiquent si le branchement est conditionnel, inconditionnel, ou un retour de fonction. Les retours de fonction sont traités à part, histoire de profiter du ''return stack predictor''. {|class="wikitable" |+ BTB avec prédiction de branchement statique évoluée |- ! Adresse du branchement !! Adresse de destination !! Type du branchement |- | 0x4165485 || 0x14657445541 | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Retour de fonction |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... || ... |} ===La prédiction avec des compteurs à saturation=== Les méthodes de prédiction de branchement statique sont intéressantes, mais elles montrent rapidement leurs limites. Pour plus de performances, il faut utiliser la '''prédiction dynamique''', qui extrait des statistiques sur l’exécution des branchements. Pour le reste du chapitre, toutes les techniques utilisent la prédiction dynamique, qu'elles soient locales ou globales. Sa version la plus simple se contente de mémoriser ce qui s'est passé lors de l’exécution précédente du branchement. Si le branchement a été pris, alors on suppose qu'il sera pris la prochaine fois. De même, s'il n'a pas été pris, on suppose qu'il ne le sera pas lors de sa prochaine exécution. Pour cela, chaque entrée du BTB contient un bit qui mémorise la dernière exécution du branchement : 1 s'il a été pris, 0 s'il n'a pas été pris. C'est ce qu'on appelle la '''prédiction de branchements sur 1 bit'''. Au vu de sa simplicité, il ne faut pas s'attendre à des miracles. La méthode fonctionne bien pour les branchements des boucles et les branchements inconditionnels, mais elle n'est pas assez performante. Une forme plus élaborée de prédiction utilise des '''compteurs à saturation'''. Un compteur à saturation est un compteur qui est incrémenté à chaque fois qu'un branchement est pris, décrémenté s'il est non-pris. Le compteur est initialisé à la moitié de sa valeur maximale, ce qui correspond à une probabilité proche de 50%. Les compteurs à saturation sont très utilisés et pas seulement pour de la prédiction locale, les méthodes globales en utilisent aussi. Pour faire la prédiction, l'unité de prédiction regarde le bit de poids fort du compteur. Le branchement est considéré comme pris si ce bit de poids fort est à 1, et non pris s'il vaut 0. En clair : on considère que le branchement est pris si le compteur indique une probabilité supérieure à 50%. [[File:Compteur à saturation.png|centre|vignette|upright=2|Compteur à saturation.]] : Pour la culture générale : le compteur à saturation du Pentium 1 était légèrement bogué, ce qui fait que sa prédiction de branchement était sous-optimale. La plupart des processeurs utilisent un compteur à saturation par branchement, ce qui veut dire un compteur à saturation par entrée dans le BTB. Vu chaque compteur est associé à une entrée du BTB, le phénomène d’''aliasing'' mentionné plus haut peut perturber les prédictions de branchement. Deux branchements peuvent se voir associés à la même entrée du BTB, et agir sur le même compteur à saturation, ce qui entraine l'apparition d''''interférences entre branchements'''. L'interférence peut avoir des effets positifs, neutres ou négatifs, suivant la corrélation entre les deux branchements. L’''aliasing'' n'est pas un problème si les deux branchements sont fortement corrélés, par exemple si les deux sont tout le temps pris ou tout le temps non-pris. Si les deux branchements vont dans le même sens, l'interférence est neutre, voire légèrement positive. Mais si l'un est souvent pris et l’autre souvent non-pris, l'interférence est négative. Les deux branchements vont se marcher dessus sans vergogne, chacun interférant sur les prédictions de l'autre ! Il est rare que l’''aliasing'' ait un impact significatif sur les unités de prédiction des deux paragraphes précédents. Il se manifeste surtout quand la BTB est très petite et la seule solution est d'augmenter la taille du BTB. ===La prédiction avec un historique local=== La prédiction avec des compteurs à saturation est très simple, mais il est possible de créer une implémentation alternative, très proche, avec des avantages et désavantages différents. Un compteur à saturation enregistre l'historique d'un branchement, à savoir ses dernières exécutions. L''''historique''' d'un branchement dit quel a été son résultat dans le passé, pour ses exécutions précédentes. Et il est possible d'enregistrer cet historique et l'utiliser pour faire des prédictions. Par exemple, prenons un branchement sont ses 6 dernières exécutions sont "pris, non-pris, pris, pris, pris, non-pris". Le processeur peut encoder cet historique sur 6 bits. Un branchement pris correspond à un bit à 1, un branchement non-pris correspond à un 0. Le bit de poids fort correspond à l'exécution la plus ancienne, le bit de poids faible à l'exécution la plus récente. Par exemple, si l'historique contient 010, cela veut dire que le branchement a été non pris, puis pris, puis non pris. L'historique d'un branchement est appelé son '''historique local''', mais nous parlerons simplement l'historique. Le processeur peut mémoriser l'historique de chaque branchement. Pour cela, il réserve n bits par branchements, pour mémoriser ses n exécutions précédentes. L'historique d'un branchement est mémorisé dans son entrée du BTB. Et plus précisément dans un champ séparé qui est alimentée comme un registre à décalage. Reste à exploiter cet historique local pour faire des prédictions. Une idée assez simple serait de l'utiliser pour calculer une moyenne des N exécutions précédentes. Si la probabilité d'être pris est supérieure à 50 %, le branchement est considéré comme pris. Dans le cas contraire, il est considéré comme non pris. Le calcul de la moyenne utilise une simple porte à majorité, qui prend en entrée tous les bits de l'historique. Cependant, cette méthode est équivalente à utiliser un compteur à saturation par branchement. La sortie de la porte à majorité est équivalente à ce qu'on aurait dans un compteur à saturation. La seule différence : le cout en circuit et la simplicité de conception. Prenons l'exemple des premiers processeurs PA RISC. Ils mémorisaient, pour chaque branchement, les trois dernières exécutions du branchement, avec trois bits par entrée du BTB. La prédiction était réalisée avec une porte à majorité, dont le résultat était utilisé comme prédiction. Ces processeurs ont préféré utiliser un historique de 3 bits, car il était plus simple à concevoir qu'un compteur à saturation équivalent de deux bits. Il suffisait d'alimenter un registre à décalage avec un bit, pas besoin d'incrémenter ou de décrémenter un compteur, pas besoin de signaux d'incrémentation/décrémentation séparés, etc. Mais pour des historiques plus longs, la donne s'inverse et des compteurs à saturation de deux-trois bits l’emportent. Un défaut de la méthode précédente, qui est partagée avec les compteurs à saturation, est qu'elle marche mal pour les branchements qui suivent un ''pattern''. Je veux dire par là que l'exécution d'un branchement suit un cycle, par exemple pris, pris, non-pris, pris, pris, non-pris, et ainsi de suite en boucle. Notons que l'apparition de tels motifs correspond le plus souvent à la présence de boucles dans le programme. Quand une boucle s’exécute, les branchements qui sont à l'intérieur tendent à exprimer de tels motifs. Avec de tels motifs, les compteurs à saturation feront des prédictions incorrectes, là où les prédicteurs qu'on va voir font des prédictions parfaites. Par exemple, prenons un branchement qui suit le ''pattern'' pris, pris, pris, non-pris. Si l'historique local vaut 1110, le branchement est censé refaire une boucle et être pris. Et certes, on a supposé que l'historique faisait 4 bits. Mais le''pattern'' sera visible tant que l'historique fait plus de 4 bits. Par exemple, le ''pattern'' serait 01110 pour un historique local de 5 bits, 101110 pour un historique local de 6 bits, 1101110 pour un historique local de 7 bits, etc. Un ''pattern'' correspond donc à une valeur bien précise de l'historique local. L'historique local permet de voir ces cycles, reste à les exploiter. Prédire correctement les ''pattern'' demande simplement d'associer un compteur à saturation à chaque valeur possible de l'historique local. Le compteur à saturation dira, quand tel pattern est rencontré, si le branchement sera pris ou non-pris par la suite. Au total, il faut donc un registre d'historique local de n bits couplé à 2^n compteurs à saturation. Et ce pour chaque branchement possible. Les compteurs à saturation sont regroupés dans une '''''Pattern History Table''''', que nous noterons PHT dans ce qui suit. Le résultat est une '''unité de prédiction à deux niveaux locale''', qui contient plusieurs registres d'historique et tout autant de PHT locales. En tout, il y a une PHT et un historique par branchement, donc par entrée du BTB. Quand un branchement est exécuté, son historique local indique quel compteur à saturation choisir dans la PHT locale. En général, l'historique local est mémorisé dans le BTB, reste à gérer la PHT. [[File:Unité de prédiction à deux niveaux purement locale.png|centre|vignette|upright=2.5|Unité de prédiction à deux niveaux avec des historiques et des PHT locales.]] Une unité de prédiction locale à deux niveaux contient plusieurs PHT, reste à savoir quelle PHT correspond à tel branchement. La correspondance se fait idéalement à partir de l'adresse du branchement, mais c'est là une implémentation idéale, qui demande beaucoup de circuits. Généralement, l'unité de prédiction n'utilise que les bits de poids faible de l'adresse du branchement, ce qui permet de faire un choix correct, mais avec une possibilité d'''aliasing''. Il est aussi possible de séparer l'historique local du BTB. Les historiques locaux sont alors mémorisés dans une mémoire RAM dédiée, appelé la '''''Branch History Table''''', ou encore la '''table des historiques locaux''', que nous noterons BHT dans ce qui suivra. Chaque byte de la BHT contient l'historique local associé à un branchement. Pour récupérer l'historique local voulu, l'adresse du branchement est envoyée à la BHT, sur les entrées d'adresse de la BHT, directement. Seuls les bits de poids faible de l'adresse sont utilisés. L'historique local est ensuite envoyé à la PHT adéquate, choisie en fonction des mêmes bits de poids faible du PC. Un bon moyen pour cela est d'accéder à toutes les PHT en parallèle, mais de sélectionner la bonne avec un multiplexeur, ce dernier étant commandé par les bits de poids faible du PC. [[File:Table des historiques locaux.jpg|centre|vignette|upright=2|Table des historiques locaux]] Un défaut des unités de prédiction purement locales de ce type est leur cout en termes de circuits. Utiliser un registre d'historique et une PHT par branchement a un cout en transistor élevé, avec tous les défauts que ca implique en termes de consommation électrique et thermique. Elles ont aussi des temps de calcul un peu plus important que les unités purement globales, mais pas de manière flagrante. Les gains en performance avec un historique local s'observent surtout pour les branchements qui sont dans des boucles ou pour les branchements utilisés pour concevoir des boucles, alors que l'implémentation globale marche bien pour les if..else (surtout quand ils sont imbriqués). Les deux sont donc complémentaires. Dans les faits, elles sont rarement utilisées seules du fait de leurs défauts, au profit d'unités de prédiction hybrides, qui mélangent historiques et PHT locaux et globaux. ==La prédiction de branchement globale/mixte== Utiliser un historique local avec une PHT est très puissant. Mais le cout en circuit est très important. Cependant, on peut adapter l'idée pour faire de la prédiction globale. La '''prédiction globale''' fait des prédictions holistiques, qui tiennent compte de tous les branchements, sans les traiter séparément. Elle ne sépare pas les branchements, tous sont mis dans le même panier. La prédiction globale fait donc le compromis inverse : elle exploite les corrélations entre branchements, mais perd les informations statistiques précise pour chaque branchement. L'idée est qu'au lieu d'utiliser un historique par branchement, on utilise un '''historique global''' qui mémorise les exécutions de tous les branchements conditionnels. L'historique enregistre les N exécutions de branchements conditionnels. L'historique global est mémorisé dans un registre unique, appelé le '''registre d'historique global'''. C'est un registre à décalage mis à jour à chaque fois que le processeur exécute un branchement : on fait rentrer un 1 si le branchement est pris, et un 0 sinon. Une unité de prédiction globale utilise cet historique pour faire sa prédiction. L'unité de prédiction globale utilise un compteur à saturation pour chaque valeur possible du registre d'historique. En clair, pour un registre de N bits, elle utilise 2^n compteurs à saturation. Là encore, les compteurs à saturation sont regroupés dans une ''Pattern History Table''. Nous parlerons de '''PHT globale''' pour désigner une PHT commandée par un historique global, de ''PHT locale'' si elle est adressée par un historique local. [[File:Prédiction dynamique à deux niveaux.png|centre|vignette|upright=2|Prédiction dynamique à deux niveaux.]] Il est admis que l'historique global donne de meilleures prédictions que l'historique local. Mais les deux sont complémentaires, ce qui fait qu'il existe des approches hybrides. Les approches hybrides exploitent à la fois les statistiques pour chaque branchement et les corrélations entre branchements. Les prédicteurs hybrides ont de meilleures performances que les prédicteurs purement globaux ou locaux. Avec un historique global, la prédiction tient compte d'éventuelles corrélations entre branchements consécutifs/proches pour faire les prédictions. C'est un avantage pour les applications qui enchaînent des if...else ou qui contiennent beaucoup de if...else imbriqués les uns dans les autres, qui s’exécutent plusieurs fois de suite. Le résultat de chaque if...else dépend généralement des précédents, ce qui rend l'utilisation d'un historique global intéressant. Par contre, ils sont assez mauvais pour le reste des branchements. Mais cette qualité devient un défaut quand elle détecte des corrélations fortuites ou parasites, inutiles pour la prédiction (corrélation n'est pas causalité, même pour prédire des branchements). Du fait de ce défaut, la prédiction globale a besoin de registres d'historiques globaux très larges, de plusieurs centaines de bits au mieux. [[File:2 level branch predictor.svg|centre|vignette|upright=2|Unités de prédiction de branchement à deux niveaux avec une seule PHT.]] Le circuit précédent a cependant un problème : si deux branchements différents s'exécutent et que l'historique est le même, le processeur n'y verra que du feu. Il y a alors une interférence, à savoir que les deux branchements vont agir sur le même compteur à saturation. On se retrouve alors dans une situation conceptuellement identique à l’''aliasing'' dans le BTB ou avec des compteurs à saturation. Sauf que les interférences sont alors beaucoup plus nombreuses et qu'elles sont clairement un problème pour les performances ! Mitiger les interférences devient alors bien plus important qu'avec les unités de prédiction précédentes. Heureusement, si augmenter la taille de la PHT peut mitiger le problème, d'autres solutions sont possibles sur les unités de prédiction globales. Elles sont au nombre de deux : mitiger les interférences liées aux branchements biaisés, ajouter des informations qui discriminent les branchements. Voyons ces solutions dans le détail. ===La mitigation des interférences par usage de l'adresse de branchement=== La première solution pour réduire l'impact de l’''aliasing'' est d'augmenter la taille de la PHT et de l'historique. Plus l'historique et la PHT sont grands, moins l’''aliasing'' est fréquent. Mais avoir des historiques et des PHT de très grande taille n'est pas une mince affaire et le cout en termes de performances et de portes logiques est souvent trop lourd. Une autre solution consiste à utiliser, en plus de l'historique, des informations sur le branchement lui-même pour choisir le compteur à saturation à utiliser. Typiquement, on peut utiliser l'adresse du branchement en plus de l'historique, pour choisir le compteur à saturation adéquat. Pour être précis, on n'utilise que rarement l'adresse complète du branchement, mais seulement les bits de poids faible. La raison est que cela fait moins de bits à utiliser, donc moins de circuits, mais l’''aliasing'' est juste atténué, pas éliminé. Des branchements différents ont beau avoir des adresses différentes, les bits de poids faible de leurs adresses peuvent être identiques. Des confusions sont donc possibles, mais sont assez rares pour que cela suffise en pratique. Une unité de prédiction de ce type est illustrée ci-dessous. Il y a plusieurs PHT séparées, donc plusieurs ensembles de 2^n compteurs à saturation. Le choix de la PHT est réalisé par l'adresse du branchement, alors que le choix du compteur à saturation est réalisé par l'historique. Les compteurs à saturation d'un branchement sont mis à jour seulement quand le branchement s'est chargé pendant que l'historique associé était dans le registre d'historique. Cette méthode a cependant le défaut de gâcher énormément de transistors. [[File:Unité de prédiction de branchement basée sur des compteurs à saturation sélectionnés par l'historique global.jpg|centre|vignette|upright=2|Unité de prédiction de branchement basée sur des compteurs à saturation sélectionnés par l'historique global]] D'autres unités de prédiction fonctionnent sur le même principe que l'unité précédente, sauf que les différentes PHTs sont fusionnées en une seule grosse PHT. C'est ce que font les unités de prédiction '''''gshare''''' et '''''gselect''''', ainsi que leurs dérivées. Avec les unités de prédiction '''''gselect''''', on concatène les bits de poids faible de l'adresse du branchement et l'historique pour obtenir le numéro du compteur à saturation à utiliser. [[File:Prédiction « gselect ».png|centre|vignette|upright=2|Prédiction « gselect ».]] Avec les unités de prédiction '''''gshare''''', on fait un XOR entre le registre d'historique et les bits de poids faible de l'adresse du branchement. Le résultat de ce XOR donne le numéro du compteur à utiliser. Avec cette technique, on utilise une PHT globale et un historique global, mais le registre d'historique est manipulé de manière à limiter l'''aliasing''. [[File:Prédiction « gshare ».png|centre|vignette|upright=2|Prédiction « gshare ».]] Intuitivement, on pourrait croire que les unités de prédiction gselect ont de meilleures performances. C'est vrai que les unités gshare combinent l'adresse du branchement et l'historique d'une manière qui fait perdre un peu d'information (impossible de retrouver l'adresse du branchement et l'historique à partir du résultat du XOR), alors que les unités gselect conservent ces informations. Mais il faut prendre en compte le fait que les deux unités doivent se comparer à PHT identique, de même taille. Prenons par exemple une PHT de 256 compteurs, adressée par 8 bits. Avec une unité gselect, les 8 bits sont utilisés à la fois pour l'historique et pour les bits de poids faible de l'adresse du branchement. Alors qu'avec une unité gshare, on peut utiliser un historique de 8 bits et les 8 bits de poids faible de l'adresse du branchement. L'augmentation de la taille de l'historique et du nombre de bits utilisés fait que l’''aliasing'' est réduit, et cela compense le fait que l'on fait un XOR au lieu d'une concaténation. Les unités précédentes réduisent l'''aliasing'' en segmentant la PHT : telles entrées sont allouées à un branchement, telles entrées à tel autre branchement, etc. Cependant, l'historique local n'est pas utilisé. Mais quitte à allouer des entrées à un branchement, autant en profiter pour leur associer son historique local. Il s'agit alors d'approches hybrides. Par exemple, on peut citer la '''prédiction mixte''' (''alloyed predictor''). Avec celle-ci, la table des compteurs à saturation est adressée avec la concaténation : de bits de l'adresse du branchement, du registre d'historique global et du registre d'historique local adressé par l'adresse du branchement. [[File:Prédiction mixte.jpg|centre|vignette|upright=2|Prédiction mixte.]] Une version optimisée de ce circuit permet d’accéder à la PHT et à la BHT en parallèle. Avec elle les bits de poids faible de l'adresse du branchement sont concaténés avec l'historique global. Le résultat est ensuite envoyé à plusieurs PHT locales distinctes, en parallèle. Les différences PHT envoient leurs résultats à un multiplexeur, commandé par l'historique local, qui sélectionne le résultat adéquat. L'inconvénient de cette mise en œuvre est que le circuit est assez gros, notamment en raison de la présence de plusieurs PHT séparées. [[File:Alloyed predictor optimisé.jpg|centre|vignette|upright=2|''Alloyed predictor'' optimisé]] ===La mitigation des interférences par la structuration en caches des PHTs=== Les techniques précédentes utilisent les bits de poids faible de l'adresse pour sélectionner la PHT adéquate, ou pour sélectionner le compteur dans la PHT. Mais le reste de l'adresse n'est pas utilisé. Cependant, il est théoriquement possible de conserver les bits de poids fort, afin d'identifier le branchement associé à un compteur. Pour cela, chaque compteur à saturation se voit associé à un ''tag'', qui mémorise le reste de l'adresse du branchement associé au compteur, comme pour les mémoires caches. Lorsque l'unité de prédiction démarre une prédiction, elle récupère les bits de poids fort de l'adresse du branchement et compare ceux-ci avec le ''tag''. S’il y a un ''match'', c'est signe que le compteur est bien associé au branchement à prédire. Dans le cas contraire, c'est signe qu'on fait face à une situation d’''aliasing'' et le compteur n'est pas utilisé pour la prédiction. Le résultat transforme la PHT en une sorte de cache assez particulier, qui associe un compteur à saturation à chaque couple adresse-historique. La technique du paragraphe précédent peut être encore améliorée afin de réduire l’''aliasing''. L’''aliasing'' sur une unité de prédiction de branchement est similaire aux conflits d'accès au cache, où deux données/adresses atterrissent dans la même ligne de cache. Une PHT peut formellement être vue comme un cache directement adressé. Une solution pour limiter ces conflits d'accès à de tels, est de les transformer en un cache associatif à n voies. On peut faire la même chose avec les unités de prédiction de branchement, ce qui revient à dupliquer les PHT, de manière à ce que les bits de poids faible de l'adresse se voient attribuer à plusieurs compteurs à saturation, un par branchement possible. Ce faisant, on réduit les interférences entre branchements. Mais cette technique augmente la quantité de circuits utilisés et la consommation d'énergie, ainsi que le temps d'accès, sans compter qu'elle requiert d'utilisation de ''tags'' pour fonctionner à la perfection. Pour les unités de prédiction de branchement, les mesures à ce sujet ne semblent pas montrer un impact réellement important sur les performances, comparé à une organisation de type directement adressé. Il est possible de pousser la logique encore plus loin en ajoutant des caches de victime et d'autres mécanismes courant sur les caches usuels. ===La mitigation des interférences liées aux branchements biaisés=== L’''aliasing'' a un impact négatif quand deux branchements aliasés (qui sont attribués au même compteur à saturation) vont dans des sens différents : si l'un est non-pris, l'autre a de bonnes chances d'être pris (ou inversement). Or, la plupart des branchements sont biaisés, à savoir très fortement orientés dans une direction qu'une autre (presque toujours pris ou presque toujours non-pris, à plus de 90/99% de chances). Plus un branchement a une probabilité élevée d' être pris ou non-pris, plus sa capacité à interférer avec les autres est forte. Le pire des cas est quand deux branchements sont biaisés mais dans des directions inverses : l'interférence est maximale. En conséquence, les branchements biaisés ou quasi biaisés sont les plus problématiques pour l’''aliasing''. Or, il est possible de concevoir des prédicteurs de branchements qui atténuent fortement les interférences des branchements biaisés ou quasi biaisés. C’est le cas de l’''agree predictor'' et de l'unité de prédiction bimodale, que nous allons voir dans ce qui suit. Les branchements très souvent pris vont interférer très fortement avec les branchements très souvent non-pris, et inversement. Par contre, les branchements très souvent pris n'interféreront pas entre eux, de même que les branchements non-pris iront bien ensemble. D'où l'idée de séparer ces deux types de branchements dans des PHT séparées, pour éviter le mauvais ''aliasing''. L''''unité de prédiction bimodale''' part d'une unité gshare, et sépare la PHT globale en deux : une PHT dédiée aux branchements qui sont très souvent pris et une autre pour les branchements très peu pris. Les deux PHT fournissent chacune une prédiction. La bonne prédiction est choisie par un multiplexeur commandé par une unité de sélection, qui se charge de déduire quelle unité a raison. Cette unité de sélection est une unité de prédiction basée sur des compteurs à saturation de 2 bits. On peut encore améliorer l'unité de sélection de prédiction en n'utilisant non pas des compteurs à saturation, mais une unité de prédiction dynamique à deux niveaux, ou toute autre unité vue auparavant. [[File:Prédiction bimodale.jpg|centre|vignette|upright=2|Prédiction bimodale.]] La '''prédiction par consensus''' (''agree predictor'') combine une unité de prédiction à historique global (idéalement de type gshare ou gselect, mais une unité normale marche aussi) et des compteurs à saturation. Il s'agit techniquement d'une technique de prédiction mixte, à la fois globale et locale. Le BTB intègre un compteur à saturation de un bit, qui indique la direction privilégiée du branchement. L'unité à historique global a un fonctionnement changé : elle calcule non pas la probabilité que le branchement soit pris ou non-pris, mais la probabilité que le résultat du branchement soit compatible avec le résultat de l'unité de prédiction de 1 bit. La prédiction finale vérifie que ces deux circuits sont d'accord, en faisant un NXOR entre les résultats des deux unités de prédiction de branchement. [[File:Prédiction par consensus.png|centre|vignette|upright=2|Prédiction par consensus.]] Des calculs simples de probabilités montrent que l'''agree predictor'' a des résultats assez importants. Ses résultats sont d'autant meilleurs que les branchements sont biaisés et penchent fortement vers le côté pris ou non-pris. ==La prédiction de branchement avec des perceptrons== Les méthodes précédentes utilisent au moins un compteur à saturation par historique possible. Sachant qu'il y a 2^n valeurs possibles pour un historique de n bits, le nombre de compteurs à saturation augmente exponentiellement avec la taille de l'historique. Ce qui limite grandement la taille de l'historique, alors qu'on aurait au contraire besoin d'un historique assez large pour faire des prédictions excellentes. Pour contourner le problème, des alternatives basées sur des techniques d'apprentissage artificiel (''machine learning'') existent. Un exemple est celui des unités de prédiction de branchement dites de ''neural branch prediction'', basée sur des perceptrons. Dans ce qui suit, nous les appelerons des '''unités perceptron'''. Les perceptrons sont un algorithme d'apprentissage automatique très simple, ce qui fait que les explications qui vont suivre devraient être simples. En théorie, il est possible d'utiliser des techniques d'apprentissage automatique plus élaborées, mais cela n'est pas possible en pratique. Rappelons qu'une unité de prédiction de branchement doit idéalement fournir sa prédiction en un cycle d'horloge. L'implémentation d'un perceptron est déjà problématique de ce point de vue, car elle demande d'utiliser beaucoup de circuits arithmétiques complexes (multiplication et addition). Autant dire que les autres techniques de ''machine learning'', encore plus gourmandes en calculs, ne sont pas praticables. ===Les perceptrons et leur usage en tant qu'unité de prédiction de branchements=== L'idée derrière les unités perceptron est que certains bits de l'historique sont plus importants que d'autres. En soi, ce genre de situation est réaliste. Il arrive que certains branchements soient pris uniquement si les deux branchements précédents ne le sont pas, ou que si l'avant-dernier branchement est lui aussi pris. Mais les autres résultats de branchement présents dans l'historique ne sont pas utiles. En conséquence, deux historiques semblables, qui ne sont différents que d'un ou deux bits, peuvent donner des prédictions presque identiques. Dans ce cas, on peut faire la prédiction en n'utilisant que les bits identiques entre ces historiques, ou du moins en leur donnant plus d'importance qu'aux autres. On exploite alors des corrélations avec certains bits de l'historique, plutôt qu'avec l'historique entier lui-même. Pour coder ces corrélations, on associe un coefficient à chaque bit de l'historique, qui indique à quel point ce bit est important pour déterminer le résultat final. Ce coefficient est proportionnel à la corrélation entre le bit de l'historique associé, et le résultat du branchement à prédire. Les coefficients sont des nombres entiers qui peuvent être positifs, nuls ou négatifs. Un coefficient positif signifie que si le branchement associé a été pris, alors le branchement à prédire a de bonnes chances de l'être aussi (et inversement). Un coefficient nul signifie que les deux branchements sont indépendants et que le bit de l'historique associé n'est pas à prendre en compte. Pour un coefficient négatif, cela signifie que si le branchement associé a été pris, alors le branchement à prédire a de bonnes chances d'être non-pris (et inversement). Les coefficients en question sont généralement compris entre -1 et 1. Une fois qu'on connait ces coefficients de corrélations, on peut calculer la probabilité que le branchement à prédire soit pris, avec des calculs arithmétiques simples. Dans les calculs qui vont suivre, un branchement pris est codé par un 1, alors qu'un branchement non-pris est codé par un -1 ! Ce détail permet de simplifier grandement les calculs. Dans sa version la plus simple, le perceptron calcule un nombre Z qui est positif ou nul si le branchement est pris, négatif s'il ne l'est pas. Tous les coefficients <math>p_i</math> sont alors des nombres relatifs, pouvant être positifs, nuls ou négatifs. Ils sont généralement compris entre -1 et 1. La formule devient donc celle-ci : : <math>Z = w_0 + \sum_i (h_i \times w_i)</math> [[File:ArtificialNeuronModel francais.png|centre|vignette|upright=2|Perceptron.]] : Sur le plan mathématique, le perceptron effectue un produit scalaire entre deux vecteurs : l'historique et le vecteur des poids. Choisir les coefficients adéquats est le but de l'algorithme du perceptron. L'unité de prédiction part de poids par défaut, qui sont mis à jour à chaque bonne ou mauvaise prédiction. Toute la magie de cet algorithme tient dans la manière dont sont mis à jour les poids. L'idée est de prendre le vecteur des poids, puis d'ajouter ou soustraire l'historique suivant le résultat du branchement. Les poids évoluent ainsi à chaque branchement, améliorant leurs prédictions d'une exécution à l'autre. La règle de mise à jour des poids est donc la suivante : : <math>w_i \leftarrow w_i + t \times h_i</math>, avec t = 1 pour un branchement pris, -1 pour un branchement non-pris. ===L'implémentation matérielle des perceptrons et ses optimisations=== Les calculs réalisés par un perceptrons sont simples, juste des multiplications et des additions et cela se sent dans la conception du circuit. L'unité perceptron utilise un perceptron par entrée du BTB, un par branchement pour simplifier. Pour cela, on utilise une mémoire SRAM qui mémorise les poids de chaque perceptron. La SRAM est adressée par les bits de poids faible du ''program counter'', de l'adresse du branchement. Une fois les poids récupérés, ils sont envoyés à un circuit de calcul de la prédiction, composé de circuits multiplieurs suivis par un additionneur multi-opérande. Le circuit de mise à jour des poids récupère la prédiction, les poids du perceptron, mais aussi le résultat du branchement. La mise à jour étant une simple addition, le circuit est composé d'additionneurs/soustracteurs, couplés à des registres pour mémoriser la prédiction et les poids du perceptron en attendant que le résultat du branchement soit disponible. Vu qu'il y a un délai de quelques cycles avant que le résultat du branchement soit disponible et que les prédictions doivent continuer pendant ce temps, les poids du perceptron et la prédiction sont stockés dans des FIFOs lues/écrites à chaque cycle. [[File:Unité de prédiction de branchement basée sur des perceptrons.png|centre|vignette|upright=2|Unité de prédiction de branchement basée sur des perceptrons]] Rien de bien compliqué sur le principe, mais rappelons que le perceptron doit fournir un résultat en moins d'un cycle d'horloge, et cela tient compte de l'accès au BTB. C'est un temps très court, surtout pour un circuit qui implique des multiplications. Les multiplications prennent facilement deux à trois cycles d'horloge. Autant dire que si on ajoute le temps d'accès à une petite SRAM, pour récupérer le perceptron, c'est presque impossible. Mais quelques optimisations simples permettent de rendre le calcul plus rapide. Premièrement, la taille de la SRAM est réduite grâce à quelques économies assez simples. Notamment, le perceptron utilise des poids codés sur quelques bits, généralement un octet, parfois 7 ou 5 bits, guère plus. Les poids sont encodés en complément à 1 ou en complément à 2, là où les perceptrons normaux utilisent des nombres flottants. Rien que ces deux choix simplifient les circuits et les rendent plus rapides. Le désavantage d'utiliser peu de bits par poids est une perte mineure en termes de taux de prédiction, qui est plus que compensée par l'économie en portes logiques, qui permet d'augmenter la taille de la SRAM. D'autres optimisations portent sur le circuit de calcul. Par exemple, la multiplication <math>h_i \times w_i</math> est facultative. Vu que l'historique contient les valeurs 1 et -1, il suffit d'additionner le poids associé si la valeur est 1 et de le soustraire s'il vaut -1. On peut même simplifier la soustraction et se limiter à une complémentation à un (une inversion des bits du poids). En faisant cela, les circuits multiplieurs disparaissent et le circuit de prédiction se résume à un simple additionneur multi-opérande couplé à quelques inverseurs commandables. Certains perceptrons prennent en compte à la fois l'historique global et l'historique local pour faire leur prédiction. Les deux historiques sont simplement concaténés et envoyés en entrée du perceptron. Cela marche assez bien, car les perceptrons peuvent utiliser des historiques de grande taille sans problèmes. Par contre, il y a besoin d'ajouter une ''branch history table'' au circuit, ce qui demande des portes logiques en plus, mais aussi un temps de calcul supplémentaire (l'accès à cette table n'est pas gratuit). ===Les avantages et inconvénients des perceptrons pour la prédiction de branchements=== L'avantage des unités perceptron est qu'elles utilisent un nombre de portes logiques qui est proportionnel à la taille de l'historique, et non pas exponentiel. Cela permet d'utiliser un historique de grande taille, et donc d'obtenir un taux de bonnes prédictions très élevé. Leur désavantage est que leurs performances de prédiction sont moins bonnes à historique équivalent. Les unités perceptrons ont des taux de prédiction élevés, mais seulement quand certaines conditions mathématiques sont respectées par les historiques. La condition en question est que les historiques correspondants à un branchement pris et les historiques où ce même branchement est non-pris doivent être linéairement séparables. Heureusement, les branchements des programmes informatiques sont une majorité à respecter la condition de séparation linéaire, ce qui rend les perceptrons parfaitement adaptés à la prédiction de branchements. La notion de linéairement séparable est un peu difficile à comprendre. Pour l'expliquer, imaginez que les N bits de l'historique forme un espace à N-dimensions discrets. Chaque historique est un point dans cet espace N-dimensionnel et chaque bit sert de coordonnée pour se repérer dans cet espace. Une fonction est linéairement séparable si l'espace N-dimensionnel peut être coupé en deux par un hyperplan : d'un côté de ce plan se trouvent les historiques des branchements pris, de l'autre se trouvent les historiques des branchements non-pris. Cet hyperplan est défini par l'ensemble des points qui respecte l'équation suivante : : <math>w_0 + \sum_i (h_i \times w_i) = 0</math> Un exemple où cette condition est respectée est quand le résultat d'une prédiction se calcule avec un ET entre plusieurs branchements de l'historique. Si un branchement est pris quand plusieurs branchements de l’historique sont tous pris ou tous non-pris, la condition est respectée et le perceptron donnera des prédictions correctes. Un exemple où cette condition n'est pas respectée est un banal XOR. Prenons l'exemple d'un historique global de 2 bits. Imaginons que le branchement à prédire soit pris : soit quand le premier branchement de l'historique est pris et le second non-pris, soit quand le premier est non-pris et le second pris. Concrètement, la prédiction exacte se calcule en faisant un XOR entre les deux bits de l'historique. Dans ce cas, la condition "linéairement séparable" n'est pas respectée et le perceptron n'aura pas des performances optimales. Les unités perceptron utilisent idéalement un perceptron par branchement. Cela demande d'associer, l'adresse du branchement pour chaque perceptron, généralement en donnant un perceptron par entrée du BTB. Mais une telle organisation n'est pas possible en pratique, car elle demanderait d'ajouter un ''tag'' à chaque perceptron/entrée du BTB, ce qui demanderait beaucoup de circuits et de ressources matérielles. Dans les faits l'association entre un branchement et un perceptron se fait en utilisant les bits de poids faible de l'adresse de branchement. Mais faire cela entrainera l'apparition de phénomènes d'''aliasing'', comme pour les unités de prédiction à deux niveaux. Les techniques vues précédemment peuvent en théorie être adaptées sur les unités à perceptrons, dans une certaine mesure. Notons que les perceptrons sont des algorithmes ou des circuits qui permettent de classer des données d'entrée en deux types. Ici, la donnée d'entrée est l'historique global et la sortie du perceptron indique si le branchement prédit est pris ou non-pris. Ils ne sont donc pas adaptés à d'autres tâches de prédiction, comme pour le préchargement des lignes de cache ou la prédiction de l'adresse de destination d'un branchement, ou toute autre tâche d'exécution spéculative un tant soit peu complexe. Leur utilisation reste cantonnée à la prédiction de branchement proprement dit, guère plus. Par contre, les autres techniques vues plus haut peuvent être utilisées pour le préchargement des lignes de cache, ou d'autres mécanismes de prédiction plus complexes, que nous aborderons dans la suite du cours. ==La prédiction avec plusieurs unités de branchement distinctes== Il est possible de combiner plusieurs unités de prédiction de branchement différentes et de combiner leurs résultats en une seule prédiction. On peut par exemple combiner une unité de prédiction spécialisée dans les boucles, une unité de prédiction 2 bits basée sur des compteurs à saturation, une unité de prédiction statique et une unité ''gshare''. Tout le problème est de décider quelle unité a fait la bonne prédiction, comment combiner les résultats des différentes unités de prédiction. Soit on choisit un résultat qui parait plus fiable que les autres, soit on combine les résultats pour faire une sorte de moyenne des prédictions. La méthode la plus simple de choisir le résultat d'une unité de prédiction parmi toutes les autres. Pour implémenter le tout, il suffit d'un multiplexeur qui effectue le choix de la prédiction. Reste à commander le multiplexeur, ce qui demande un '''circuit méta-prédicteur''' qui sélectionne la bonne unité de prédiction. Il arrive que le circuit méta-prédicteur soit conçu avec les mêmes techniques que les unités de prédiction de branchement elle-mêmes. Dans le cas le plus simple, il s'agit d'un simple compteur à saturation, mis à jour suivant la concordance des prédictions avec le résultat effectif du branchement. Mais c'est loin d'être systématique. Une autre solution combine le résultat des différentes unités de prédiction avec une fonction mathématique adéquate. Avec un nombre impair d'unités de prédiction, une méthode assez efficace est de simplement prendre le résultat majoritaire. Si une majorité d'unités pense que le branchement est pris, alors on le considère comme pris, et comme non pris dans le cas contraire. Par contre, avec un nombre pair d'unités, cette méthode simple ne marche pas, il y a un risque d'égalité. L'unité de prédiction de branchement « '''''e-gskew''''' » utilise trois unités gshare et combine leurs résultats avec un vote à majorité. Les trois unités gshare utilisent des XOR légèrement différents, dans le sens où les bits de l'adresse du branchement choisi ne sont pas les mêmes dans les trois unités, sans compter que le résultat du XOR peut subir une modification qui dépend de l'unité de prédiction choisie. Autrement dit, la fonction de hachage qui associe une entrée à un branchement dépend de l'unité. [[File:Unité de prédiction « e-gskew ».png|centre|vignette|upright=2|Unité de prédiction « e-gskew ».]] Enfin, une dernière solution est que certaines unités de prédiction ont la priorité sur toutes les autres. Par exemple, prenons un processeur avec un ''return stack predictor'', une unité qui détecte les boucles et une unité de prédiction avec un historique. Si le ''return stack predictor'' détecte une instruction de retour de fonction, sa prédiction passera avant toutes les autres unités de prédiction. Et dans ce qui va suivre, c'est cette solution qui sera utilisé pour les unités de prédiction qu'on va étudier. ===La prédiction des branchements des boucles=== Les boucles sont à part, pour ce qui est de la prédiction de branchement. Rappelons qu'une boucle FOR/WHILE répète une série d'instruction N fois, le nombre N étant appelé le '''compteur de boucle'''. Ce qui fait que les branchements d'une boucle FOR sont pris n − 1 fois, la dernière exécution étant non prise. La conséquence est que les unités de prédiction usuelles sont assez mauvaises pour prédire les branchements des boucles. Par contre, la prédiction statique marche particulièrement bien comparé au reste. La prédiction statique prédit que les branchements des boucles sont toujours pris, ce qui donne des prédictions qui sont d'autant meilleures que la boucle est répétée un grand nombre de fois. Reste à gérer les branchements des boucles au mieux, en sachant cela. L'idée est d'utiliser plusieurs unités de prédiction séparées : une spécialisée dans les boucles et d'autres unités de prédiction. Uen unité de méta-prédiction se charge d'activer l'unité de prédiction des boucles dès qu'une boucle est détéctée. Les unités de prédiction modernes essayent de détecter les branchements des boucles afin de les mettre à part. En soi, ce n'est pas compliqué les branchements des boucles sont presque toujours des branchements descendants et réciproquement. De plus, ces branchements sont pris à chaque répétition de la boucle, et ne sont non-pris que quand on quitte la boucle. Le compteur de boucle analyse l'exécution des branchements, pour détecter les branchements des boucles. Un branchement est considéré comme un branchement de boucle si : il est pris N fois, avant de ne pas être pris une fois. Au moment où le branchement n'est plus pris, le ''Loop Predictor'' lui attribue une entrée dédié. L'entrée mémorise mémorise le nombre de fois que ce branchement a été pris, à savoir N, et est associée à un compteur. Le compteur est incrémenté/décrémenté à chaque exécution du branchement, et sa valeur est comparée à la valeur N. Quand le compteur atteint N+1, il est réinitialisé. Il s'agit d'une implémentation simple, mais est visiblement proche de celle utilisée sur le Pentium M, si j'en crois l'article "The Intel Pentium M Processor : Microarchitecture and Performance". Il faut noter que la boucle doit s'exécuter au moins une fois pour être détectée, et seules les exécutions suivantes sont prédites correctement. La boucle doit donc s'exécuter plusieurs fois, ce qui implique qu'elle est elle-même dans une boucle plus grande qu'elle. Une telle unité est donc capable de prédire spécifiquement les branchements de boucles FOR imbriquées, à savoir où une boucle FOR est dans une autre boucle FOR. Concrètement, cela permet de répéter la première boucle FOR plusieurs fois de suite, souvent un grand nombre de fois. Avec les boucles imbriquées, le compteur de boucle peut changer d'une répétition à l'autre, mais ce n'est que rarement le cas. Dans de nombreux cas, le compteur de boucle reste le même d'une répétition à l'autre. Ce qui fait qu'une fois la boucle exécutée une première fois, on peut prédire à la perfection les exécutions suivantes. Pour cela, il suffit de déterminer le compteur de boucle après la première exécution de la boucle, puis de le mémoriser dans un compteur. Lors des exécutions suivantes de la boucle, on compte le nombre de fois que le branchement de la boucle s'exécute : il est prédit comme pris tant que le compteur est différent du compteur de boucle, mais non-pris en cas d'égalité. ===Le prédicteur L-TAGE=== Il est possible d'utiliser plusieurs unités de prédiction, chacune utilisant des historiques de taille différente. Pour donner un exemple simple, on pourrait avoir une unité avec un historique de 2 bits, une autre avec un historique de 3 bits, une autre de 4 bits, etc. Une méta-unité de branchement prédit alors quelle taille d'historique donne les meilleurs résultats. Il est fréquent que les tailles d'historiques utilisées suivent une formule mathématique précise, appelée suite géométrique, ce qui est un mot bien barbare pour dire que l'on passe d'un historique au suivant en multipliant sa taille par une constante bien précise. Par exemple, on pourrait avoir des historiques de taille 2, 4, 8, 16, 32. C'est d'ailleurs le principe derrière les '''unités de branchement TAGE (''TAgged GEometric length predictor'')'''. Elles utilisent plusieurs registres d'historiques, chacun avec sa propre PHT, dont la taille suit une suite géométrique. Avec cette méthode, les branchements qui ont un motif répétitif sont alors parfaitement détectés, peu importe sa taille. Par exemple, un branchement avec un historique dont le cycle est pris, non-pris, non-pris, sera parfaitement prédit avec un historique de 3 bits, 6 bits, ou de 3*n bits, mais pas forcément avec un historique de 4 ou 8 bits. De ce fait, utiliser plusieurs unités de branchement avec plusieurs tailles d'historique fonctionne plutôt bien. Le prédicteur TAGE a été raffiné avec le temps, sur plusieurs points, que je ne vais pas détailler dans ce qui suit. Précisons cependant que les PHT sont structurées en caches, afin d'éliminer les phénomènes d'''aliasing''. L'ajout d'une unité de prédiction pour les boucles, a donné le '''prédicteur L-TAGE''', qui a lui-même été amélioré par la suite. Les unités de branchement TAGE sont les plus utilisées au moment où j'écris ces lignes (2026), et la majorité des processeurs x86 commerciaux utilisent une variante de ce prédicteur, à quelques exceptions près. Le '''prédicteur ITTAGE''' peut être adapté pour prédire des branchements indirects. La seule différence est que chaque table d'historique est associée non seulement à une prédiction pris/non-pris, mais aussi à une adresse de destination. L'idée derrière l'ITTAGE est que l'adresse de destination dépend de l'historique. L'adresse de destination dépend de ce qui se passe avant qu'on exécute le branchement indirect, donc de l'historique global. Il est donc fréquent que des historiques différents donne un résultat différent, que ce soit pour la prédiction pris/non-pris, mais aussi pour l'adresse de destination. L'ITTAGE associe donc une adresse de destination par ''pattern'' prédit. ===La prédiction de branchement à deux niveaux=== Les processeurs modernes utilisent une prédiction de branchement à deux niveaux. Une première unité de branchement fournit des résultats rapides et peu précis, la seconde a des résultats plus précis mais fournit ses résultats après quelques cycles d'horloge. La première unité envoie les adresses prédites au cache d'instruction, pour charger les instructions prédites et met à jour le ''program counter'' en conséquence. La seconde envoie les adresses prédites à l'étage de décodage d'instruction, histoire d'annuler des branchements considérés à tort comme pris/non-pris. Il y a donc une unité de prédiction rapide, dont le résultat est corrigé par une seconde unité. La seconde unité a le dernier mot, elle corrige ou annule les prédictions de la première. Ce qui lui vaut le nom, en anglais, d'''overiding predictor''. La correction se fait simplement en corrigeant le ''program counter'' et en forçant le décodeur à émettre des NOPs pendant quelques cycles, le temps d'éliminer les instructions chargées à tort. [[File:Double BTB sur les CPU modernes.png|centre|vignette|upright=2|Double BTB sur les CPU modernes.]] Les deux unités de prédiction n'utilisent pas le même algorithme. Par exemple, la microarchitecture Silvermont d'Intel utilisait deux unités de prédiction : une unité rapide basée sur des compteurs à saturation, et un prédicteur ''gshare'' lent. Les deux unités de prédiction utilisent des BTB séparés, de taille différente, au moins deux. Vu que les BTB sont des caches, on les appelle des '''BTB de niveau L1 et L2'''. Par exemple, les processeurs Bulldozer d'AMD disposent de deux BTBs : un BTB L1 de 512 entrées, et un BTB L2 de 5120 entrées. Le BTB de niveau L2 est souvent un cache de victime, qui mémorise les branchements retirés de la BTB de niveau L1. La BTB pour les branchements indirects vient en plus de ces deux BTBs, elle complète souvent la BTB de niveau L2. Le ''return stack predictor'' vient en plus des BTB L1 et L2, mais sa petite taille et sa rapidité font qu'il complète l'unité/BTB de niveau L1. Quelques processeurs ont trois niveaux de BTB, avec un niveau L0, L1 et L2. La BTB L0 aliment le cache d'instruction, alors que la L1 et la L2 préviennent les décodeurs d'instruction. La différence entre la L1 et la L2 est que la L2 met plus de temps à prévenir les décodeurs. Les branchements peuvent donc être annulés/forcés à deux étages de décodage différents. Pour donner quelques exemples, les processeurs Zen d'AMD disposent de plusieurs BTBs, avec des niveaux L0, L1 et L2. Le tableau ci-dessous vous donne la taille et la latence de chaque niveau de BTB. La BTB de niveau L0 alimente le cache d'instruction, la L1 permet d'annuler les instructions au premier cycle de décodage, la BTB L2 annule les instructions vers le milieu/fin du décodage. {|class="wikitable" |- ! rowspan="3" | Zen 2 | 16 || 0 |- | 512 || 1 |- | 7168 || 4 |- ! rowspan="3" | Zen 3 | 16 || 0 |- | 1024 || 1 |- | 6656 + 1536 (branchements indirects)|| 4 |} ==Les optimisations de la prédiction de branchements== La prédiction de branchement est complexe et toute économie est bonne à prendre. Pour améliorer les performances, ou simplement améliorer l'utilisation du BTB et des PHT, diverses techniques ont été inventées. Ces techniques marchent quel que soit l'unité de prédiction prise en compte. Elles peuvent s'appliquer aussi bien aux unités basées sur des perceptrons, que sur des unités à deux niveaux ou des unités de prédiction dynamique en général. Ces techniques sont assez variés : certaines profitent du fait que de nombreux branchements sont biaisés, d'autres tentent de réduire les interférences entre branchements sans utiliser l'historique global/local, etc. ===Le filtrage de branchements=== Une première optimisation permet d'économiser l'usage des différentes ressources matérielles, comme la BTB ou les PHT, en les réservant aux branchements non-biaisés. Idéalement, les branchements biaisés peuvent être prédits en utilisant des techniques très simples. D'où l'idée d'utiliser deux unités de prédiction spécialisées : une pour les branchements biaisés et une autre plus complexe. L'unité de prédiction pour les branchements biaisés est une unité de prédiction à 1 bit qui indique si le branchement est pris ou non, ce qui suffit largement pour de tels branchements. Cette technique s'appelle le '''filtrage de branchement''' et son nom est assez parlant. On filtre les branchements biaisés pour éviter qu'ils utilisent des PHT et d'autres ressources qui gagneraient à être utilisées pour des branchements plus difficiles à prédire. Appliquer cette idée demande de reconnaître les branchements fortement biaisés d'une manière ou d'une autre, ce qui est plus facile à dire qu'à faire. Une solution similaire enregistre quels branchements sont biaisés dans le BTB, et utilise cette information pour faire des prédictions. ===L'usage de fonctions de hashage pour indexer les diverses SRAM/tables=== Plus haut, nous avons vu que les unités de prédiction de branchement contiennent des structures qui sont adressées par l'adresse de branchement. Tel est le cas du ''Branch Target Buffer'', de certaines PHT globales ou locales, de la ''Branch History Table'' qui stocke les historiques locaux, ou encore de la SRAM des poids des unités à base de perceptrons. En pratique, ces structures sont adressées non pas par l'adresse de branchement complète, mais pas les bits de poids faible de cette adresse. Cela a pour conséquence l'apparition d'un ''aliasing'' lié au fait que ces structures vont confondre deux branchements pour lesquels les bits de poids faible de l'adresse sont identiques. Il est possible d'utiliser autre chose que les bits de poids faible de l'adresse du branchement, afin de limiter les interférences. Et les possibilités sont multiples. Les possibilités en question s'inspirent des traitements effectués sur les adresses des banques dans les mémoires évoluées. Une première solution serait de faire un XOR entre les bits de poids faible de l'adresse du branchement, et d'autres bits de cette même adresse. Ainsi, deux branchements éloignés en mémoire donneraient des résultats différents, même si leurs bits de poids faible sont identiques. Une autre possibilité serait de diviser l'adresse du branchement par un nombre et de garder le reste. Ce calcul de modulo n'est en soi pas très différent du fait de conserver seulement les bits de poids faible. Conserver les N bits de poids faible consiste en effet à prendre le modulo par 2^N de l'adresse. Ici, l'idée serait de faire un modulo par un nombre P, qui serait un nombre premier proche de 2^N. Le fait que le nombre soit premier limite les cas où deux adresses différentes donneraient le même reste, ce qui réduit l'''aliasing''. Le fait de prendre un nombre proche de 2^N pour une entrée de N bits est que le résultat reste assez proche de ce qu'on obtiendrait en gardant les bits de poids faible, cette dernière étant une solution pas trop mauvaise. D'autres possibilités similaires se basent sur des réductions polynomiales ou d'autres astuces impliquant des nombres premiers. L'efficacité de ces méthodes dépend grandement de la taille de la SRAM/table considérée, des adresses des branchements et de beaucoup d'autres paramètres. La réduction des interférences par telle ou telle méthode dépend aussi de l'unité de prédiction considérée. Les résultats ne sont pas les mêmes selon que l'on parle d'une unité à perceptrons ou d'une unité à deux niveaux ou d'unités à base de compteurs à saturation, ou que l'on parle du BTB. Par contre, toutes les méthodes ne sont pas équivalentes en termes de temps de calcul ou de portes logiques. Autant la première solution avec un XOR ajoute un temps de calcul négligeable et quelques portes logiques, autant les autres méthodes requièrent l'ajout d'un diviseur très lent et gourmand en portes logiques pour calculer des modulos. Elles ne sont généralement pas praticables pour ces raisons, le temps de calcul serait trop élevé. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Le pipeline | prevText=Le pipeline | next=Les optimisations du chargement des instructions | nextText=Les optimisations du chargement des instructions }} </noinclude> 2ght3um946ehbpyz15jkn672ilswy1b 774412 774411 2026-10-07T22:32:35Z Mewtow 31375 /* La prédiction de branchement à deux niveaux */ Déplacement section autre 774412 wikitext text/x-wiki Les branchements sont pris en charge par plusieurs étages du pipeline. Exécuter un branchement demande de savoir deux choses : s'il est pris ou non, quelle est l'adresse de destination. L'adresse de destination est localisée grâce au mode d'adressage du branchement. Avec le mode d'adressage direct, elle est extraite de l'instruction par le séquenceur. Avec l'adressage indirect, elle est lue dans un registre. Avec les branchements relatifs, elle est calculée via un additionneur, qui additionne un décalage au ''program counter''. L'additionneur peut être soit dans l'étage de ''PC Update'', soit dans l'unité de calcul de l'étage d'exécution. Passons maintenant au résultat du branchement, à savoir s'il est pris ou non. Pour savoir si le branchement est pris, il faut distinguer les branchements conditionnels et inconditionnels. Pour rappel, les branchements inconditionnels regroupent les appels de fonction, les simples instructions de saut, et quelques autres. Ils sont toujours pris, ils s'exécutent toujours. À partir du moment où le décodeur a décodé un branchement inconditionnel, il sait que le branchement est pris. Pour les branchements conditionnels, ils sont pris seulement si une condition précise est respectée. On sait s'ils sont exécutés une fois que la condition est calculée par l'unité de calcul, ou par une unité de branchement spécialisée. [[File:Gestion des branchements sur un pipeline.png|centre|vignette|upright=2.0|Gestion des branchements conditionnels sur un pipeline.]] Sur le schéma précédent, les branchements inconditionnels ne sont pas montrés. Pour cela, il faudrait rajouter une connexion du décodeur vers le multiplexeur de branchement. De plus, il faudrait aussi rajouter de quoi retarder l'exécution du branchement seulement une fois que l'adresse est connue. Par exemple, elle est connue un cycle d'horloge plus si l'adresse est lue depuis les registres, comparé à un branchement direct. ==Les branchements et les exceptions imprécises/précises== Un branchement est censé altérer le ''program counter'' immédiatement, en un cycle d'horloge. Mais sur un pipeline, Le branchement fait bien reprendre le processeur au bon endroit, mais avec du retard. La raison est qu'un branchement met plusieurs cycles pour s'exécuter. Il faut plusieurs cycles pour déterminer quelle est l'adresse de destination, temps qui dépend de là où est lue l'adresse. Et on sait si le branchement est pris seulement au niveau du décodeur, voire au niveau de l'unité de calcul. Le branchement doit traverser plusieurs étages avant qu'on connaisse son résultat. Il y a bien 3 à 4 cycles avant cela. Et pendant ce temps de trajet, des instructions seront chargées dans le pipeline, potentiellement à tort. Si le branchement n'est pas pris, ce n'est pas grave : les instructions n'ont pas été chargées à tort. Mais dans le cas contraire, les instructions n'auraient pas dû rentrer dans le pipeline. Le problème est similaire à celui observé avec les exceptions précises/imprécises. La différence est que les branchements sont des instructions machines, pas les exceptions matérielles. Mais le problème des instructions chargées à tort est le même dans les grandes lignes. Une différence est que les branchements sont résolus plus ou moins au même étage, alors que les exceptions peuvent survenir dans tout le pipeline. Une autre différence, bien plus importante, est que les branchements non-pris ne chargent pas d'instructions à tort et qu'il faut faire la différence entre branchement pris et non-pris. Dans la suite, nous parlerons d'instructions fautives pour parler des instructions chargées à tort. Pour corriger ce problème, il existe une solution logicielle. Le compilateur fait suivre les branchements par des instructions qui ne font rien, des NOP (No OPeration) : c'est ce qu'on appelle un '''délai de branchement'''. L'idée est que toutes les instructions chargées à tort sont des NOPs, qui ne font rien. Mais le nombre de NOP à ajouter dépend du pipeline du processeur, ce qui peut poser des problèmes de compatibilité. Mais d'autres solutions, matérielles cette fois, sont utilisées de nos jours. ===L'annulation des instructions au décodage=== Les branchements inconditionnels sont dans un cas plus favorable : ils sont identifiés dans l'unité de décodage et sont toujours pris. D'autres branchements sont dans ce cas : les appels de fonctions, les interruptions logicielles. Avec eux, le décodeur annule les instructions fautives, il les remplace par des instructions NOP. L'étage de décodage sait que les instructions suivant un branchement inconditionnel ont été chargées à tort. Précisément N instructions, avec N le nombre d'étages entre le chargement et le décodage (inclus). Les N instructions suivantes sont tout simplement annulés, elles sont remplacées par des NOPs. L'implémentation demande juste un simple compteur et quelques circuits annexes. Cependant, la technique ne marche pas du tout pour les branchements conditionnels. En effet, on ne sait pas s'ils sont pris ou non à l'étage de décodage. Du moins pas avec un pipeline normal. Mais certains processeurs, basés sur le pipeline RISC classique, calculaient les branchements pendant l'étape de décodage, dans une ALU spécialisée qui travaille en parallèle de l'unité de décodage. Le défaut de cette technique est que l'ALU en question doit lire les registres. Pour rappel, sur ce pipeline, l'étape de décodage effectue la lecture des opérandes dans les registres, en parallèle de l'unité de décodage. Mais ce qu'on n'a pas dit dans le chapitre précédent, c'est qu'elle effectue aussi le calcul des branchements dans une mini-ALU séparée ! Ce faisant, le résultat du branchement est connu dès la fin de l'étape de décodage. ===L'usage du système d'exceptions précises pour les branchements conditionnels=== La solution gère le problème en empêchant les instructions chargées à tort d'enregistrer leurs résultats, afin qu'elles n'aient pas le moindre effet sur l'état du processeur. Le hardware pour les exceptions précises, décrit quelques paragraphes avant, est réutilisé pour cela. Les branchements conditionnels peuvent être pris ou non-pris et on doit tenir compte de ces deux cas. Si le branchement n'est pas pris, les instructions n'ont pas été chargées à tort, elles peuvent enregistrer leur résultat dans les registres. Par contre, ce n'est pas le cas pour les branchements pris, les mécanismes d'exceptions précise doivent alors d'activer. Pour cela, la meilleure solution est que l'unité de calcul génère un indicateur d'exception si le branchement n'est pas pris. L'ALU effectue la comparaison et génère ou non l'indicateur d'exception adéquat suivant le résultat de celle-ci. L'idée est d'utiliser un indicateur d'exception particulier, qui précise qu'un branchement a été pris, signe que des instructions ont été chargées à tort. L'indicateur est propagé dans le pipeline, jusqu'à atteindre l'étage d'enregistrement dans les registres. Cet étage décide s'il faut autoriser ou non les écritures qu'il recoit, pour annuler les instructions chargées à tort. Le ''program counter'' est corrigé immédiatement, simplement en exécutant le branchement. Reste à savoir qui génère cet indicateur d'exception de branchement pris. Diverses optimisations visent à éliminer les instructions chargées à tort, à faire en sorte que des instructions utiles soient chargées. Le chapitre suivant va détailler ces optimisations dans le détail, qui sont regroupées sous le terme de prédiction de branchement. Elles ne fonctionnent pas pour les exceptions précises, seulement les branchements. ===La prédiction de branchement=== Les techniques précédentes ont pour défaut que de nombreux cycles d'horloge sont gâchés à cause des instructions fautives. Pour éviter ce gâchis, les concepteurs de processeurs ont inventé l'''exécution spéculative de branchement'', qui consiste à deviner l'adresse de destination du branchement et charger les bonnes instructions dès le départ. Le but est de ne pas charger d'instruction à tort, en prédisant quelles seront les bonnes instructions à charger. Et cela demande de résoudre trois problèmes : * reconnaître les branchements ; * savoir si un branchement sera exécuté ou non : c'est la '''prédiction de branchement''' ; * dans le cas où un branchement serait exécuté, il faut aussi savoir quelle est l'adresse de destination : c'est la '''prédiction de l'adresse de destination''' d'un branchement, aussi appelée ''branch target prediction''. Pour résoudre le second problème, le processeur contient un circuit qui déterminer si le branchement est pris (on doit brancher vers l'adresse de destination) ou non pris (on poursuit l’exécution du programme immédiatement après le branchement) : c'est l''''unité de prédiction de branchement'''. La prédiction de direction de branchement est elle aussi déléguée à un circuit spécialisé : l’'''unité de prédiction de direction de branchement'''. C'est l'unité de prédiction de branchement qui autorise l'unité de prédiction de destination de branchement à modifier le ''program counter''. Dans certains processeurs, les deux unités sont regroupées dans le même circuit. [[File:Relation entre prédiction de branchement et de direction de branchement.png|centre|vignette|upright=2|Relation entre prédiction de branchement et de direction de branchement.]] : Au lieu de parler d'unité de prédiction de branchement, j'utiliserais parfois le terme '''prédicteur''', sous-entendu ''prédicteur de branchement''. Avant de poursuivre, faisons une remarque très importante. J'ai parlé d'unité de prédiction de branchement, comme s'il n'y en avait qu'une seule. Mais en réalité, n'en avoir qu'une seule est l'exception. La règle est d'avoir plusieurs unités de branchements spécialisées. Un processeur moderne utilise plusieurs prédicteurs séparés, chacun prédisant un type de branchement bien précis. Il y a pas exemple un prédicteur spécialisé pour les boucles, un autre pour les instructions de retour de fonctions, un autre pour les branchements directs, un autre pour les branchements indirects. De plus, les processeurs modernes mélangent des prédicteurs rapides et imprécis, avec des prédicteurs lents mais précis. Les prédicteurs de premier type envoient les adresses prédites au cache d'instruction, pour charger les instructions prédites et mettre à jour le ''program counter'' en conséquence. Les prédicteurs lents et précis envoient leurs résultats au décodeur d'instruction, histoire d'annuler des branchements considérés à tort comme pris/non-pris. Il y a donc des prédicteurs rapides, dont le résultat est corrigé par des prédicteurs plus lents. La seconde unité a le dernier mot, elle corrige ou annule les prédictions de la première. Ce qui lui vaut le nom, en anglais, d'''overiding predictor''. Ce détail aura rapidement son imortance. ==Les erreurs de prédiction de branchement== Le processeur peut parfaitement se tromper en faisant ses prédictions, ce qui charge des instructions par erreur dans le pipeline. Dans ce cas, on parle d''''erreur de prédiction'''. Les erreurs de prédiction sont corrigées par le système d'exception précises, ou un équivalent. En clair, les instructions chargées à tort se propagent jusqu'à la sortie du pipeline, où leurs résultats sont alors annulés. N'importe quelle technique de gestion des exceptions précise fait le travail, mais les processeurs modernes utilisent un tampon de réordonnancement (''reorder buffer'') qu'on détaillera dans quelques chapitres. Détecter les erreurs de prédiction demande d'ajouter un circuit dans le processeur : l’''unité de vérification de branchement'' (''branch verification unit''). Elle génère un indicateur d'exception, qui est géré par le système d'exception précise. Pour générer l'indicateur, elle compare l'adresse de destination prédite et celle calculée en exécutant le branchement. Pour cela, l'adresse prédite doit être propagée dans le pipeline jusqu’à ce que l'adresse de destination du branchement soit calculée. [[File:Unité de vérification de branchement.png|centre|vignette|upright=2|Unité de vérification de branchement]] ===La pénalité de mauvaise prédiction=== Le temps pour vider le pipeline des instructions fautives dépend du pipeline. La dernière instruction à être chargée dans le pipeline le sera en même temps qu'on détecte l'erreur de prédiction. Il faudra attendre que cette instruction quitte le pipeline, et donc qu'elle traverse tous les étages. Il y a donc un temps d'attente pour collecter les résultats de ces instructions et la décision de ne pas enregistrer leurs résultats dans les registres Le nombre d'instructions chargées à tort dépend de où est détectée l'erreur de prédiction. Plus le résultat du branchement est connu tard, plus le nombre d'instructions chargées à tort sera grand, et plus le nombre de cycles d'horloge gâchés le sera aussi. Le nombre de cycles gâchés s'appelle la '''pénalité de mauvaise prédiction'''. Elle correspond précisément au nombre de cycles/étages entre le premier étage du pipeline, et l'étage où le branchement est résolu (on connait l'adresse à laquelle brancher). Plus celui-ci est éloigné du début du pipeline, plus la pénalité est importante. [[File:MIT 6.823 Computer System Architecture, Fall 2005 - P-3 vs. P-4 Pipelines - 4473425702.jpg|centre|vignette|upright=2.5|Comparaison de la pénalité de mauvaise prédiction entre le Pentium 3 et le Pentium 4 d'Intel.]] Elle est généralement d'autant plus longue que le pipeline lui-même est long. Les concepteurs de processeurs font donc face à un compromis : un pipeline long permet d'exécuter plus d'instructions en parallèle, mais cela augmente aussi la pénalité de mauvaise prédiction. Un pipeline de 10-15 étapes est un bon compromis. Les pipelines plus longs ont besoin d'une prédiction de branchement élaborée pour fonctionner correctement. Plus la prédiction de branchement est performante, plus le pipeline peut être long. Les pénalités de mauvaise prédiction seront plus importantes, mais les mauvaises prédictions seront plus rares, ce qui compense complétement. ===La ''minimal control dependency''=== En utilisant une technique du nom de ''minimal control dependency'', seules les instructions qui dépendent du résultat du branchement sont supprimées du pipeline en cas de mauvaise prédiction, les autres n'étant pas annulées. Sans ''minimal control dependency'', toutes les instructions qui suivent un branchement dans notre pipeline sont annulées. Pourtant, certaines d'entre elles pourraient être utiles. Prenons un exemple : supposons que l'on dispose d'un processeur de 31 étages (un Pentium 4 par exemple). Supposons que l'adresse du branchement est connue au 9éme étage. On fait face à un branchement qui envoie le processeur seulement 6 instructions plus loin. Si toutes les instructions qui suivent le branchement sont supprimées, les instructions en rouge sont celles qui sont chargées incorrectement. On remarque pourtant que certaines instructions chargées sont potentiellement correctes : celles qui suivent le point d'arrivée du branchement. Elles ne le sont pas forcément : il se peut qu'elles aient des dépendances avec les instructions supprimées. Mais si elles n'en ont pas, alors ces instructions auraient dûes être exécutées. Il serait donc plus efficace de les laisser enregistrer leurs résultats au lieu de les ré-exécuter à l’identique un peu plus tard. Ce genre de choses est possible sur les processeurs qui implémentent une technique du nom de ''Minimal Control Dependancy''. En gros, cette technique fait en sorte que seules les instructions qui dépendent du résultat du branchement soient supprimées du pipeline en cas de mauvaise prédiction. [[File:Minimal cntrol dependency.png|centre|vignette|upright=2|Minimal control dependency]] ==La reconnaissance des branchements== Pour prédire des branchements, le processeur doit faire la différence entre branchements et autres instructions, dès l'étage de chargement. Or, un processeur « normal », sans prédiction de branchement, ne peut faire cette différence qu'à l'étage de décodage, et pas avant. Les chercheurs ont donc dû trouver une solution. La première solution se base sur les techniques de prédécodage vues dans le chapitre sur le cache. Pour rappel, ce prédécodage consiste à décoder partiellement les instructions lors de leur chargement dans le cache d'instructions et à mémoriser des informations utiles dans la ligne de cache. Dans le cas des branchements, les circuits de prédécodage peuvent identifier les branchements et mémoriser cette information dans la ligne de cache. Une autre solution mémorise les branchements déjà rencontrés, dans un cache dédié. Ce cache mémorise l'adresse (le ''program counter'') des branchements déjà rencontrés : il s'appelle le '''tampon d’adresse de branchement''' (''branch adress buffer''). Il est intégré à l'unité de prédiction de branchement, mais est alimenté par le décodeur d'instruction. Reconnaitre un branchement demande l'implication du décodeur d'instruction, qui sait si l'instruction qu'il vient de décoder est un branchement ou non. Il peut même savoir si le branchement est conditionnel, inconditionnel, un appel de retour de fonction, etc. Lorsque le décodeur d'instruction reconnait un branchement, il ajoute ce branchement dans le cache Le tapon d'adresse de branchement contient une adresse mémoire par branchement, et éventuellement d'autres informations. Et pour comprendre quelle adresse est mémorisée, il faut faire attention à un détail. Les explications qui vont suivre vont faire intervenir deux adresses. * La première est l''''adresse de destination''' du branchement, à savoir l'adresse à laquelle le processeur doit reprendre son exécution si le branchement est pris. * L'autre adresse est l''''adresse du branchement''' lui-même. Elle correspond à sa position en mémoire RAM. L'adresse du branchement est la valeur du ''program counter'' quand le branchement est chargé dans le cache, alors que l'adresse de destination est celle du ''program Counter'' une fois le branchement exécuté. Il faudra faire bien attention à ne pas confondre les deux adresses dans ce qui suit. L'adresse du branchement est celle qui est utilisée pour la reconnaissance du branchement. Le tampon d'adresse de branchement mémorise, pour chaque branchement, son adresse de branchement (d'où son nom). À chaque cycle d'horloge, l'unité de chargement envoie le ''program counter'' en entrée du tampon d’adresse de branchement. Un succès dans ce cache indique qu'un branchement déjà rencontré est ré-executé, on peut utiliser la prédiction de branchement. Dans le cas contraire, la prédiction de branchement n'est pas utilisée, et l'instruction est chargée normalement. ==La prédiction de l'adresse de destination== La '''prédiction de l'adresse de destination d'un branchement''' détermine l'adresse de destination d'un branchement. Rappelons qu'il existe plusieurs modes d'adressage pour les branchements et que chacun d'entre eux précise l'adresse de destination à sa manière. Suivant le mode d'adressage, l'adresse de destination est soit dans l'instruction elle-même (adressage direct), soit calculée à l’exécution en additionnant un ''offset'' au ''program counter'' (adressage relatif), soit dans un registre du processeur (branchement indirect), soit précisée de manière implicite (retour de fonction, adresse au sommet de la pile). : Histoire de simplifier la terminologie, je parlerais parfois de '''prédiction de destination''' pour parler de la ''prédiction de l'adresse de destination d'un branchement''. Pour rappel, les branchements se classent en trois types : * les branchements directs pour lequel l'adresse de destination est toujours la même ; * les branchements indirects où l'adresse de destination est dans un registre et peut changer ; * les instructions de retour de fonction. Les branchements directs sont facilement prévisibles, vu que l'adresse vers laquelle il faut brancher est toujours la même. La prédiction des branchements directs et relatifs se fait globalement de la même manière, ce qui fait que nous ferons la confusion dans ce qui suit. Pour les instructions de retour de fonction, une prédiction parfaite est possible. Et c'est ce qui explique qu'elles sont traitées à part, par une unité de branchement dédiée. Pour les branchements indirects, vu que cette adresse change, la prédire celle-ci est particulièrement compliqué (quand c'est possible). Les branchements indirects sont eux aussi traités par une unité de prédiction dédiée rien que pour eux. ===La prédiction de destination pour les branchements directs=== Lorsqu'un branchement est exécuté, on mémoriser l'adresse de destination et la réutiliser lors d'exécutions ultérieures du branchement. Cette technique marche à la perfection pour les branchements directs, pour lesquels cette adresse est toujours la même, mais donne des résultats mitigés pour les branchements indirects. Pour mémoriser l'adresse de destination, on utilise un cache qui mémorise les correspondances entre l'adresse du branchement et l'adresse de destination. Ce cache est appelé le '''''branch target buffer''''' en anglais, qui sera abrévié en BTB dans ce qui suit. Le BTB est une amélioration du tampon d'adresse de branchement vu plus haut, auquel on aurait ajouté les adresses de destination des branchements. Le BTB est utilisé comme suit : on envoie en entrée l'adresse du branchement, le BTB répond au cycle suivant en précisant : s’il reconnaît l'adresse d'entrée, et quelle est l'adresse de destination si c'est le cas. Le BTB peut être un cache totalement associatif, associatif par voie, ou directement adressé, mais ces derniers sont les plus fréquents. Les BTB sont généralement des caches de type directement adressés, où seuls les bits de poids faible de l'adresse du branchement adressent le cache, le reste de l'adresse est tout simplement ignoré. Il existe aussi des BTB qui sont construits comme les caches associatifs à plusieurs voies, mais ceux-ci impliquent généralement la présence de ''tags'', ce qui fait qu'ils sont un peu moins fréquents. [[File:Branch target buffer directement adressé.png|centre|vignette|upright=2|Branch target buffer directement adressé.]] Comme pour tous les caches, un accès au BTB peut entraîner un défaut de cache, c’est-à-dire qu'un branchement censé être dans le BTB n'y est pas. Comme pour les autres caches, les défauts de cache peuvent se classer en trois types : les défauts de cache à froid (''cold miss'') causés par la première exécution d'un branchement, les défauts liés à la capacité du BTB/cache, et les défauts par conflit d'accès au cache. Les défauts liés à la capacité du BTB sont les plus simples à comprendre. La capacité limitée du BTB fait que d'anciens branchements sont éliminées pour laisser la place à de nouveaux. En conséquence, ces branchements peuvent donner des erreurs de prédiction si leur correspondance a été éliminée du cache. On peut réduire des défauts en augmentant la taille du BTB. Les défauts liés aux conflits d'accès au BTB sont eux beaucoup plus intéressants, car la conception du BTB est assez spéciale dans la manière dont sont gérés ce genre de défauts. Le BTB est un cache partiellement associatif, ce qui fait que des défauts de cache par conflit d'accès peuvent avoir lieu. Pour rappel, les défauts par conflit d'accès ont lieu quand deux adresses mémoires se voient attribuer la même ligne de cache. Pour un BTB, cela correspond au cas où deux branchements se voient attribuer la même entrée dans le BTB, ce qui porte le nom d’''aliasing''. Ne pas détecter ce genre de situation sur un cache normal entraînerait des problèmes : la donnée lue/écrite ne serait pas forcément la bonne. Les caches normaux utilisent un système de tags pour éviter ce problème et on s'attendrait à ce que les BTB fassent de même. Mais pour un BTB, l'absence de ''tags'' n'est pas un problème : la seule conséquence est une augmentation du taux de mauvaises prédictions, dont les conséquences en termes de performance peuvent être facilement compensées. Les BTB peuvent donc se passer de tags, ce qui a de nombreux avantages : le circuit est plus rapide, prend moins de portes logiques, consomme moins d'énergie, etc. Ces économies de portes logiques et de performances peuvent être utilisées pour augmenter la taille du BTB, ce qui compense, voire surcompense les mauvaises prédictions induites par l’''aliasing''. Mais ce n'est pas systématique : d'autres BTBs utilisent des ''tags'' pour améliorer le taux de prédiction. Nous ferons donc la différence entre '''BTB tagués''' et '''BTB non-tagués'''. Des processeurs se passent de BTB. À la place, ils mémorisent les correspondances entre branchement et adresse de destination dans les bits de contrôle du cache d'instructions. Mais nous verrons cela en détail dans le chapitre suivant. ===La prédiction de destination pour les branchements indirects=== Un BTB classique peut fonctionner pour les branchements indirects. Son seul défaut est qu'il mémorisera la dernière adresse de destination prise par le branchement. Et cela peut fonctionner pour les branchements indirects fortement biaisés, qui ont presque toujours la même adresse de destination. Mais la majorité des branchements indirects change d'adresse de destination régulièrement. Tous les processeurs commerciaux datant d'avant le Pentium M utilisaient un BTB normal pour prédire les branchements indirects. Les processeurs qui les ont suivi sont capables de prédire l'adresse de destination d'un branchement indirect. , grâce à une '''unité de prédiction des branchements indirects''', séparée de l'unité de prédiction de branchement normale. Elle utilise un BTB amélioré, qui mémorise plusieurs adresses de destination pour un seul branchement, en compagnie d'informations pour prédire laquelle est la bonne. Il est possible d'utiliser un BTB unique, pour tous types de branchements, appelé un '''BTB unifié'''. Un BTB unifié a plusieurs adresse de destination par entrée du BTB. Mais une seule est utilisée pour les branchements directs. Vu que les branchements directs sont majoritaires, cela fait beaucoup d'entrées inutilisées. En conséquence, elle est tout sauf optimale, et n'est presque jamais utilisée en pratique. {|class="wikitable" |+ Branch Target Buffer unifié |- ! Adresse du branchement !! Informations de prédiction !! Adresse de destination !! Seconde adresse de destination |- | 0x54462146 || 11101011 || 0x52495674 || 0x24987461 |- | 0x54142446 || 11101101 || 0x15646956 || x0x4158746 |- | 0x25456542 || 10011101 || 0x52495674 || 0x24157461 |- | 0x15455855 || 11010101 || 0x52485754 || 0x24024511 |- | ... || ... || ... || ... |- | ... || ... || ... || ... |- | 0x54462146 || 1001101 || 0x52495674 || 0x24987461 |} Une seconde solution utilise un BTB séparé, distinct du BTB pour les branchements directs. En séparant les deux, on a un BTB simple qui ne mémorise qu'une adresse de destination par branchement, et un BTB pour branchements indirects avec plusieurs adresses de destination par branchement. [[File:BTB avec prédiction de branchements indirects.png|centre|vignette|upright=2.5|BTB avec prédiction de branchements indirects.]] L'idée est que l'on économise des circuits à nombre d'entrées égales. Par exemple, comparons ce que cela donne avec 64 entrées. D'un côté, on a un BTB à 64 entrées, capable de gérer deux adresse de destination par branchement. En pratique, la majorité des branchements sont des branchements directs, qui n'utiliseraient qu'une seule case mémoire. Les circuits du BTB seraient donc sous-utilisés. De l'autre, on a un BTB à 48 entrées pour branchements directs, et un second BTB avec 16 entrées, capable de mémoriser deux adresses par branchements. Le premier BTB utilise moins de circuits, et est utilisé à pleine capacité, il ne gaspille pas de circuits pour mémoriser des adresses de destination vides. Par contre, on perd un peu en flexibilité. L'organisation précédente marche bien si 1/4 des branchements rencontrés sont des branchements indirects. Mais si le ratio est différent, la performance est légèrement sous-optimale. Attention, cela ne veut pas dire qu'il y aura des entrées vides dans un BTB, non. Simplement, le premier BTB risque de manquer de place pour les branchements directs récents, alors que les branchements indirects seront eux bien prédits, même les anciens. À l'opposé, un BTB unique permet de mémoriser les 64 derniers branchements, peu importe qu'ils soient indirects ou non. En clair : un BTB unique est plus flexible, ce qui donne un gain de performance dans certaines situations. Cependant, cet avantage théorique ne tient pas compte de l'économie en circuits, qui peut être utilisée pour faire grossir le BTB pour branchements directs. Par exemple, à budget à transistor équivalent, on a le choix entre BTB à 64 entrées uniques, et un BTB pour branchements directs de 80 entrées avec un BTB indirect de 16 entrées. Le fait qu'un BTB soit plus gros permet des gains de performances qui compensent la perte de flexibilité. On peut ainsi avoir le meilleur des deux monde, à savoir une économie de circuits et les gains en performance qui en découlent, malgré une petite perte de flexibilité. Une troisième solution utilise un BTB normal, mais réserve plusieurs entrées pour un branchement indirect. Par exemple, un branchement indirect qui a trois adresses de destination prendra trois entrées : une par adresse de destination. Mais quelques difficultés surviennent alors. Comment placer trois copies d'un même branchement, alors que ces trois copies ont le même ''program counter'' ? Et que faire pour savoir quelle copie sera prise ? Que faire si l'unité de prédiction de branchement prédit les trois copies comme prises ? La technique du '''''Program Counter'' virtuel''' (''Virtual Program Counter'') résoud ces problèmes d'une manière assez élégante. L'idée est la même que précédemment, à savoir qu'il y a une copie du branchement par adresse de destination, dans le BTB. Mais ces copies n'ont pas exactement le même ''Program counter''. Chaque copie se voit attribuer un ''Program Counter'' virtuel. Et comprendre pourquoi demande de comprendre l'idée sous-jacente. La technique a été inspirée par une optimisation des compilateurs, qui transforme un branchement indirects en une séquence de branchements conditionnels. Par exemple, un branchement indirects qui peut brancher vers les adresses A, B et C est transformé en trois branchements conditionnels : un qui branche vers A, un autre qui branche vers B et un autre vers C. Elle ne marche pas pour tous les branchements indirects, elle demande des circonstances particulières pour fonctionner. Mais l'idée est adaptée pour le ''design'' de l'unité de prédiction de branchement. L'idée est qu'un branchement indirect est déroulé en plusieurs branchements dans le BTB. Les branchements sont traités en séquence : l'unité de prédiction prédit si le premier branchement est pris ou non. S'il est pris, son adresse de destination est envoyée au cache d'instruction. S'il n'est pas pris, l'unité de prédiction tester la seconde copie du branchement, et rebelote. Il teste toutes les copies, une par une, et s’arrête quand il tombe sur un branchement pris. Si aucune copie n'est prise, alors le branchement est prédit comme non-pris. Première remarque : il s'agit d'un processus itératif, où on test un branchement à la fois. Et oui, c'est une prédiction par cycle d'horloge. Prédire un branchement indirect peut donc demander plusieurs cycles d'horloge. Mais ce n'est pas un problème, car le prédicteur peut servir d'''overriding predictor'', d'unité de prédiction lente mais précise. La prédiction peut être utilisée par l'unité de décodage, pour annuler les instructions chargées à tord. Deuxième remarque : comment passer d'un branchement à l'autre ? La première copie a un ''program counter'' normal, qui correspond à l'adresse du branchement. La seconde a un ''program counter'' qui est calculé à partir de cette première adresse, la troisième copie a une adresse calculée à partir de la seconde, etc. Concrètement, n'importe quelle fonction un tant soit peu simple suffit, tant qu'elle est déterministe. L'article qui a proposé cette techniques (''Virtual Program Counter (VPC) Prediction : Very Low Cost Indirect Branch Prediction Using Conditional Branch Prediction Hardware'') se contenait de faire un XOR entre l'adresse précédente et une constante, avec une constante différente par itération. Troisième remarque : l'algorithme demande de faire une parenthèse dans le fonctionnement normal du BTB. Quand un branchement indirect est détecté, il lance une succession de prédiction pour prédire un branchement. L'incrémentation du ''program counter'' normale cesse pendant ce processus. Et cela ne doit être fait que quand un branchement indirect est détecté. Pour cela, le BTB mémorise le type de chaque branchement, sans l'entrée du BTBT : est-ce qu'il est conditionnel, inconditionnel, mais aussi direct ou indirect. Et le type est accesible en sortie du BTB, sur une sortie de quelques bit. Quand l'unité de prédiction remarque qu'un branchement indirect vient de sortir du BTB, alors elle lance le processus. [[File:Virtual program counter.png|centre|vignette|upright=2.5|Virtual program counter]] L'avantage de cette technique est qu'elle s’implémente facilement, son cout en transistors est faible. Elle demande juste d'ajouter quelques circuits autour d'un BTB classique. Elle utilise l'unité de prédiction classique, pas besoin d'une unité de prédiction séparée. Vu qu'il y a un seul BTB, on n'a pas les désavantages d'un BTB séparé pour les branchements indirects. Les entrées du BTB sont réparties entre branchements directs et indirects de manière optimale. Niveau performance, elle est un peu moins bonne que les unités de prédiction ITTAGE qu'on détaillera dans la suite du chapitre. Mais elle est suffisante pour être utilisée sur certains processeurs commerciaux. ===La prédiction de l'adresse de retour d'une fonction=== Presque tous les processeurs conçus après les années 2000 peuvent prévoir l'adresse à laquelle il faudra reprendre lorsqu'une fonction/sous-programme a fini de s’exécuter. Ils font de la '''prédiction de retour de fonction'''. Pour cela, ils possèdent pour une unité de prédiction de branchement spécialisée, appelée le ''return stack predictor'', ou '''''pile de prédiction de retour de fonction'''''. Le terme ''return stack predictor'' étant plus court, nous l'utiliserons dans la suite du cours. Le ''return stack predictor'' est capable de reconnaitre les instructions de retour de fonction, et de prédire l'adresse de retour. Pour comprendre comment elle fait, rappelez-vous le chapitre sur la pile d'appel. Dans ce chapitre, j'avais introduit le concept de ''pile d'adresse de retour'', à savoir une pile d'appel très simplifiée, qui ne contient que les adresses de retour des fonctions en cours d'exécution. Le ''return stack predictor'' est essentiellement composé d'une telle pile d'adresse de retour, avec des circuits pour empiler et dépiler les adresses de retour. Le ''return stack predictor'' doit reconstruire une pile d'adresses de retour, à partir du programme exécuté. La pile d'adresse est mémorisée dans le ''return stack predictor'', elle est totalement séparée de la pile d'appel normale. Le programme exécuté continue d'utiliser la même pile d'appel qu'avant, ce qui fait que les adresses de retour sont dispersées dans la pile d'appel, parfois dans des registres spéciaux du processeur. Mais cela ne pose pas de problèmes pour le processeur, qui reconstruit la pile d'adresse de retour avec l'aide du décodeur. La reconstruction se fait à partir des instructions d'appel et de retour de fonction. Une instruction d'appel de fonction empile une adresse de retour, une instruction de retour de fonction la dépile. Lorsque le décodeur d'instruction reconnait une instruction d'appel de fonction, il extrait l'adresse de retour et l'envoie au ''return stack predictor''. L'adresse de retour est alors empilée, ce qui fait qu'elle se retrouve au sommet de la pile d'adresse de retour. Dépiler une adresse de retour revient à prédire un branchement. Lorsque le ''return stack predictor'' détecte une instruction de retour de fonction, il dépile l'adresse au sommet de la pile. L'adresse est alors envoyée du cache d'instruction, pour l'ire l'instruction adéquate, et le ''program counter'' est mis à jour avec cette adresse. Toute la difficulté revient à détecter les instructions de retour de fonction, sans quoi on ne sait pas quand dépiler l'adresse. La documentation là-dessus n'est pas très fournie, il est difficile de savoir ce qui est fait dans les processeurs modernes. Mais on peut faire quelques suppositions. La première possibilité est l'usage du ''prédécodage'', une technique qui décode partiellement les instructions lors de leur chargement dans le cache d'instruction. Elle sera vue dans le chapitre suivant, mais parlons-en rapidement. Le prédécodage ajoute quelques bits au début de chaque instruction pour préciser si c'est un branchement conditionnel, inconditionnel, un retour de fonction. On peut ainsi savoir rapidement quand dépiler : dès qu'une instruction est lue dans le cache d'instruction, le ''return stack predictor'' vérifie les bits ajoutés et agit en fonction. La seconde possibilité est que le BTB reconnait l'instruction de retour de fonction. Pour chaque branchement, il mémorise non seulement son adresse de retour, mais aussi son type. Par type, on veut dire : si c'est un branchement conditionnel, inconditionnel, un retour de fonction. Les retours de fonction sont donc reconnus à condition qu'ils aient été exécutés auparavant. Il est aussi possible d'avoir un BTB spécialisé dans les retours de fonction, qui est alimenté par les décodeurs. Dès que le décodeur reconnait une instruction de retour de fonction, il ajoute son ''program counter'' dans ce BTB spécialisé. Une dernière possibilité est que le ''return stack predictor'' agit une fois l'instruction partiellement décodée. Le décodeur d'instruction reconnait les instructions de retour de fonction rapidement, en un cycle, et consulte alors le ''return stack predictor''. L'adresse est dépilée, et le décodeur sait où brancher. Il n'a pas besoin de devoir lire l'adresse de retour en mémoire, il la lit depuis le ''return stack predictor'', directement. La prédiction se fait donc assez tard dans le pipeline, quelques instructions ont été chargées à tort. Mais le décodeur peut les annuler, comme il le ferait avec un branchement inconditionnel mal prédit. ==La prédiction de branchement locale== La prédiction de branchement tente de prédire si un branchement sera pris ou non-pris, et décide quelle adresse envoyer au cache en fonction du résultat. Si on prédit qu'un branchement est non pris, on laisse le ''program counter'' faire son travail. À l'inverse si le branchement est prédit comme étant pris, l'adresse de destination est envoyée au cache d'instruction et au ''program counter''. La prédiction de branchement assigne une probabilité qu'un soit branchement soit pris en fonction de ces informations : si la probabilité est de plus de 50%, le branchement est considéré comme pris. La prédiction de branchement se base avant tout sur des statistiques, c’est-à-dire qu'elle détermine la probabilité d’exécution d'un branchement en fonction d'informations assez diverses. La prédiction de branchement n'a d'intérêt que si les prédictions sont suffisamment fiables pour valoir le coup. Rappelons que la pénalité lors d'une mauvaise prédiction est importante, d'autant plus coûteuse que le pipeline est long. Il est généralement admis qu'il faut au minimum un taux de réussite proche de 90%, voire plus sur les processeurs modernes. Heureusement, la plupart des branchements sont des '''branchements biaisés''', c’est-à-dire qu'ils sont presque toujours pris ou presque toujours non-pris, sauf en de rares occasions. De tels branchements font que la prédiction des branchements est facile, bien qu'ils posent paradoxalement quelques problèmes, comme nous le verrons plus bas. Un autre point important est que les unités de prédiction de branchement doivent être très rapides. Idéalement, elles doivent fournir leur prédiction en un seul cycle d'horloge. Un seul cycle d'horloge est un temps très court, surtout sur les ordinateurs avec une fréquence élevée. Les techniques de prédiction dynamique ne peuvent donc pas utiliser des méthodes statistiques extraordinairement complexes. Cela va à l'encontre du fait que le taux de mauvaises prédictions doit être très faible, idéalement inférieur à 1% sur les processeurs modernes. Vous comprenez donc aisément que concevoir une unité de prédiction de branchement est un véritable défi d’ingénierie électronique qui requiert des prouesses technologiques sans précédent. Suivant la nature des informations utilisées, on peut distinguer plusieurs types de prédiction de branchement : locale, globale, dynamique, statique, etc. Nous allons d'abord voir les méthodes statiques avant de voir les méthodes dynamiques. La '''prédiction locale''' prédit chaque branchement indépendamment les uns des autres. Elle sépare bien les statistiques/moyennes pour chaque branchement. Et c'est autant un point fort qu'un point faible. En effet, elle n'exploite pas les corrélations entre branchements, à savoir le fait que l'exécution d'un branchement a tendance à déborder sur les suivants. Dans ce qui suit, nous allons voir les techniques de prédiction locales. ===Les ''branch hints''=== Dans son implémentation la plus explicite, la prédiction est inscrite dans le branchement lui-même. Quelques bits de l'opcode du branchement précisent si le branchement est majoritairement pris ou non pris. Ils permettent d'influencer les règles de prédiction de branchement et de passer outre les réglages par défaut. Ces bits sont appelés des '''suggestions de branchement''' (''branch hint''). Mais tous les processeurs ne gèrent pas cette fonctionnalité. Et de plus, cette solution marche assez mal. La raison est que le programmeur ou le compilateur doit déterminer si le branchement est souvent pris ou non-pris, mais qu'il n'a pas de moyen réellement fiable pour cela. Une solution possible est d’exécuter le programme sur un ensemble de données réalistes et d'analyser le comportement de chaque branchement, afin de déterminer les suggestions de branchement adéquates, mais c'est une solution lourde et peu pratique, qui a de bonnes chances de donner des résultats peu reproductibles d'une exécution à l'autre. ===La prédiction statique de branchement=== La '''prédiction statique''' prédit le résultat du branchement en fonction de caractéristiques fixes du branchement lui-même, comme son adresse, son mode d'adressage, l'adresse de destination, la position du branchement en mémoire, si le branchement est direct ou indirect, etc. Les informations en question sont connues une fois le branchement décodé, il n'y a pas besoin de mémoriser la moindre information supplémentaire. Une première implémentation, assez évidente, fait la différence entre les branchements inconditionnels toujours pris, et les branchements conditionnels au résultat variable. L'idée est de prédire les branchements inconditionnels comme toujours pris alors que les branchements conditionnels sont prédits comme non-pris (ce qui est une approximation). L'implémentation est relativement simple : on utilise un BTB qui ne mémorise que les branchements inconditionnels, et basta ! Les branchements conditionnels sont simplement ignorés, traités comme n'importe quelle autre instruction. Cette méthode est particulièrement inefficace pour les branchements de boucle, où la condition est toujours vraie, sauf en sortie de boucle ! Il a donc fallu affiner légèrement l'algorithme de prédiction statique. Une implémentation plus subtile fait la différence entre les branchements des boucles et les autres. Reste à identifier ceux-ci d'une manière ou d'une autre. Et un bon moyen est de regarder si le branchement revient en arrière ou au contraire saute une portion de code. Un programme s'exécute des adresses basses vers les adresses hautes, les instructions vont naturellement dans le sens croissant des adresses. Un branchement de boucle fait revenir en arrière, histoire de répéter un morceau de code, pas les autres branchements (du moins pas forcément). Et cela nous amène à faire la distinction entre les branchements conditionnels ascendants et descendants. Un '''branchement conditionnel ascendant''' est un branchement qui demande au processeur de reprendre plus loin dans la mémoire : l'adresse de destination est supérieure à l'adresse du branchement. Un branchement '''conditionnel descendant''' a une adresse de destination inférieure à l'adresse du branchement : le branchement demande au processeur de reprendre en arrière dans la mémoire, avant le branchement. Les branchements ascendants sont rarement pris (ils servent dans les conditions de type SI…ALORS), contrairement aux branchements descendants (qui servent à fabriquer des boucles). On peut ainsi modifier l’algorithme de prédiction statique comme suit : * les branchements inconditionnels sont toujours pris ; * les branchements descendants sont toujours pris ; * les branchements ascendants ne sont jamais pris. L'implémentation demande d'élargir le BTB. Le BTB n'a pas seulement à mémoriser l'adresse du branchement et son adresse de destination, mais aussi le type du branchement. En général, il se contente de deux bits, qui indiquent si le branchement est conditionnel, inconditionnel, ou un retour de fonction. Les retours de fonction sont traités à part, histoire de profiter du ''return stack predictor''. {|class="wikitable" |+ BTB avec prédiction de branchement statique évoluée |- ! Adresse du branchement !! Adresse de destination !! Type du branchement |- | 0x4165485 || 0x14657445541 | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Retour de fonction |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... || ... |} ===La prédiction avec des compteurs à saturation=== Les méthodes de prédiction de branchement statique sont intéressantes, mais elles montrent rapidement leurs limites. Pour plus de performances, il faut utiliser la '''prédiction dynamique''', qui extrait des statistiques sur l’exécution des branchements. Pour le reste du chapitre, toutes les techniques utilisent la prédiction dynamique, qu'elles soient locales ou globales. Sa version la plus simple se contente de mémoriser ce qui s'est passé lors de l’exécution précédente du branchement. Si le branchement a été pris, alors on suppose qu'il sera pris la prochaine fois. De même, s'il n'a pas été pris, on suppose qu'il ne le sera pas lors de sa prochaine exécution. Pour cela, chaque entrée du BTB contient un bit qui mémorise la dernière exécution du branchement : 1 s'il a été pris, 0 s'il n'a pas été pris. C'est ce qu'on appelle la '''prédiction de branchements sur 1 bit'''. Au vu de sa simplicité, il ne faut pas s'attendre à des miracles. La méthode fonctionne bien pour les branchements des boucles et les branchements inconditionnels, mais elle n'est pas assez performante. Une forme plus élaborée de prédiction utilise des '''compteurs à saturation'''. Un compteur à saturation est un compteur qui est incrémenté à chaque fois qu'un branchement est pris, décrémenté s'il est non-pris. Le compteur est initialisé à la moitié de sa valeur maximale, ce qui correspond à une probabilité proche de 50%. Les compteurs à saturation sont très utilisés et pas seulement pour de la prédiction locale, les méthodes globales en utilisent aussi. Pour faire la prédiction, l'unité de prédiction regarde le bit de poids fort du compteur. Le branchement est considéré comme pris si ce bit de poids fort est à 1, et non pris s'il vaut 0. En clair : on considère que le branchement est pris si le compteur indique une probabilité supérieure à 50%. [[File:Compteur à saturation.png|centre|vignette|upright=2|Compteur à saturation.]] : Pour la culture générale : le compteur à saturation du Pentium 1 était légèrement bogué, ce qui fait que sa prédiction de branchement était sous-optimale. La plupart des processeurs utilisent un compteur à saturation par branchement, ce qui veut dire un compteur à saturation par entrée dans le BTB. Vu chaque compteur est associé à une entrée du BTB, le phénomène d’''aliasing'' mentionné plus haut peut perturber les prédictions de branchement. Deux branchements peuvent se voir associés à la même entrée du BTB, et agir sur le même compteur à saturation, ce qui entraine l'apparition d''''interférences entre branchements'''. L'interférence peut avoir des effets positifs, neutres ou négatifs, suivant la corrélation entre les deux branchements. L’''aliasing'' n'est pas un problème si les deux branchements sont fortement corrélés, par exemple si les deux sont tout le temps pris ou tout le temps non-pris. Si les deux branchements vont dans le même sens, l'interférence est neutre, voire légèrement positive. Mais si l'un est souvent pris et l’autre souvent non-pris, l'interférence est négative. Les deux branchements vont se marcher dessus sans vergogne, chacun interférant sur les prédictions de l'autre ! Il est rare que l’''aliasing'' ait un impact significatif sur les unités de prédiction des deux paragraphes précédents. Il se manifeste surtout quand la BTB est très petite et la seule solution est d'augmenter la taille du BTB. ===La prédiction avec un historique local=== La prédiction avec des compteurs à saturation est très simple, mais il est possible de créer une implémentation alternative, très proche, avec des avantages et désavantages différents. Un compteur à saturation enregistre l'historique d'un branchement, à savoir ses dernières exécutions. L''''historique''' d'un branchement dit quel a été son résultat dans le passé, pour ses exécutions précédentes. Et il est possible d'enregistrer cet historique et l'utiliser pour faire des prédictions. Par exemple, prenons un branchement sont ses 6 dernières exécutions sont "pris, non-pris, pris, pris, pris, non-pris". Le processeur peut encoder cet historique sur 6 bits. Un branchement pris correspond à un bit à 1, un branchement non-pris correspond à un 0. Le bit de poids fort correspond à l'exécution la plus ancienne, le bit de poids faible à l'exécution la plus récente. Par exemple, si l'historique contient 010, cela veut dire que le branchement a été non pris, puis pris, puis non pris. L'historique d'un branchement est appelé son '''historique local''', mais nous parlerons simplement l'historique. Le processeur peut mémoriser l'historique de chaque branchement. Pour cela, il réserve n bits par branchements, pour mémoriser ses n exécutions précédentes. L'historique d'un branchement est mémorisé dans son entrée du BTB. Et plus précisément dans un champ séparé qui est alimentée comme un registre à décalage. Reste à exploiter cet historique local pour faire des prédictions. Une idée assez simple serait de l'utiliser pour calculer une moyenne des N exécutions précédentes. Si la probabilité d'être pris est supérieure à 50 %, le branchement est considéré comme pris. Dans le cas contraire, il est considéré comme non pris. Le calcul de la moyenne utilise une simple porte à majorité, qui prend en entrée tous les bits de l'historique. Cependant, cette méthode est équivalente à utiliser un compteur à saturation par branchement. La sortie de la porte à majorité est équivalente à ce qu'on aurait dans un compteur à saturation. La seule différence : le cout en circuit et la simplicité de conception. Prenons l'exemple des premiers processeurs PA RISC. Ils mémorisaient, pour chaque branchement, les trois dernières exécutions du branchement, avec trois bits par entrée du BTB. La prédiction était réalisée avec une porte à majorité, dont le résultat était utilisé comme prédiction. Ces processeurs ont préféré utiliser un historique de 3 bits, car il était plus simple à concevoir qu'un compteur à saturation équivalent de deux bits. Il suffisait d'alimenter un registre à décalage avec un bit, pas besoin d'incrémenter ou de décrémenter un compteur, pas besoin de signaux d'incrémentation/décrémentation séparés, etc. Mais pour des historiques plus longs, la donne s'inverse et des compteurs à saturation de deux-trois bits l’emportent. Un défaut de la méthode précédente, qui est partagée avec les compteurs à saturation, est qu'elle marche mal pour les branchements qui suivent un ''pattern''. Je veux dire par là que l'exécution d'un branchement suit un cycle, par exemple pris, pris, non-pris, pris, pris, non-pris, et ainsi de suite en boucle. Notons que l'apparition de tels motifs correspond le plus souvent à la présence de boucles dans le programme. Quand une boucle s’exécute, les branchements qui sont à l'intérieur tendent à exprimer de tels motifs. Avec de tels motifs, les compteurs à saturation feront des prédictions incorrectes, là où les prédicteurs qu'on va voir font des prédictions parfaites. Par exemple, prenons un branchement qui suit le ''pattern'' pris, pris, pris, non-pris. Si l'historique local vaut 1110, le branchement est censé refaire une boucle et être pris. Et certes, on a supposé que l'historique faisait 4 bits. Mais le''pattern'' sera visible tant que l'historique fait plus de 4 bits. Par exemple, le ''pattern'' serait 01110 pour un historique local de 5 bits, 101110 pour un historique local de 6 bits, 1101110 pour un historique local de 7 bits, etc. Un ''pattern'' correspond donc à une valeur bien précise de l'historique local. L'historique local permet de voir ces cycles, reste à les exploiter. Prédire correctement les ''pattern'' demande simplement d'associer un compteur à saturation à chaque valeur possible de l'historique local. Le compteur à saturation dira, quand tel pattern est rencontré, si le branchement sera pris ou non-pris par la suite. Au total, il faut donc un registre d'historique local de n bits couplé à 2^n compteurs à saturation. Et ce pour chaque branchement possible. Les compteurs à saturation sont regroupés dans une '''''Pattern History Table''''', que nous noterons PHT dans ce qui suit. Le résultat est une '''unité de prédiction à deux niveaux locale''', qui contient plusieurs registres d'historique et tout autant de PHT locales. En tout, il y a une PHT et un historique par branchement, donc par entrée du BTB. Quand un branchement est exécuté, son historique local indique quel compteur à saturation choisir dans la PHT locale. En général, l'historique local est mémorisé dans le BTB, reste à gérer la PHT. [[File:Unité de prédiction à deux niveaux purement locale.png|centre|vignette|upright=2.5|Unité de prédiction à deux niveaux avec des historiques et des PHT locales.]] Une unité de prédiction locale à deux niveaux contient plusieurs PHT, reste à savoir quelle PHT correspond à tel branchement. La correspondance se fait idéalement à partir de l'adresse du branchement, mais c'est là une implémentation idéale, qui demande beaucoup de circuits. Généralement, l'unité de prédiction n'utilise que les bits de poids faible de l'adresse du branchement, ce qui permet de faire un choix correct, mais avec une possibilité d'''aliasing''. Il est aussi possible de séparer l'historique local du BTB. Les historiques locaux sont alors mémorisés dans une mémoire RAM dédiée, appelé la '''''Branch History Table''''', ou encore la '''table des historiques locaux''', que nous noterons BHT dans ce qui suivra. Chaque byte de la BHT contient l'historique local associé à un branchement. Pour récupérer l'historique local voulu, l'adresse du branchement est envoyée à la BHT, sur les entrées d'adresse de la BHT, directement. Seuls les bits de poids faible de l'adresse sont utilisés. L'historique local est ensuite envoyé à la PHT adéquate, choisie en fonction des mêmes bits de poids faible du PC. Un bon moyen pour cela est d'accéder à toutes les PHT en parallèle, mais de sélectionner la bonne avec un multiplexeur, ce dernier étant commandé par les bits de poids faible du PC. [[File:Table des historiques locaux.jpg|centre|vignette|upright=2|Table des historiques locaux]] Un défaut des unités de prédiction purement locales de ce type est leur cout en termes de circuits. Utiliser un registre d'historique et une PHT par branchement a un cout en transistor élevé, avec tous les défauts que ca implique en termes de consommation électrique et thermique. Elles ont aussi des temps de calcul un peu plus important que les unités purement globales, mais pas de manière flagrante. Les gains en performance avec un historique local s'observent surtout pour les branchements qui sont dans des boucles ou pour les branchements utilisés pour concevoir des boucles, alors que l'implémentation globale marche bien pour les if..else (surtout quand ils sont imbriqués). Les deux sont donc complémentaires. Dans les faits, elles sont rarement utilisées seules du fait de leurs défauts, au profit d'unités de prédiction hybrides, qui mélangent historiques et PHT locaux et globaux. ==La prédiction de branchement globale/mixte== Utiliser un historique local avec une PHT est très puissant. Mais le cout en circuit est très important. Cependant, on peut adapter l'idée pour faire de la prédiction globale. La '''prédiction globale''' fait des prédictions holistiques, qui tiennent compte de tous les branchements, sans les traiter séparément. Elle ne sépare pas les branchements, tous sont mis dans le même panier. La prédiction globale fait donc le compromis inverse : elle exploite les corrélations entre branchements, mais perd les informations statistiques précise pour chaque branchement. L'idée est qu'au lieu d'utiliser un historique par branchement, on utilise un '''historique global''' qui mémorise les exécutions de tous les branchements conditionnels. L'historique enregistre les N exécutions de branchements conditionnels. L'historique global est mémorisé dans un registre unique, appelé le '''registre d'historique global'''. C'est un registre à décalage mis à jour à chaque fois que le processeur exécute un branchement : on fait rentrer un 1 si le branchement est pris, et un 0 sinon. Une unité de prédiction globale utilise cet historique pour faire sa prédiction. L'unité de prédiction globale utilise un compteur à saturation pour chaque valeur possible du registre d'historique. En clair, pour un registre de N bits, elle utilise 2^n compteurs à saturation. Là encore, les compteurs à saturation sont regroupés dans une ''Pattern History Table''. Nous parlerons de '''PHT globale''' pour désigner une PHT commandée par un historique global, de ''PHT locale'' si elle est adressée par un historique local. [[File:Prédiction dynamique à deux niveaux.png|centre|vignette|upright=2|Prédiction dynamique à deux niveaux.]] Il est admis que l'historique global donne de meilleures prédictions que l'historique local. Mais les deux sont complémentaires, ce qui fait qu'il existe des approches hybrides. Les approches hybrides exploitent à la fois les statistiques pour chaque branchement et les corrélations entre branchements. Les prédicteurs hybrides ont de meilleures performances que les prédicteurs purement globaux ou locaux. Avec un historique global, la prédiction tient compte d'éventuelles corrélations entre branchements consécutifs/proches pour faire les prédictions. C'est un avantage pour les applications qui enchaînent des if...else ou qui contiennent beaucoup de if...else imbriqués les uns dans les autres, qui s’exécutent plusieurs fois de suite. Le résultat de chaque if...else dépend généralement des précédents, ce qui rend l'utilisation d'un historique global intéressant. Par contre, ils sont assez mauvais pour le reste des branchements. Mais cette qualité devient un défaut quand elle détecte des corrélations fortuites ou parasites, inutiles pour la prédiction (corrélation n'est pas causalité, même pour prédire des branchements). Du fait de ce défaut, la prédiction globale a besoin de registres d'historiques globaux très larges, de plusieurs centaines de bits au mieux. [[File:2 level branch predictor.svg|centre|vignette|upright=2|Unités de prédiction de branchement à deux niveaux avec une seule PHT.]] Le circuit précédent a cependant un problème : si deux branchements différents s'exécutent et que l'historique est le même, le processeur n'y verra que du feu. Il y a alors une interférence, à savoir que les deux branchements vont agir sur le même compteur à saturation. On se retrouve alors dans une situation conceptuellement identique à l’''aliasing'' dans le BTB ou avec des compteurs à saturation. Sauf que les interférences sont alors beaucoup plus nombreuses et qu'elles sont clairement un problème pour les performances ! Mitiger les interférences devient alors bien plus important qu'avec les unités de prédiction précédentes. Heureusement, si augmenter la taille de la PHT peut mitiger le problème, d'autres solutions sont possibles sur les unités de prédiction globales. Elles sont au nombre de deux : mitiger les interférences liées aux branchements biaisés, ajouter des informations qui discriminent les branchements. Voyons ces solutions dans le détail. ===La mitigation des interférences par usage de l'adresse de branchement=== La première solution pour réduire l'impact de l’''aliasing'' est d'augmenter la taille de la PHT et de l'historique. Plus l'historique et la PHT sont grands, moins l’''aliasing'' est fréquent. Mais avoir des historiques et des PHT de très grande taille n'est pas une mince affaire et le cout en termes de performances et de portes logiques est souvent trop lourd. Une autre solution consiste à utiliser, en plus de l'historique, des informations sur le branchement lui-même pour choisir le compteur à saturation à utiliser. Typiquement, on peut utiliser l'adresse du branchement en plus de l'historique, pour choisir le compteur à saturation adéquat. Pour être précis, on n'utilise que rarement l'adresse complète du branchement, mais seulement les bits de poids faible. La raison est que cela fait moins de bits à utiliser, donc moins de circuits, mais l’''aliasing'' est juste atténué, pas éliminé. Des branchements différents ont beau avoir des adresses différentes, les bits de poids faible de leurs adresses peuvent être identiques. Des confusions sont donc possibles, mais sont assez rares pour que cela suffise en pratique. Une unité de prédiction de ce type est illustrée ci-dessous. Il y a plusieurs PHT séparées, donc plusieurs ensembles de 2^n compteurs à saturation. Le choix de la PHT est réalisé par l'adresse du branchement, alors que le choix du compteur à saturation est réalisé par l'historique. Les compteurs à saturation d'un branchement sont mis à jour seulement quand le branchement s'est chargé pendant que l'historique associé était dans le registre d'historique. Cette méthode a cependant le défaut de gâcher énormément de transistors. [[File:Unité de prédiction de branchement basée sur des compteurs à saturation sélectionnés par l'historique global.jpg|centre|vignette|upright=2|Unité de prédiction de branchement basée sur des compteurs à saturation sélectionnés par l'historique global]] D'autres unités de prédiction fonctionnent sur le même principe que l'unité précédente, sauf que les différentes PHTs sont fusionnées en une seule grosse PHT. C'est ce que font les unités de prédiction '''''gshare''''' et '''''gselect''''', ainsi que leurs dérivées. Avec les unités de prédiction '''''gselect''''', on concatène les bits de poids faible de l'adresse du branchement et l'historique pour obtenir le numéro du compteur à saturation à utiliser. [[File:Prédiction « gselect ».png|centre|vignette|upright=2|Prédiction « gselect ».]] Avec les unités de prédiction '''''gshare''''', on fait un XOR entre le registre d'historique et les bits de poids faible de l'adresse du branchement. Le résultat de ce XOR donne le numéro du compteur à utiliser. Avec cette technique, on utilise une PHT globale et un historique global, mais le registre d'historique est manipulé de manière à limiter l'''aliasing''. [[File:Prédiction « gshare ».png|centre|vignette|upright=2|Prédiction « gshare ».]] Intuitivement, on pourrait croire que les unités de prédiction gselect ont de meilleures performances. C'est vrai que les unités gshare combinent l'adresse du branchement et l'historique d'une manière qui fait perdre un peu d'information (impossible de retrouver l'adresse du branchement et l'historique à partir du résultat du XOR), alors que les unités gselect conservent ces informations. Mais il faut prendre en compte le fait que les deux unités doivent se comparer à PHT identique, de même taille. Prenons par exemple une PHT de 256 compteurs, adressée par 8 bits. Avec une unité gselect, les 8 bits sont utilisés à la fois pour l'historique et pour les bits de poids faible de l'adresse du branchement. Alors qu'avec une unité gshare, on peut utiliser un historique de 8 bits et les 8 bits de poids faible de l'adresse du branchement. L'augmentation de la taille de l'historique et du nombre de bits utilisés fait que l’''aliasing'' est réduit, et cela compense le fait que l'on fait un XOR au lieu d'une concaténation. Les unités précédentes réduisent l'''aliasing'' en segmentant la PHT : telles entrées sont allouées à un branchement, telles entrées à tel autre branchement, etc. Cependant, l'historique local n'est pas utilisé. Mais quitte à allouer des entrées à un branchement, autant en profiter pour leur associer son historique local. Il s'agit alors d'approches hybrides. Par exemple, on peut citer la '''prédiction mixte''' (''alloyed predictor''). Avec celle-ci, la table des compteurs à saturation est adressée avec la concaténation : de bits de l'adresse du branchement, du registre d'historique global et du registre d'historique local adressé par l'adresse du branchement. [[File:Prédiction mixte.jpg|centre|vignette|upright=2|Prédiction mixte.]] Une version optimisée de ce circuit permet d’accéder à la PHT et à la BHT en parallèle. Avec elle les bits de poids faible de l'adresse du branchement sont concaténés avec l'historique global. Le résultat est ensuite envoyé à plusieurs PHT locales distinctes, en parallèle. Les différences PHT envoient leurs résultats à un multiplexeur, commandé par l'historique local, qui sélectionne le résultat adéquat. L'inconvénient de cette mise en œuvre est que le circuit est assez gros, notamment en raison de la présence de plusieurs PHT séparées. [[File:Alloyed predictor optimisé.jpg|centre|vignette|upright=2|''Alloyed predictor'' optimisé]] ===La mitigation des interférences par la structuration en caches des PHTs=== Les techniques précédentes utilisent les bits de poids faible de l'adresse pour sélectionner la PHT adéquate, ou pour sélectionner le compteur dans la PHT. Mais le reste de l'adresse n'est pas utilisé. Cependant, il est théoriquement possible de conserver les bits de poids fort, afin d'identifier le branchement associé à un compteur. Pour cela, chaque compteur à saturation se voit associé à un ''tag'', qui mémorise le reste de l'adresse du branchement associé au compteur, comme pour les mémoires caches. Lorsque l'unité de prédiction démarre une prédiction, elle récupère les bits de poids fort de l'adresse du branchement et compare ceux-ci avec le ''tag''. S’il y a un ''match'', c'est signe que le compteur est bien associé au branchement à prédire. Dans le cas contraire, c'est signe qu'on fait face à une situation d’''aliasing'' et le compteur n'est pas utilisé pour la prédiction. Le résultat transforme la PHT en une sorte de cache assez particulier, qui associe un compteur à saturation à chaque couple adresse-historique. La technique du paragraphe précédent peut être encore améliorée afin de réduire l’''aliasing''. L’''aliasing'' sur une unité de prédiction de branchement est similaire aux conflits d'accès au cache, où deux données/adresses atterrissent dans la même ligne de cache. Une PHT peut formellement être vue comme un cache directement adressé. Une solution pour limiter ces conflits d'accès à de tels, est de les transformer en un cache associatif à n voies. On peut faire la même chose avec les unités de prédiction de branchement, ce qui revient à dupliquer les PHT, de manière à ce que les bits de poids faible de l'adresse se voient attribuer à plusieurs compteurs à saturation, un par branchement possible. Ce faisant, on réduit les interférences entre branchements. Mais cette technique augmente la quantité de circuits utilisés et la consommation d'énergie, ainsi que le temps d'accès, sans compter qu'elle requiert d'utilisation de ''tags'' pour fonctionner à la perfection. Pour les unités de prédiction de branchement, les mesures à ce sujet ne semblent pas montrer un impact réellement important sur les performances, comparé à une organisation de type directement adressé. Il est possible de pousser la logique encore plus loin en ajoutant des caches de victime et d'autres mécanismes courant sur les caches usuels. ===La mitigation des interférences liées aux branchements biaisés=== L’''aliasing'' a un impact négatif quand deux branchements aliasés (qui sont attribués au même compteur à saturation) vont dans des sens différents : si l'un est non-pris, l'autre a de bonnes chances d'être pris (ou inversement). Or, la plupart des branchements sont biaisés, à savoir très fortement orientés dans une direction qu'une autre (presque toujours pris ou presque toujours non-pris, à plus de 90/99% de chances). Plus un branchement a une probabilité élevée d' être pris ou non-pris, plus sa capacité à interférer avec les autres est forte. Le pire des cas est quand deux branchements sont biaisés mais dans des directions inverses : l'interférence est maximale. En conséquence, les branchements biaisés ou quasi biaisés sont les plus problématiques pour l’''aliasing''. Or, il est possible de concevoir des prédicteurs de branchements qui atténuent fortement les interférences des branchements biaisés ou quasi biaisés. C’est le cas de l’''agree predictor'' et de l'unité de prédiction bimodale, que nous allons voir dans ce qui suit. Les branchements très souvent pris vont interférer très fortement avec les branchements très souvent non-pris, et inversement. Par contre, les branchements très souvent pris n'interféreront pas entre eux, de même que les branchements non-pris iront bien ensemble. D'où l'idée de séparer ces deux types de branchements dans des PHT séparées, pour éviter le mauvais ''aliasing''. L''''unité de prédiction bimodale''' part d'une unité gshare, et sépare la PHT globale en deux : une PHT dédiée aux branchements qui sont très souvent pris et une autre pour les branchements très peu pris. Les deux PHT fournissent chacune une prédiction. La bonne prédiction est choisie par un multiplexeur commandé par une unité de sélection, qui se charge de déduire quelle unité a raison. Cette unité de sélection est une unité de prédiction basée sur des compteurs à saturation de 2 bits. On peut encore améliorer l'unité de sélection de prédiction en n'utilisant non pas des compteurs à saturation, mais une unité de prédiction dynamique à deux niveaux, ou toute autre unité vue auparavant. [[File:Prédiction bimodale.jpg|centre|vignette|upright=2|Prédiction bimodale.]] La '''prédiction par consensus''' (''agree predictor'') combine une unité de prédiction à historique global (idéalement de type gshare ou gselect, mais une unité normale marche aussi) et des compteurs à saturation. Il s'agit techniquement d'une technique de prédiction mixte, à la fois globale et locale. Le BTB intègre un compteur à saturation de un bit, qui indique la direction privilégiée du branchement. L'unité à historique global a un fonctionnement changé : elle calcule non pas la probabilité que le branchement soit pris ou non-pris, mais la probabilité que le résultat du branchement soit compatible avec le résultat de l'unité de prédiction de 1 bit. La prédiction finale vérifie que ces deux circuits sont d'accord, en faisant un NXOR entre les résultats des deux unités de prédiction de branchement. [[File:Prédiction par consensus.png|centre|vignette|upright=2|Prédiction par consensus.]] Des calculs simples de probabilités montrent que l'''agree predictor'' a des résultats assez importants. Ses résultats sont d'autant meilleurs que les branchements sont biaisés et penchent fortement vers le côté pris ou non-pris. ==La prédiction de branchement avec des perceptrons== Les méthodes précédentes utilisent au moins un compteur à saturation par historique possible. Sachant qu'il y a 2^n valeurs possibles pour un historique de n bits, le nombre de compteurs à saturation augmente exponentiellement avec la taille de l'historique. Ce qui limite grandement la taille de l'historique, alors qu'on aurait au contraire besoin d'un historique assez large pour faire des prédictions excellentes. Pour contourner le problème, des alternatives basées sur des techniques d'apprentissage artificiel (''machine learning'') existent. Un exemple est celui des unités de prédiction de branchement dites de ''neural branch prediction'', basée sur des perceptrons. Dans ce qui suit, nous les appelerons des '''unités perceptron'''. Les perceptrons sont un algorithme d'apprentissage automatique très simple, ce qui fait que les explications qui vont suivre devraient être simples. En théorie, il est possible d'utiliser des techniques d'apprentissage automatique plus élaborées, mais cela n'est pas possible en pratique. Rappelons qu'une unité de prédiction de branchement doit idéalement fournir sa prédiction en un cycle d'horloge. L'implémentation d'un perceptron est déjà problématique de ce point de vue, car elle demande d'utiliser beaucoup de circuits arithmétiques complexes (multiplication et addition). Autant dire que les autres techniques de ''machine learning'', encore plus gourmandes en calculs, ne sont pas praticables. ===Les perceptrons et leur usage en tant qu'unité de prédiction de branchements=== L'idée derrière les unités perceptron est que certains bits de l'historique sont plus importants que d'autres. En soi, ce genre de situation est réaliste. Il arrive que certains branchements soient pris uniquement si les deux branchements précédents ne le sont pas, ou que si l'avant-dernier branchement est lui aussi pris. Mais les autres résultats de branchement présents dans l'historique ne sont pas utiles. En conséquence, deux historiques semblables, qui ne sont différents que d'un ou deux bits, peuvent donner des prédictions presque identiques. Dans ce cas, on peut faire la prédiction en n'utilisant que les bits identiques entre ces historiques, ou du moins en leur donnant plus d'importance qu'aux autres. On exploite alors des corrélations avec certains bits de l'historique, plutôt qu'avec l'historique entier lui-même. Pour coder ces corrélations, on associe un coefficient à chaque bit de l'historique, qui indique à quel point ce bit est important pour déterminer le résultat final. Ce coefficient est proportionnel à la corrélation entre le bit de l'historique associé, et le résultat du branchement à prédire. Les coefficients sont des nombres entiers qui peuvent être positifs, nuls ou négatifs. Un coefficient positif signifie que si le branchement associé a été pris, alors le branchement à prédire a de bonnes chances de l'être aussi (et inversement). Un coefficient nul signifie que les deux branchements sont indépendants et que le bit de l'historique associé n'est pas à prendre en compte. Pour un coefficient négatif, cela signifie que si le branchement associé a été pris, alors le branchement à prédire a de bonnes chances d'être non-pris (et inversement). Les coefficients en question sont généralement compris entre -1 et 1. Une fois qu'on connait ces coefficients de corrélations, on peut calculer la probabilité que le branchement à prédire soit pris, avec des calculs arithmétiques simples. Dans les calculs qui vont suivre, un branchement pris est codé par un 1, alors qu'un branchement non-pris est codé par un -1 ! Ce détail permet de simplifier grandement les calculs. Dans sa version la plus simple, le perceptron calcule un nombre Z qui est positif ou nul si le branchement est pris, négatif s'il ne l'est pas. Tous les coefficients <math>p_i</math> sont alors des nombres relatifs, pouvant être positifs, nuls ou négatifs. Ils sont généralement compris entre -1 et 1. La formule devient donc celle-ci : : <math>Z = w_0 + \sum_i (h_i \times w_i)</math> [[File:ArtificialNeuronModel francais.png|centre|vignette|upright=2|Perceptron.]] : Sur le plan mathématique, le perceptron effectue un produit scalaire entre deux vecteurs : l'historique et le vecteur des poids. Choisir les coefficients adéquats est le but de l'algorithme du perceptron. L'unité de prédiction part de poids par défaut, qui sont mis à jour à chaque bonne ou mauvaise prédiction. Toute la magie de cet algorithme tient dans la manière dont sont mis à jour les poids. L'idée est de prendre le vecteur des poids, puis d'ajouter ou soustraire l'historique suivant le résultat du branchement. Les poids évoluent ainsi à chaque branchement, améliorant leurs prédictions d'une exécution à l'autre. La règle de mise à jour des poids est donc la suivante : : <math>w_i \leftarrow w_i + t \times h_i</math>, avec t = 1 pour un branchement pris, -1 pour un branchement non-pris. ===L'implémentation matérielle des perceptrons et ses optimisations=== Les calculs réalisés par un perceptrons sont simples, juste des multiplications et des additions et cela se sent dans la conception du circuit. L'unité perceptron utilise un perceptron par entrée du BTB, un par branchement pour simplifier. Pour cela, on utilise une mémoire SRAM qui mémorise les poids de chaque perceptron. La SRAM est adressée par les bits de poids faible du ''program counter'', de l'adresse du branchement. Une fois les poids récupérés, ils sont envoyés à un circuit de calcul de la prédiction, composé de circuits multiplieurs suivis par un additionneur multi-opérande. Le circuit de mise à jour des poids récupère la prédiction, les poids du perceptron, mais aussi le résultat du branchement. La mise à jour étant une simple addition, le circuit est composé d'additionneurs/soustracteurs, couplés à des registres pour mémoriser la prédiction et les poids du perceptron en attendant que le résultat du branchement soit disponible. Vu qu'il y a un délai de quelques cycles avant que le résultat du branchement soit disponible et que les prédictions doivent continuer pendant ce temps, les poids du perceptron et la prédiction sont stockés dans des FIFOs lues/écrites à chaque cycle. [[File:Unité de prédiction de branchement basée sur des perceptrons.png|centre|vignette|upright=2|Unité de prédiction de branchement basée sur des perceptrons]] Rien de bien compliqué sur le principe, mais rappelons que le perceptron doit fournir un résultat en moins d'un cycle d'horloge, et cela tient compte de l'accès au BTB. C'est un temps très court, surtout pour un circuit qui implique des multiplications. Les multiplications prennent facilement deux à trois cycles d'horloge. Autant dire que si on ajoute le temps d'accès à une petite SRAM, pour récupérer le perceptron, c'est presque impossible. Mais quelques optimisations simples permettent de rendre le calcul plus rapide. Premièrement, la taille de la SRAM est réduite grâce à quelques économies assez simples. Notamment, le perceptron utilise des poids codés sur quelques bits, généralement un octet, parfois 7 ou 5 bits, guère plus. Les poids sont encodés en complément à 1 ou en complément à 2, là où les perceptrons normaux utilisent des nombres flottants. Rien que ces deux choix simplifient les circuits et les rendent plus rapides. Le désavantage d'utiliser peu de bits par poids est une perte mineure en termes de taux de prédiction, qui est plus que compensée par l'économie en portes logiques, qui permet d'augmenter la taille de la SRAM. D'autres optimisations portent sur le circuit de calcul. Par exemple, la multiplication <math>h_i \times w_i</math> est facultative. Vu que l'historique contient les valeurs 1 et -1, il suffit d'additionner le poids associé si la valeur est 1 et de le soustraire s'il vaut -1. On peut même simplifier la soustraction et se limiter à une complémentation à un (une inversion des bits du poids). En faisant cela, les circuits multiplieurs disparaissent et le circuit de prédiction se résume à un simple additionneur multi-opérande couplé à quelques inverseurs commandables. Certains perceptrons prennent en compte à la fois l'historique global et l'historique local pour faire leur prédiction. Les deux historiques sont simplement concaténés et envoyés en entrée du perceptron. Cela marche assez bien, car les perceptrons peuvent utiliser des historiques de grande taille sans problèmes. Par contre, il y a besoin d'ajouter une ''branch history table'' au circuit, ce qui demande des portes logiques en plus, mais aussi un temps de calcul supplémentaire (l'accès à cette table n'est pas gratuit). ===Les avantages et inconvénients des perceptrons pour la prédiction de branchements=== L'avantage des unités perceptron est qu'elles utilisent un nombre de portes logiques qui est proportionnel à la taille de l'historique, et non pas exponentiel. Cela permet d'utiliser un historique de grande taille, et donc d'obtenir un taux de bonnes prédictions très élevé. Leur désavantage est que leurs performances de prédiction sont moins bonnes à historique équivalent. Les unités perceptrons ont des taux de prédiction élevés, mais seulement quand certaines conditions mathématiques sont respectées par les historiques. La condition en question est que les historiques correspondants à un branchement pris et les historiques où ce même branchement est non-pris doivent être linéairement séparables. Heureusement, les branchements des programmes informatiques sont une majorité à respecter la condition de séparation linéaire, ce qui rend les perceptrons parfaitement adaptés à la prédiction de branchements. La notion de linéairement séparable est un peu difficile à comprendre. Pour l'expliquer, imaginez que les N bits de l'historique forme un espace à N-dimensions discrets. Chaque historique est un point dans cet espace N-dimensionnel et chaque bit sert de coordonnée pour se repérer dans cet espace. Une fonction est linéairement séparable si l'espace N-dimensionnel peut être coupé en deux par un hyperplan : d'un côté de ce plan se trouvent les historiques des branchements pris, de l'autre se trouvent les historiques des branchements non-pris. Cet hyperplan est défini par l'ensemble des points qui respecte l'équation suivante : : <math>w_0 + \sum_i (h_i \times w_i) = 0</math> Un exemple où cette condition est respectée est quand le résultat d'une prédiction se calcule avec un ET entre plusieurs branchements de l'historique. Si un branchement est pris quand plusieurs branchements de l’historique sont tous pris ou tous non-pris, la condition est respectée et le perceptron donnera des prédictions correctes. Un exemple où cette condition n'est pas respectée est un banal XOR. Prenons l'exemple d'un historique global de 2 bits. Imaginons que le branchement à prédire soit pris : soit quand le premier branchement de l'historique est pris et le second non-pris, soit quand le premier est non-pris et le second pris. Concrètement, la prédiction exacte se calcule en faisant un XOR entre les deux bits de l'historique. Dans ce cas, la condition "linéairement séparable" n'est pas respectée et le perceptron n'aura pas des performances optimales. Les unités perceptron utilisent idéalement un perceptron par branchement. Cela demande d'associer, l'adresse du branchement pour chaque perceptron, généralement en donnant un perceptron par entrée du BTB. Mais une telle organisation n'est pas possible en pratique, car elle demanderait d'ajouter un ''tag'' à chaque perceptron/entrée du BTB, ce qui demanderait beaucoup de circuits et de ressources matérielles. Dans les faits l'association entre un branchement et un perceptron se fait en utilisant les bits de poids faible de l'adresse de branchement. Mais faire cela entrainera l'apparition de phénomènes d'''aliasing'', comme pour les unités de prédiction à deux niveaux. Les techniques vues précédemment peuvent en théorie être adaptées sur les unités à perceptrons, dans une certaine mesure. Notons que les perceptrons sont des algorithmes ou des circuits qui permettent de classer des données d'entrée en deux types. Ici, la donnée d'entrée est l'historique global et la sortie du perceptron indique si le branchement prédit est pris ou non-pris. Ils ne sont donc pas adaptés à d'autres tâches de prédiction, comme pour le préchargement des lignes de cache ou la prédiction de l'adresse de destination d'un branchement, ou toute autre tâche d'exécution spéculative un tant soit peu complexe. Leur utilisation reste cantonnée à la prédiction de branchement proprement dit, guère plus. Par contre, les autres techniques vues plus haut peuvent être utilisées pour le préchargement des lignes de cache, ou d'autres mécanismes de prédiction plus complexes, que nous aborderons dans la suite du cours. ==La prédiction avec plusieurs unités de branchement distinctes== Il est possible de combiner plusieurs unités de prédiction de branchement différentes et de combiner leurs résultats en une seule prédiction. On peut par exemple combiner une unité de prédiction spécialisée dans les boucles, une unité de prédiction 2 bits basée sur des compteurs à saturation, une unité de prédiction statique et une unité ''gshare''. Tout le problème est de décider quelle unité a fait la bonne prédiction, comment combiner les résultats des différentes unités de prédiction. Soit on choisit un résultat qui parait plus fiable que les autres, soit on combine les résultats pour faire une sorte de moyenne des prédictions. La méthode la plus simple de choisir le résultat d'une unité de prédiction parmi toutes les autres. Pour implémenter le tout, il suffit d'un multiplexeur qui effectue le choix de la prédiction. Reste à commander le multiplexeur, ce qui demande un '''circuit méta-prédicteur''' qui sélectionne la bonne unité de prédiction. Il arrive que le circuit méta-prédicteur soit conçu avec les mêmes techniques que les unités de prédiction de branchement elle-mêmes. Dans le cas le plus simple, il s'agit d'un simple compteur à saturation, mis à jour suivant la concordance des prédictions avec le résultat effectif du branchement. Mais c'est loin d'être systématique. Une autre solution combine le résultat des différentes unités de prédiction avec une fonction mathématique adéquate. Avec un nombre impair d'unités de prédiction, une méthode assez efficace est de simplement prendre le résultat majoritaire. Si une majorité d'unités pense que le branchement est pris, alors on le considère comme pris, et comme non pris dans le cas contraire. Par contre, avec un nombre pair d'unités, cette méthode simple ne marche pas, il y a un risque d'égalité. L'unité de prédiction de branchement « '''''e-gskew''''' » utilise trois unités gshare et combine leurs résultats avec un vote à majorité. Les trois unités gshare utilisent des XOR légèrement différents, dans le sens où les bits de l'adresse du branchement choisi ne sont pas les mêmes dans les trois unités, sans compter que le résultat du XOR peut subir une modification qui dépend de l'unité de prédiction choisie. Autrement dit, la fonction de hachage qui associe une entrée à un branchement dépend de l'unité. [[File:Unité de prédiction « e-gskew ».png|centre|vignette|upright=2|Unité de prédiction « e-gskew ».]] Enfin, une dernière solution est que certaines unités de prédiction ont la priorité sur toutes les autres. Par exemple, prenons un processeur avec un ''return stack predictor'', une unité qui détecte les boucles et une unité de prédiction avec un historique. Si le ''return stack predictor'' détecte une instruction de retour de fonction, sa prédiction passera avant toutes les autres unités de prédiction. Et dans ce qui va suivre, c'est cette solution qui sera utilisé pour les unités de prédiction qu'on va étudier. ===La prédiction des branchements des boucles=== Les boucles sont à part, pour ce qui est de la prédiction de branchement. Rappelons qu'une boucle FOR/WHILE répète une série d'instruction N fois, le nombre N étant appelé le '''compteur de boucle'''. Ce qui fait que les branchements d'une boucle FOR sont pris n − 1 fois, la dernière exécution étant non prise. La conséquence est que les unités de prédiction usuelles sont assez mauvaises pour prédire les branchements des boucles. Par contre, la prédiction statique marche particulièrement bien comparé au reste. La prédiction statique prédit que les branchements des boucles sont toujours pris, ce qui donne des prédictions qui sont d'autant meilleures que la boucle est répétée un grand nombre de fois. Reste à gérer les branchements des boucles au mieux, en sachant cela. L'idée est d'utiliser plusieurs unités de prédiction séparées : une spécialisée dans les boucles et d'autres unités de prédiction. Uen unité de méta-prédiction se charge d'activer l'unité de prédiction des boucles dès qu'une boucle est détéctée. Les unités de prédiction modernes essayent de détecter les branchements des boucles afin de les mettre à part. En soi, ce n'est pas compliqué les branchements des boucles sont presque toujours des branchements descendants et réciproquement. De plus, ces branchements sont pris à chaque répétition de la boucle, et ne sont non-pris que quand on quitte la boucle. Le compteur de boucle analyse l'exécution des branchements, pour détecter les branchements des boucles. Un branchement est considéré comme un branchement de boucle si : il est pris N fois, avant de ne pas être pris une fois. Au moment où le branchement n'est plus pris, le ''Loop Predictor'' lui attribue une entrée dédié. L'entrée mémorise mémorise le nombre de fois que ce branchement a été pris, à savoir N, et est associée à un compteur. Le compteur est incrémenté/décrémenté à chaque exécution du branchement, et sa valeur est comparée à la valeur N. Quand le compteur atteint N+1, il est réinitialisé. Il s'agit d'une implémentation simple, mais est visiblement proche de celle utilisée sur le Pentium M, si j'en crois l'article "The Intel Pentium M Processor : Microarchitecture and Performance". Il faut noter que la boucle doit s'exécuter au moins une fois pour être détectée, et seules les exécutions suivantes sont prédites correctement. La boucle doit donc s'exécuter plusieurs fois, ce qui implique qu'elle est elle-même dans une boucle plus grande qu'elle. Une telle unité est donc capable de prédire spécifiquement les branchements de boucles FOR imbriquées, à savoir où une boucle FOR est dans une autre boucle FOR. Concrètement, cela permet de répéter la première boucle FOR plusieurs fois de suite, souvent un grand nombre de fois. Avec les boucles imbriquées, le compteur de boucle peut changer d'une répétition à l'autre, mais ce n'est que rarement le cas. Dans de nombreux cas, le compteur de boucle reste le même d'une répétition à l'autre. Ce qui fait qu'une fois la boucle exécutée une première fois, on peut prédire à la perfection les exécutions suivantes. Pour cela, il suffit de déterminer le compteur de boucle après la première exécution de la boucle, puis de le mémoriser dans un compteur. Lors des exécutions suivantes de la boucle, on compte le nombre de fois que le branchement de la boucle s'exécute : il est prédit comme pris tant que le compteur est différent du compteur de boucle, mais non-pris en cas d'égalité. ===Le prédicteur L-TAGE=== Il est possible d'utiliser plusieurs unités de prédiction, chacune utilisant des historiques de taille différente. Pour donner un exemple simple, on pourrait avoir une unité avec un historique de 2 bits, une autre avec un historique de 3 bits, une autre de 4 bits, etc. Une méta-unité de branchement prédit alors quelle taille d'historique donne les meilleurs résultats. Il est fréquent que les tailles d'historiques utilisées suivent une formule mathématique précise, appelée suite géométrique, ce qui est un mot bien barbare pour dire que l'on passe d'un historique au suivant en multipliant sa taille par une constante bien précise. Par exemple, on pourrait avoir des historiques de taille 2, 4, 8, 16, 32. C'est d'ailleurs le principe derrière les '''unités de branchement TAGE (''TAgged GEometric length predictor'')'''. Elles utilisent plusieurs registres d'historiques, chacun avec sa propre PHT, dont la taille suit une suite géométrique. Avec cette méthode, les branchements qui ont un motif répétitif sont alors parfaitement détectés, peu importe sa taille. Par exemple, un branchement avec un historique dont le cycle est pris, non-pris, non-pris, sera parfaitement prédit avec un historique de 3 bits, 6 bits, ou de 3*n bits, mais pas forcément avec un historique de 4 ou 8 bits. De ce fait, utiliser plusieurs unités de branchement avec plusieurs tailles d'historique fonctionne plutôt bien. Le prédicteur TAGE a été raffiné avec le temps, sur plusieurs points, que je ne vais pas détailler dans ce qui suit. Précisons cependant que les PHT sont structurées en caches, afin d'éliminer les phénomènes d'''aliasing''. L'ajout d'une unité de prédiction pour les boucles, a donné le '''prédicteur L-TAGE''', qui a lui-même été amélioré par la suite. Les unités de branchement TAGE sont les plus utilisées au moment où j'écris ces lignes (2026), et la majorité des processeurs x86 commerciaux utilisent une variante de ce prédicteur, à quelques exceptions près. Le '''prédicteur ITTAGE''' peut être adapté pour prédire des branchements indirects. La seule différence est que chaque table d'historique est associée non seulement à une prédiction pris/non-pris, mais aussi à une adresse de destination. L'idée derrière l'ITTAGE est que l'adresse de destination dépend de l'historique. L'adresse de destination dépend de ce qui se passe avant qu'on exécute le branchement indirect, donc de l'historique global. Il est donc fréquent que des historiques différents donne un résultat différent, que ce soit pour la prédiction pris/non-pris, mais aussi pour l'adresse de destination. L'ITTAGE associe donc une adresse de destination par ''pattern'' prédit. ==Les optimisations de la prédiction de branchements== La prédiction de branchement est complexe et toute économie est bonne à prendre. Pour améliorer les performances, ou simplement améliorer l'utilisation du BTB et des PHT, diverses techniques ont été inventées. Ces techniques marchent quel que soit l'unité de prédiction prise en compte. Elles peuvent s'appliquer aussi bien aux unités basées sur des perceptrons, que sur des unités à deux niveaux ou des unités de prédiction dynamique en général. Ces techniques sont assez variés : certaines profitent du fait que de nombreux branchements sont biaisés, d'autres tentent de réduire les interférences entre branchements sans utiliser l'historique global/local, etc. ===Le filtrage de branchements=== Une première optimisation permet d'économiser l'usage des différentes ressources matérielles, comme la BTB ou les PHT, en les réservant aux branchements non-biaisés. Idéalement, les branchements biaisés peuvent être prédits en utilisant des techniques très simples. D'où l'idée d'utiliser deux unités de prédiction spécialisées : une pour les branchements biaisés et une autre plus complexe. L'unité de prédiction pour les branchements biaisés est une unité de prédiction à 1 bit qui indique si le branchement est pris ou non, ce qui suffit largement pour de tels branchements. Cette technique s'appelle le '''filtrage de branchement''' et son nom est assez parlant. On filtre les branchements biaisés pour éviter qu'ils utilisent des PHT et d'autres ressources qui gagneraient à être utilisées pour des branchements plus difficiles à prédire. Appliquer cette idée demande de reconnaître les branchements fortement biaisés d'une manière ou d'une autre, ce qui est plus facile à dire qu'à faire. Une solution similaire enregistre quels branchements sont biaisés dans le BTB, et utilise cette information pour faire des prédictions. ===L'usage de fonctions de hashage pour indexer les diverses SRAM/tables=== Plus haut, nous avons vu que les unités de prédiction de branchement contiennent des structures qui sont adressées par l'adresse de branchement. Tel est le cas du ''Branch Target Buffer'', de certaines PHT globales ou locales, de la ''Branch History Table'' qui stocke les historiques locaux, ou encore de la SRAM des poids des unités à base de perceptrons. En pratique, ces structures sont adressées non pas par l'adresse de branchement complète, mais pas les bits de poids faible de cette adresse. Cela a pour conséquence l'apparition d'un ''aliasing'' lié au fait que ces structures vont confondre deux branchements pour lesquels les bits de poids faible de l'adresse sont identiques. Il est possible d'utiliser autre chose que les bits de poids faible de l'adresse du branchement, afin de limiter les interférences. Et les possibilités sont multiples. Les possibilités en question s'inspirent des traitements effectués sur les adresses des banques dans les mémoires évoluées. Une première solution serait de faire un XOR entre les bits de poids faible de l'adresse du branchement, et d'autres bits de cette même adresse. Ainsi, deux branchements éloignés en mémoire donneraient des résultats différents, même si leurs bits de poids faible sont identiques. Une autre possibilité serait de diviser l'adresse du branchement par un nombre et de garder le reste. Ce calcul de modulo n'est en soi pas très différent du fait de conserver seulement les bits de poids faible. Conserver les N bits de poids faible consiste en effet à prendre le modulo par 2^N de l'adresse. Ici, l'idée serait de faire un modulo par un nombre P, qui serait un nombre premier proche de 2^N. Le fait que le nombre soit premier limite les cas où deux adresses différentes donneraient le même reste, ce qui réduit l'''aliasing''. Le fait de prendre un nombre proche de 2^N pour une entrée de N bits est que le résultat reste assez proche de ce qu'on obtiendrait en gardant les bits de poids faible, cette dernière étant une solution pas trop mauvaise. D'autres possibilités similaires se basent sur des réductions polynomiales ou d'autres astuces impliquant des nombres premiers. L'efficacité de ces méthodes dépend grandement de la taille de la SRAM/table considérée, des adresses des branchements et de beaucoup d'autres paramètres. La réduction des interférences par telle ou telle méthode dépend aussi de l'unité de prédiction considérée. Les résultats ne sont pas les mêmes selon que l'on parle d'une unité à perceptrons ou d'une unité à deux niveaux ou d'unités à base de compteurs à saturation, ou que l'on parle du BTB. Par contre, toutes les méthodes ne sont pas équivalentes en termes de temps de calcul ou de portes logiques. Autant la première solution avec un XOR ajoute un temps de calcul négligeable et quelques portes logiques, autant les autres méthodes requièrent l'ajout d'un diviseur très lent et gourmand en portes logiques pour calculer des modulos. Elles ne sont généralement pas praticables pour ces raisons, le temps de calcul serait trop élevé. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Le pipeline | prevText=Le pipeline | next=Les optimisations du chargement des instructions | nextText=Les optimisations du chargement des instructions }} </noinclude> eenatjslgkzjnyiobhqai29svrlkyne 774413 774412 2026-10-07T22:34:50Z Mewtow 31375 /* La prédiction de destination pour les branchements directs */ 774413 wikitext text/x-wiki Les branchements sont pris en charge par plusieurs étages du pipeline. Exécuter un branchement demande de savoir deux choses : s'il est pris ou non, quelle est l'adresse de destination. L'adresse de destination est localisée grâce au mode d'adressage du branchement. Avec le mode d'adressage direct, elle est extraite de l'instruction par le séquenceur. Avec l'adressage indirect, elle est lue dans un registre. Avec les branchements relatifs, elle est calculée via un additionneur, qui additionne un décalage au ''program counter''. L'additionneur peut être soit dans l'étage de ''PC Update'', soit dans l'unité de calcul de l'étage d'exécution. Passons maintenant au résultat du branchement, à savoir s'il est pris ou non. Pour savoir si le branchement est pris, il faut distinguer les branchements conditionnels et inconditionnels. Pour rappel, les branchements inconditionnels regroupent les appels de fonction, les simples instructions de saut, et quelques autres. Ils sont toujours pris, ils s'exécutent toujours. À partir du moment où le décodeur a décodé un branchement inconditionnel, il sait que le branchement est pris. Pour les branchements conditionnels, ils sont pris seulement si une condition précise est respectée. On sait s'ils sont exécutés une fois que la condition est calculée par l'unité de calcul, ou par une unité de branchement spécialisée. [[File:Gestion des branchements sur un pipeline.png|centre|vignette|upright=2.0|Gestion des branchements conditionnels sur un pipeline.]] Sur le schéma précédent, les branchements inconditionnels ne sont pas montrés. Pour cela, il faudrait rajouter une connexion du décodeur vers le multiplexeur de branchement. De plus, il faudrait aussi rajouter de quoi retarder l'exécution du branchement seulement une fois que l'adresse est connue. Par exemple, elle est connue un cycle d'horloge plus si l'adresse est lue depuis les registres, comparé à un branchement direct. ==Les branchements et les exceptions imprécises/précises== Un branchement est censé altérer le ''program counter'' immédiatement, en un cycle d'horloge. Mais sur un pipeline, Le branchement fait bien reprendre le processeur au bon endroit, mais avec du retard. La raison est qu'un branchement met plusieurs cycles pour s'exécuter. Il faut plusieurs cycles pour déterminer quelle est l'adresse de destination, temps qui dépend de là où est lue l'adresse. Et on sait si le branchement est pris seulement au niveau du décodeur, voire au niveau de l'unité de calcul. Le branchement doit traverser plusieurs étages avant qu'on connaisse son résultat. Il y a bien 3 à 4 cycles avant cela. Et pendant ce temps de trajet, des instructions seront chargées dans le pipeline, potentiellement à tort. Si le branchement n'est pas pris, ce n'est pas grave : les instructions n'ont pas été chargées à tort. Mais dans le cas contraire, les instructions n'auraient pas dû rentrer dans le pipeline. Le problème est similaire à celui observé avec les exceptions précises/imprécises. La différence est que les branchements sont des instructions machines, pas les exceptions matérielles. Mais le problème des instructions chargées à tort est le même dans les grandes lignes. Une différence est que les branchements sont résolus plus ou moins au même étage, alors que les exceptions peuvent survenir dans tout le pipeline. Une autre différence, bien plus importante, est que les branchements non-pris ne chargent pas d'instructions à tort et qu'il faut faire la différence entre branchement pris et non-pris. Dans la suite, nous parlerons d'instructions fautives pour parler des instructions chargées à tort. Pour corriger ce problème, il existe une solution logicielle. Le compilateur fait suivre les branchements par des instructions qui ne font rien, des NOP (No OPeration) : c'est ce qu'on appelle un '''délai de branchement'''. L'idée est que toutes les instructions chargées à tort sont des NOPs, qui ne font rien. Mais le nombre de NOP à ajouter dépend du pipeline du processeur, ce qui peut poser des problèmes de compatibilité. Mais d'autres solutions, matérielles cette fois, sont utilisées de nos jours. ===L'annulation des instructions au décodage=== Les branchements inconditionnels sont dans un cas plus favorable : ils sont identifiés dans l'unité de décodage et sont toujours pris. D'autres branchements sont dans ce cas : les appels de fonctions, les interruptions logicielles. Avec eux, le décodeur annule les instructions fautives, il les remplace par des instructions NOP. L'étage de décodage sait que les instructions suivant un branchement inconditionnel ont été chargées à tort. Précisément N instructions, avec N le nombre d'étages entre le chargement et le décodage (inclus). Les N instructions suivantes sont tout simplement annulés, elles sont remplacées par des NOPs. L'implémentation demande juste un simple compteur et quelques circuits annexes. Cependant, la technique ne marche pas du tout pour les branchements conditionnels. En effet, on ne sait pas s'ils sont pris ou non à l'étage de décodage. Du moins pas avec un pipeline normal. Mais certains processeurs, basés sur le pipeline RISC classique, calculaient les branchements pendant l'étape de décodage, dans une ALU spécialisée qui travaille en parallèle de l'unité de décodage. Le défaut de cette technique est que l'ALU en question doit lire les registres. Pour rappel, sur ce pipeline, l'étape de décodage effectue la lecture des opérandes dans les registres, en parallèle de l'unité de décodage. Mais ce qu'on n'a pas dit dans le chapitre précédent, c'est qu'elle effectue aussi le calcul des branchements dans une mini-ALU séparée ! Ce faisant, le résultat du branchement est connu dès la fin de l'étape de décodage. ===L'usage du système d'exceptions précises pour les branchements conditionnels=== La solution gère le problème en empêchant les instructions chargées à tort d'enregistrer leurs résultats, afin qu'elles n'aient pas le moindre effet sur l'état du processeur. Le hardware pour les exceptions précises, décrit quelques paragraphes avant, est réutilisé pour cela. Les branchements conditionnels peuvent être pris ou non-pris et on doit tenir compte de ces deux cas. Si le branchement n'est pas pris, les instructions n'ont pas été chargées à tort, elles peuvent enregistrer leur résultat dans les registres. Par contre, ce n'est pas le cas pour les branchements pris, les mécanismes d'exceptions précise doivent alors d'activer. Pour cela, la meilleure solution est que l'unité de calcul génère un indicateur d'exception si le branchement n'est pas pris. L'ALU effectue la comparaison et génère ou non l'indicateur d'exception adéquat suivant le résultat de celle-ci. L'idée est d'utiliser un indicateur d'exception particulier, qui précise qu'un branchement a été pris, signe que des instructions ont été chargées à tort. L'indicateur est propagé dans le pipeline, jusqu'à atteindre l'étage d'enregistrement dans les registres. Cet étage décide s'il faut autoriser ou non les écritures qu'il recoit, pour annuler les instructions chargées à tort. Le ''program counter'' est corrigé immédiatement, simplement en exécutant le branchement. Reste à savoir qui génère cet indicateur d'exception de branchement pris. Diverses optimisations visent à éliminer les instructions chargées à tort, à faire en sorte que des instructions utiles soient chargées. Le chapitre suivant va détailler ces optimisations dans le détail, qui sont regroupées sous le terme de prédiction de branchement. Elles ne fonctionnent pas pour les exceptions précises, seulement les branchements. ===La prédiction de branchement=== Les techniques précédentes ont pour défaut que de nombreux cycles d'horloge sont gâchés à cause des instructions fautives. Pour éviter ce gâchis, les concepteurs de processeurs ont inventé l'''exécution spéculative de branchement'', qui consiste à deviner l'adresse de destination du branchement et charger les bonnes instructions dès le départ. Le but est de ne pas charger d'instruction à tort, en prédisant quelles seront les bonnes instructions à charger. Et cela demande de résoudre trois problèmes : * reconnaître les branchements ; * savoir si un branchement sera exécuté ou non : c'est la '''prédiction de branchement''' ; * dans le cas où un branchement serait exécuté, il faut aussi savoir quelle est l'adresse de destination : c'est la '''prédiction de l'adresse de destination''' d'un branchement, aussi appelée ''branch target prediction''. Pour résoudre le second problème, le processeur contient un circuit qui déterminer si le branchement est pris (on doit brancher vers l'adresse de destination) ou non pris (on poursuit l’exécution du programme immédiatement après le branchement) : c'est l''''unité de prédiction de branchement'''. La prédiction de direction de branchement est elle aussi déléguée à un circuit spécialisé : l’'''unité de prédiction de direction de branchement'''. C'est l'unité de prédiction de branchement qui autorise l'unité de prédiction de destination de branchement à modifier le ''program counter''. Dans certains processeurs, les deux unités sont regroupées dans le même circuit. [[File:Relation entre prédiction de branchement et de direction de branchement.png|centre|vignette|upright=2|Relation entre prédiction de branchement et de direction de branchement.]] : Au lieu de parler d'unité de prédiction de branchement, j'utiliserais parfois le terme '''prédicteur''', sous-entendu ''prédicteur de branchement''. Avant de poursuivre, faisons une remarque très importante. J'ai parlé d'unité de prédiction de branchement, comme s'il n'y en avait qu'une seule. Mais en réalité, n'en avoir qu'une seule est l'exception. La règle est d'avoir plusieurs unités de branchements spécialisées. Un processeur moderne utilise plusieurs prédicteurs séparés, chacun prédisant un type de branchement bien précis. Il y a pas exemple un prédicteur spécialisé pour les boucles, un autre pour les instructions de retour de fonctions, un autre pour les branchements directs, un autre pour les branchements indirects. De plus, les processeurs modernes mélangent des prédicteurs rapides et imprécis, avec des prédicteurs lents mais précis. Les prédicteurs de premier type envoient les adresses prédites au cache d'instruction, pour charger les instructions prédites et mettre à jour le ''program counter'' en conséquence. Les prédicteurs lents et précis envoient leurs résultats au décodeur d'instruction, histoire d'annuler des branchements considérés à tort comme pris/non-pris. Il y a donc des prédicteurs rapides, dont le résultat est corrigé par des prédicteurs plus lents. La seconde unité a le dernier mot, elle corrige ou annule les prédictions de la première. Ce qui lui vaut le nom, en anglais, d'''overiding predictor''. Ce détail aura rapidement son imortance. ==Les erreurs de prédiction de branchement== Le processeur peut parfaitement se tromper en faisant ses prédictions, ce qui charge des instructions par erreur dans le pipeline. Dans ce cas, on parle d''''erreur de prédiction'''. Les erreurs de prédiction sont corrigées par le système d'exception précises, ou un équivalent. En clair, les instructions chargées à tort se propagent jusqu'à la sortie du pipeline, où leurs résultats sont alors annulés. N'importe quelle technique de gestion des exceptions précise fait le travail, mais les processeurs modernes utilisent un tampon de réordonnancement (''reorder buffer'') qu'on détaillera dans quelques chapitres. Détecter les erreurs de prédiction demande d'ajouter un circuit dans le processeur : l’''unité de vérification de branchement'' (''branch verification unit''). Elle génère un indicateur d'exception, qui est géré par le système d'exception précise. Pour générer l'indicateur, elle compare l'adresse de destination prédite et celle calculée en exécutant le branchement. Pour cela, l'adresse prédite doit être propagée dans le pipeline jusqu’à ce que l'adresse de destination du branchement soit calculée. [[File:Unité de vérification de branchement.png|centre|vignette|upright=2|Unité de vérification de branchement]] ===La pénalité de mauvaise prédiction=== Le temps pour vider le pipeline des instructions fautives dépend du pipeline. La dernière instruction à être chargée dans le pipeline le sera en même temps qu'on détecte l'erreur de prédiction. Il faudra attendre que cette instruction quitte le pipeline, et donc qu'elle traverse tous les étages. Il y a donc un temps d'attente pour collecter les résultats de ces instructions et la décision de ne pas enregistrer leurs résultats dans les registres Le nombre d'instructions chargées à tort dépend de où est détectée l'erreur de prédiction. Plus le résultat du branchement est connu tard, plus le nombre d'instructions chargées à tort sera grand, et plus le nombre de cycles d'horloge gâchés le sera aussi. Le nombre de cycles gâchés s'appelle la '''pénalité de mauvaise prédiction'''. Elle correspond précisément au nombre de cycles/étages entre le premier étage du pipeline, et l'étage où le branchement est résolu (on connait l'adresse à laquelle brancher). Plus celui-ci est éloigné du début du pipeline, plus la pénalité est importante. [[File:MIT 6.823 Computer System Architecture, Fall 2005 - P-3 vs. P-4 Pipelines - 4473425702.jpg|centre|vignette|upright=2.5|Comparaison de la pénalité de mauvaise prédiction entre le Pentium 3 et le Pentium 4 d'Intel.]] Elle est généralement d'autant plus longue que le pipeline lui-même est long. Les concepteurs de processeurs font donc face à un compromis : un pipeline long permet d'exécuter plus d'instructions en parallèle, mais cela augmente aussi la pénalité de mauvaise prédiction. Un pipeline de 10-15 étapes est un bon compromis. Les pipelines plus longs ont besoin d'une prédiction de branchement élaborée pour fonctionner correctement. Plus la prédiction de branchement est performante, plus le pipeline peut être long. Les pénalités de mauvaise prédiction seront plus importantes, mais les mauvaises prédictions seront plus rares, ce qui compense complétement. ===La ''minimal control dependency''=== En utilisant une technique du nom de ''minimal control dependency'', seules les instructions qui dépendent du résultat du branchement sont supprimées du pipeline en cas de mauvaise prédiction, les autres n'étant pas annulées. Sans ''minimal control dependency'', toutes les instructions qui suivent un branchement dans notre pipeline sont annulées. Pourtant, certaines d'entre elles pourraient être utiles. Prenons un exemple : supposons que l'on dispose d'un processeur de 31 étages (un Pentium 4 par exemple). Supposons que l'adresse du branchement est connue au 9éme étage. On fait face à un branchement qui envoie le processeur seulement 6 instructions plus loin. Si toutes les instructions qui suivent le branchement sont supprimées, les instructions en rouge sont celles qui sont chargées incorrectement. On remarque pourtant que certaines instructions chargées sont potentiellement correctes : celles qui suivent le point d'arrivée du branchement. Elles ne le sont pas forcément : il se peut qu'elles aient des dépendances avec les instructions supprimées. Mais si elles n'en ont pas, alors ces instructions auraient dûes être exécutées. Il serait donc plus efficace de les laisser enregistrer leurs résultats au lieu de les ré-exécuter à l’identique un peu plus tard. Ce genre de choses est possible sur les processeurs qui implémentent une technique du nom de ''Minimal Control Dependancy''. En gros, cette technique fait en sorte que seules les instructions qui dépendent du résultat du branchement soient supprimées du pipeline en cas de mauvaise prédiction. [[File:Minimal cntrol dependency.png|centre|vignette|upright=2|Minimal control dependency]] ==La reconnaissance des branchements== Pour prédire des branchements, le processeur doit faire la différence entre branchements et autres instructions, dès l'étage de chargement. Or, un processeur « normal », sans prédiction de branchement, ne peut faire cette différence qu'à l'étage de décodage, et pas avant. Les chercheurs ont donc dû trouver une solution. La première solution se base sur les techniques de prédécodage vues dans le chapitre sur le cache. Pour rappel, ce prédécodage consiste à décoder partiellement les instructions lors de leur chargement dans le cache d'instructions et à mémoriser des informations utiles dans la ligne de cache. Dans le cas des branchements, les circuits de prédécodage peuvent identifier les branchements et mémoriser cette information dans la ligne de cache. Une autre solution mémorise les branchements déjà rencontrés, dans un cache dédié. Ce cache mémorise l'adresse (le ''program counter'') des branchements déjà rencontrés : il s'appelle le '''tampon d’adresse de branchement''' (''branch adress buffer''). Il est intégré à l'unité de prédiction de branchement, mais est alimenté par le décodeur d'instruction. Reconnaitre un branchement demande l'implication du décodeur d'instruction, qui sait si l'instruction qu'il vient de décoder est un branchement ou non. Il peut même savoir si le branchement est conditionnel, inconditionnel, un appel de retour de fonction, etc. Lorsque le décodeur d'instruction reconnait un branchement, il ajoute ce branchement dans le cache Le tapon d'adresse de branchement contient une adresse mémoire par branchement, et éventuellement d'autres informations. Et pour comprendre quelle adresse est mémorisée, il faut faire attention à un détail. Les explications qui vont suivre vont faire intervenir deux adresses. * La première est l''''adresse de destination''' du branchement, à savoir l'adresse à laquelle le processeur doit reprendre son exécution si le branchement est pris. * L'autre adresse est l''''adresse du branchement''' lui-même. Elle correspond à sa position en mémoire RAM. L'adresse du branchement est la valeur du ''program counter'' quand le branchement est chargé dans le cache, alors que l'adresse de destination est celle du ''program Counter'' une fois le branchement exécuté. Il faudra faire bien attention à ne pas confondre les deux adresses dans ce qui suit. L'adresse du branchement est celle qui est utilisée pour la reconnaissance du branchement. Le tampon d'adresse de branchement mémorise, pour chaque branchement, son adresse de branchement (d'où son nom). À chaque cycle d'horloge, l'unité de chargement envoie le ''program counter'' en entrée du tampon d’adresse de branchement. Un succès dans ce cache indique qu'un branchement déjà rencontré est ré-executé, on peut utiliser la prédiction de branchement. Dans le cas contraire, la prédiction de branchement n'est pas utilisée, et l'instruction est chargée normalement. ==La prédiction de l'adresse de destination== La '''prédiction de l'adresse de destination d'un branchement''' détermine l'adresse de destination d'un branchement. Rappelons qu'il existe plusieurs modes d'adressage pour les branchements et que chacun d'entre eux précise l'adresse de destination à sa manière. Suivant le mode d'adressage, l'adresse de destination est soit dans l'instruction elle-même (adressage direct), soit calculée à l’exécution en additionnant un ''offset'' au ''program counter'' (adressage relatif), soit dans un registre du processeur (branchement indirect), soit précisée de manière implicite (retour de fonction, adresse au sommet de la pile). : Histoire de simplifier la terminologie, je parlerais parfois de '''prédiction de destination''' pour parler de la ''prédiction de l'adresse de destination d'un branchement''. Pour rappel, les branchements se classent en trois types : * les branchements directs pour lequel l'adresse de destination est toujours la même ; * les branchements indirects où l'adresse de destination est dans un registre et peut changer ; * les instructions de retour de fonction. Les branchements directs sont facilement prévisibles, vu que l'adresse vers laquelle il faut brancher est toujours la même. La prédiction des branchements directs et relatifs se fait globalement de la même manière, ce qui fait que nous ferons la confusion dans ce qui suit. Pour les instructions de retour de fonction, une prédiction parfaite est possible. Et c'est ce qui explique qu'elles sont traitées à part, par une unité de branchement dédiée. Pour les branchements indirects, vu que cette adresse change, la prédire celle-ci est particulièrement compliqué (quand c'est possible). Les branchements indirects sont eux aussi traités par une unité de prédiction dédiée rien que pour eux. ===La prédiction de destination pour les branchements directs=== Lorsqu'un branchement est exécuté, on mémoriser l'adresse de destination et la réutiliser lors d'exécutions ultérieures du branchement. Cette technique marche à la perfection pour les branchements directs, pour lesquels cette adresse est toujours la même, mais donne des résultats mitigés pour les branchements indirects. Pour mémoriser l'adresse de destination, on utilise un cache qui mémorise les correspondances entre l'adresse du branchement et l'adresse de destination. Ce cache est appelé le '''''branch target buffer''''' en anglais, qui sera abrévié en BTB dans ce qui suit. Le BTB est une amélioration du tampon d'adresse de branchement vu plus haut, auquel on aurait ajouté les adresses de destination des branchements. Le BTB est utilisé comme suit : on envoie en entrée l'adresse du branchement, le BTB répond au cycle suivant en précisant : s’il reconnaît l'adresse d'entrée, et quelle est l'adresse de destination si c'est le cas. Le BTB peut être un cache totalement associatif, associatif par voie, ou directement adressé, mais ces derniers sont les plus fréquents. Les BTB sont généralement des caches de type directement adressés, où seuls les bits de poids faible de l'adresse du branchement adressent le cache, le reste de l'adresse est tout simplement ignoré. Il existe aussi des BTB qui sont construits comme les caches associatifs à plusieurs voies, mais ceux-ci impliquent généralement la présence de ''tags'', ce qui fait qu'ils sont un peu moins fréquents. [[File:Branch target buffer directement adressé.png|centre|vignette|upright=2|Branch target buffer directement adressé.]] Comme pour tous les caches, un accès au BTB peut entraîner un défaut de cache, c’est-à-dire qu'un branchement censé être dans le BTB n'y est pas. Comme pour les autres caches, les défauts de cache peuvent se classer en trois types : les défauts de cache à froid (''cold miss'') causés par la première exécution d'un branchement, les défauts liés à la capacité du BTB/cache, et les défauts par conflit d'accès au cache. Les défauts liés à la capacité du BTB sont les plus simples à comprendre. La capacité limitée du BTB fait que d'anciens branchements sont éliminées pour laisser la place à de nouveaux. En conséquence, ces branchements peuvent donner des erreurs de prédiction si leur correspondance a été éliminée du cache. On peut réduire des défauts en augmentant la taille du BTB. Les défauts liés aux conflits d'accès au BTB sont eux beaucoup plus intéressants, car la conception du BTB est assez spéciale dans la manière dont sont gérés ce genre de défauts. Le BTB est un cache partiellement associatif, ce qui fait que des défauts de cache par conflit d'accès peuvent avoir lieu. Pour rappel, les défauts par conflit d'accès ont lieu quand deux adresses mémoires se voient attribuer la même ligne de cache. Pour un BTB, cela correspond au cas où deux branchements se voient attribuer la même entrée dans le BTB, ce qui porte le nom d’''aliasing''. Ne pas détecter ce genre de situation sur un cache normal entraînerait des problèmes : la donnée lue/écrite ne serait pas forcément la bonne. Les caches normaux utilisent un système de tags pour éviter ce problème et on s'attendrait à ce que les BTB fassent de même. Mais pour un BTB, l'absence de ''tags'' n'est pas un problème : la seule conséquence est une augmentation du taux de mauvaises prédictions, dont les conséquences en termes de performance peuvent être facilement compensées. Les BTB peuvent donc se passer de tags, ce qui a de nombreux avantages : le circuit est plus rapide, prend moins de portes logiques, consomme moins d'énergie, etc. Ces économies de portes logiques et de performances peuvent être utilisées pour augmenter la taille du BTB, ce qui compense, voire surcompense les mauvaises prédictions induites par l’''aliasing''. Mais ce n'est pas systématique : d'autres BTBs utilisent des ''tags'' pour améliorer le taux de prédiction. Nous ferons donc la différence entre '''BTB tagués''' et '''BTB non-tagués'''. Des processeurs se passent de BTB. À la place, ils mémorisent les correspondances entre branchement et adresse de destination dans les bits de contrôle du cache d'instructions. Mais nous verrons cela en détail dans le chapitre suivant. Plus haut, j'ai dit qu'un processeur moderne utilisait à la fois un prédicteurs rapide et imprécis, avec un prédicteur lent mais précis. Le prédicteur lent a le dernier mot, il corrige ou annule les prédictions du prédicteur rapide. Ce qui lui vaut le nom, en anglais, d'''overiding predictor''. La correction se fait simplement en corrigeant le ''program counter'' et en forçant le décodeur à émettre des NOPs pendant quelques cycles, le temps d'éliminer les instructions chargées à tort. [[File:Double BTB sur les CPU modernes.png|centre|vignette|upright=2|Double BTB sur les CPU modernes.]] Les deux unités de prédiction utilisent des BTB séparés, de taille différente, au moins deux. Vu que les BTB sont des caches, on les appelle des '''BTB de niveau L1 et L2'''. Par exemple, les processeurs Bulldozer d'AMD disposent de deux BTBs : un BTB L1 de 512 entrées, et un BTB L2 de 5120 entrées. Le BTB de niveau L2 est souvent un cache de victime, qui mémorise les branchements retirés de la BTB de niveau L1. La BTB pour les branchements indirects vient en plus de ces deux BTBs, elle complète souvent la BTB de niveau L2. Le ''return stack predictor'' vient en plus des BTB L1 et L2, mais sa petite taille et sa rapidité font qu'il complète l'unité/BTB de niveau L1. Quelques processeurs ont trois niveaux de BTB, avec un niveau L0, L1 et L2. La BTB L0 aliment le cache d'instruction, alors que la L1 et la L2 préviennent les décodeurs d'instruction. La différence entre la L1 et la L2 est que la L2 met plus de temps à prévenir les décodeurs. Les branchements peuvent donc être annulés/forcés à deux étages de décodage différents. Pour donner quelques exemples, les processeurs Zen d'AMD disposent de plusieurs BTBs, avec des niveaux L0, L1 et L2. Le tableau ci-dessous vous donne la taille et la latence de chaque niveau de BTB. La BTB de niveau L0 alimente le cache d'instruction, la L1 permet d'annuler les instructions au premier cycle de décodage, la BTB L2 annule les instructions vers le milieu/fin du décodage. {|class="wikitable" |- ! rowspan="3" | Zen 2 | 16 || 0 |- | 512 || 1 |- | 7168 || 4 |- ! rowspan="3" | Zen 3 | 16 || 0 |- | 1024 || 1 |- | 6656 + 1536 (branchements indirects)|| 4 |} ===La prédiction de destination pour les branchements indirects=== Un BTB classique peut fonctionner pour les branchements indirects. Son seul défaut est qu'il mémorisera la dernière adresse de destination prise par le branchement. Et cela peut fonctionner pour les branchements indirects fortement biaisés, qui ont presque toujours la même adresse de destination. Mais la majorité des branchements indirects change d'adresse de destination régulièrement. Tous les processeurs commerciaux datant d'avant le Pentium M utilisaient un BTB normal pour prédire les branchements indirects. Les processeurs qui les ont suivi sont capables de prédire l'adresse de destination d'un branchement indirect. , grâce à une '''unité de prédiction des branchements indirects''', séparée de l'unité de prédiction de branchement normale. Elle utilise un BTB amélioré, qui mémorise plusieurs adresses de destination pour un seul branchement, en compagnie d'informations pour prédire laquelle est la bonne. Il est possible d'utiliser un BTB unique, pour tous types de branchements, appelé un '''BTB unifié'''. Un BTB unifié a plusieurs adresse de destination par entrée du BTB. Mais une seule est utilisée pour les branchements directs. Vu que les branchements directs sont majoritaires, cela fait beaucoup d'entrées inutilisées. En conséquence, elle est tout sauf optimale, et n'est presque jamais utilisée en pratique. {|class="wikitable" |+ Branch Target Buffer unifié |- ! Adresse du branchement !! Informations de prédiction !! Adresse de destination !! Seconde adresse de destination |- | 0x54462146 || 11101011 || 0x52495674 || 0x24987461 |- | 0x54142446 || 11101101 || 0x15646956 || x0x4158746 |- | 0x25456542 || 10011101 || 0x52495674 || 0x24157461 |- | 0x15455855 || 11010101 || 0x52485754 || 0x24024511 |- | ... || ... || ... || ... |- | ... || ... || ... || ... |- | 0x54462146 || 1001101 || 0x52495674 || 0x24987461 |} Une seconde solution utilise un BTB séparé, distinct du BTB pour les branchements directs. En séparant les deux, on a un BTB simple qui ne mémorise qu'une adresse de destination par branchement, et un BTB pour branchements indirects avec plusieurs adresses de destination par branchement. [[File:BTB avec prédiction de branchements indirects.png|centre|vignette|upright=2.5|BTB avec prédiction de branchements indirects.]] L'idée est que l'on économise des circuits à nombre d'entrées égales. Par exemple, comparons ce que cela donne avec 64 entrées. D'un côté, on a un BTB à 64 entrées, capable de gérer deux adresse de destination par branchement. En pratique, la majorité des branchements sont des branchements directs, qui n'utiliseraient qu'une seule case mémoire. Les circuits du BTB seraient donc sous-utilisés. De l'autre, on a un BTB à 48 entrées pour branchements directs, et un second BTB avec 16 entrées, capable de mémoriser deux adresses par branchements. Le premier BTB utilise moins de circuits, et est utilisé à pleine capacité, il ne gaspille pas de circuits pour mémoriser des adresses de destination vides. Par contre, on perd un peu en flexibilité. L'organisation précédente marche bien si 1/4 des branchements rencontrés sont des branchements indirects. Mais si le ratio est différent, la performance est légèrement sous-optimale. Attention, cela ne veut pas dire qu'il y aura des entrées vides dans un BTB, non. Simplement, le premier BTB risque de manquer de place pour les branchements directs récents, alors que les branchements indirects seront eux bien prédits, même les anciens. À l'opposé, un BTB unique permet de mémoriser les 64 derniers branchements, peu importe qu'ils soient indirects ou non. En clair : un BTB unique est plus flexible, ce qui donne un gain de performance dans certaines situations. Cependant, cet avantage théorique ne tient pas compte de l'économie en circuits, qui peut être utilisée pour faire grossir le BTB pour branchements directs. Par exemple, à budget à transistor équivalent, on a le choix entre BTB à 64 entrées uniques, et un BTB pour branchements directs de 80 entrées avec un BTB indirect de 16 entrées. Le fait qu'un BTB soit plus gros permet des gains de performances qui compensent la perte de flexibilité. On peut ainsi avoir le meilleur des deux monde, à savoir une économie de circuits et les gains en performance qui en découlent, malgré une petite perte de flexibilité. Une troisième solution utilise un BTB normal, mais réserve plusieurs entrées pour un branchement indirect. Par exemple, un branchement indirect qui a trois adresses de destination prendra trois entrées : une par adresse de destination. Mais quelques difficultés surviennent alors. Comment placer trois copies d'un même branchement, alors que ces trois copies ont le même ''program counter'' ? Et que faire pour savoir quelle copie sera prise ? Que faire si l'unité de prédiction de branchement prédit les trois copies comme prises ? La technique du '''''Program Counter'' virtuel''' (''Virtual Program Counter'') résoud ces problèmes d'une manière assez élégante. L'idée est la même que précédemment, à savoir qu'il y a une copie du branchement par adresse de destination, dans le BTB. Mais ces copies n'ont pas exactement le même ''Program counter''. Chaque copie se voit attribuer un ''Program Counter'' virtuel. Et comprendre pourquoi demande de comprendre l'idée sous-jacente. La technique a été inspirée par une optimisation des compilateurs, qui transforme un branchement indirects en une séquence de branchements conditionnels. Par exemple, un branchement indirects qui peut brancher vers les adresses A, B et C est transformé en trois branchements conditionnels : un qui branche vers A, un autre qui branche vers B et un autre vers C. Elle ne marche pas pour tous les branchements indirects, elle demande des circonstances particulières pour fonctionner. Mais l'idée est adaptée pour le ''design'' de l'unité de prédiction de branchement. L'idée est qu'un branchement indirect est déroulé en plusieurs branchements dans le BTB. Les branchements sont traités en séquence : l'unité de prédiction prédit si le premier branchement est pris ou non. S'il est pris, son adresse de destination est envoyée au cache d'instruction. S'il n'est pas pris, l'unité de prédiction tester la seconde copie du branchement, et rebelote. Il teste toutes les copies, une par une, et s’arrête quand il tombe sur un branchement pris. Si aucune copie n'est prise, alors le branchement est prédit comme non-pris. Première remarque : il s'agit d'un processus itératif, où on test un branchement à la fois. Et oui, c'est une prédiction par cycle d'horloge. Prédire un branchement indirect peut donc demander plusieurs cycles d'horloge. Mais ce n'est pas un problème, car le prédicteur peut servir d'''overriding predictor'', d'unité de prédiction lente mais précise. La prédiction peut être utilisée par l'unité de décodage, pour annuler les instructions chargées à tord. Deuxième remarque : comment passer d'un branchement à l'autre ? La première copie a un ''program counter'' normal, qui correspond à l'adresse du branchement. La seconde a un ''program counter'' qui est calculé à partir de cette première adresse, la troisième copie a une adresse calculée à partir de la seconde, etc. Concrètement, n'importe quelle fonction un tant soit peu simple suffit, tant qu'elle est déterministe. L'article qui a proposé cette techniques (''Virtual Program Counter (VPC) Prediction : Very Low Cost Indirect Branch Prediction Using Conditional Branch Prediction Hardware'') se contenait de faire un XOR entre l'adresse précédente et une constante, avec une constante différente par itération. Troisième remarque : l'algorithme demande de faire une parenthèse dans le fonctionnement normal du BTB. Quand un branchement indirect est détecté, il lance une succession de prédiction pour prédire un branchement. L'incrémentation du ''program counter'' normale cesse pendant ce processus. Et cela ne doit être fait que quand un branchement indirect est détecté. Pour cela, le BTB mémorise le type de chaque branchement, sans l'entrée du BTBT : est-ce qu'il est conditionnel, inconditionnel, mais aussi direct ou indirect. Et le type est accesible en sortie du BTB, sur une sortie de quelques bit. Quand l'unité de prédiction remarque qu'un branchement indirect vient de sortir du BTB, alors elle lance le processus. [[File:Virtual program counter.png|centre|vignette|upright=2.5|Virtual program counter]] L'avantage de cette technique est qu'elle s’implémente facilement, son cout en transistors est faible. Elle demande juste d'ajouter quelques circuits autour d'un BTB classique. Elle utilise l'unité de prédiction classique, pas besoin d'une unité de prédiction séparée. Vu qu'il y a un seul BTB, on n'a pas les désavantages d'un BTB séparé pour les branchements indirects. Les entrées du BTB sont réparties entre branchements directs et indirects de manière optimale. Niveau performance, elle est un peu moins bonne que les unités de prédiction ITTAGE qu'on détaillera dans la suite du chapitre. Mais elle est suffisante pour être utilisée sur certains processeurs commerciaux. ===La prédiction de l'adresse de retour d'une fonction=== Presque tous les processeurs conçus après les années 2000 peuvent prévoir l'adresse à laquelle il faudra reprendre lorsqu'une fonction/sous-programme a fini de s’exécuter. Ils font de la '''prédiction de retour de fonction'''. Pour cela, ils possèdent pour une unité de prédiction de branchement spécialisée, appelée le ''return stack predictor'', ou '''''pile de prédiction de retour de fonction'''''. Le terme ''return stack predictor'' étant plus court, nous l'utiliserons dans la suite du cours. Le ''return stack predictor'' est capable de reconnaitre les instructions de retour de fonction, et de prédire l'adresse de retour. Pour comprendre comment elle fait, rappelez-vous le chapitre sur la pile d'appel. Dans ce chapitre, j'avais introduit le concept de ''pile d'adresse de retour'', à savoir une pile d'appel très simplifiée, qui ne contient que les adresses de retour des fonctions en cours d'exécution. Le ''return stack predictor'' est essentiellement composé d'une telle pile d'adresse de retour, avec des circuits pour empiler et dépiler les adresses de retour. Le ''return stack predictor'' doit reconstruire une pile d'adresses de retour, à partir du programme exécuté. La pile d'adresse est mémorisée dans le ''return stack predictor'', elle est totalement séparée de la pile d'appel normale. Le programme exécuté continue d'utiliser la même pile d'appel qu'avant, ce qui fait que les adresses de retour sont dispersées dans la pile d'appel, parfois dans des registres spéciaux du processeur. Mais cela ne pose pas de problèmes pour le processeur, qui reconstruit la pile d'adresse de retour avec l'aide du décodeur. La reconstruction se fait à partir des instructions d'appel et de retour de fonction. Une instruction d'appel de fonction empile une adresse de retour, une instruction de retour de fonction la dépile. Lorsque le décodeur d'instruction reconnait une instruction d'appel de fonction, il extrait l'adresse de retour et l'envoie au ''return stack predictor''. L'adresse de retour est alors empilée, ce qui fait qu'elle se retrouve au sommet de la pile d'adresse de retour. Dépiler une adresse de retour revient à prédire un branchement. Lorsque le ''return stack predictor'' détecte une instruction de retour de fonction, il dépile l'adresse au sommet de la pile. L'adresse est alors envoyée du cache d'instruction, pour l'ire l'instruction adéquate, et le ''program counter'' est mis à jour avec cette adresse. Toute la difficulté revient à détecter les instructions de retour de fonction, sans quoi on ne sait pas quand dépiler l'adresse. La documentation là-dessus n'est pas très fournie, il est difficile de savoir ce qui est fait dans les processeurs modernes. Mais on peut faire quelques suppositions. La première possibilité est l'usage du ''prédécodage'', une technique qui décode partiellement les instructions lors de leur chargement dans le cache d'instruction. Elle sera vue dans le chapitre suivant, mais parlons-en rapidement. Le prédécodage ajoute quelques bits au début de chaque instruction pour préciser si c'est un branchement conditionnel, inconditionnel, un retour de fonction. On peut ainsi savoir rapidement quand dépiler : dès qu'une instruction est lue dans le cache d'instruction, le ''return stack predictor'' vérifie les bits ajoutés et agit en fonction. La seconde possibilité est que le BTB reconnait l'instruction de retour de fonction. Pour chaque branchement, il mémorise non seulement son adresse de retour, mais aussi son type. Par type, on veut dire : si c'est un branchement conditionnel, inconditionnel, un retour de fonction. Les retours de fonction sont donc reconnus à condition qu'ils aient été exécutés auparavant. Il est aussi possible d'avoir un BTB spécialisé dans les retours de fonction, qui est alimenté par les décodeurs. Dès que le décodeur reconnait une instruction de retour de fonction, il ajoute son ''program counter'' dans ce BTB spécialisé. Une dernière possibilité est que le ''return stack predictor'' agit une fois l'instruction partiellement décodée. Le décodeur d'instruction reconnait les instructions de retour de fonction rapidement, en un cycle, et consulte alors le ''return stack predictor''. L'adresse est dépilée, et le décodeur sait où brancher. Il n'a pas besoin de devoir lire l'adresse de retour en mémoire, il la lit depuis le ''return stack predictor'', directement. La prédiction se fait donc assez tard dans le pipeline, quelques instructions ont été chargées à tort. Mais le décodeur peut les annuler, comme il le ferait avec un branchement inconditionnel mal prédit. ==La prédiction de branchement locale== La prédiction de branchement tente de prédire si un branchement sera pris ou non-pris, et décide quelle adresse envoyer au cache en fonction du résultat. Si on prédit qu'un branchement est non pris, on laisse le ''program counter'' faire son travail. À l'inverse si le branchement est prédit comme étant pris, l'adresse de destination est envoyée au cache d'instruction et au ''program counter''. La prédiction de branchement assigne une probabilité qu'un soit branchement soit pris en fonction de ces informations : si la probabilité est de plus de 50%, le branchement est considéré comme pris. La prédiction de branchement se base avant tout sur des statistiques, c’est-à-dire qu'elle détermine la probabilité d’exécution d'un branchement en fonction d'informations assez diverses. La prédiction de branchement n'a d'intérêt que si les prédictions sont suffisamment fiables pour valoir le coup. Rappelons que la pénalité lors d'une mauvaise prédiction est importante, d'autant plus coûteuse que le pipeline est long. Il est généralement admis qu'il faut au minimum un taux de réussite proche de 90%, voire plus sur les processeurs modernes. Heureusement, la plupart des branchements sont des '''branchements biaisés''', c’est-à-dire qu'ils sont presque toujours pris ou presque toujours non-pris, sauf en de rares occasions. De tels branchements font que la prédiction des branchements est facile, bien qu'ils posent paradoxalement quelques problèmes, comme nous le verrons plus bas. Un autre point important est que les unités de prédiction de branchement doivent être très rapides. Idéalement, elles doivent fournir leur prédiction en un seul cycle d'horloge. Un seul cycle d'horloge est un temps très court, surtout sur les ordinateurs avec une fréquence élevée. Les techniques de prédiction dynamique ne peuvent donc pas utiliser des méthodes statistiques extraordinairement complexes. Cela va à l'encontre du fait que le taux de mauvaises prédictions doit être très faible, idéalement inférieur à 1% sur les processeurs modernes. Vous comprenez donc aisément que concevoir une unité de prédiction de branchement est un véritable défi d’ingénierie électronique qui requiert des prouesses technologiques sans précédent. Suivant la nature des informations utilisées, on peut distinguer plusieurs types de prédiction de branchement : locale, globale, dynamique, statique, etc. Nous allons d'abord voir les méthodes statiques avant de voir les méthodes dynamiques. La '''prédiction locale''' prédit chaque branchement indépendamment les uns des autres. Elle sépare bien les statistiques/moyennes pour chaque branchement. Et c'est autant un point fort qu'un point faible. En effet, elle n'exploite pas les corrélations entre branchements, à savoir le fait que l'exécution d'un branchement a tendance à déborder sur les suivants. Dans ce qui suit, nous allons voir les techniques de prédiction locales. ===Les ''branch hints''=== Dans son implémentation la plus explicite, la prédiction est inscrite dans le branchement lui-même. Quelques bits de l'opcode du branchement précisent si le branchement est majoritairement pris ou non pris. Ils permettent d'influencer les règles de prédiction de branchement et de passer outre les réglages par défaut. Ces bits sont appelés des '''suggestions de branchement''' (''branch hint''). Mais tous les processeurs ne gèrent pas cette fonctionnalité. Et de plus, cette solution marche assez mal. La raison est que le programmeur ou le compilateur doit déterminer si le branchement est souvent pris ou non-pris, mais qu'il n'a pas de moyen réellement fiable pour cela. Une solution possible est d’exécuter le programme sur un ensemble de données réalistes et d'analyser le comportement de chaque branchement, afin de déterminer les suggestions de branchement adéquates, mais c'est une solution lourde et peu pratique, qui a de bonnes chances de donner des résultats peu reproductibles d'une exécution à l'autre. ===La prédiction statique de branchement=== La '''prédiction statique''' prédit le résultat du branchement en fonction de caractéristiques fixes du branchement lui-même, comme son adresse, son mode d'adressage, l'adresse de destination, la position du branchement en mémoire, si le branchement est direct ou indirect, etc. Les informations en question sont connues une fois le branchement décodé, il n'y a pas besoin de mémoriser la moindre information supplémentaire. Une première implémentation, assez évidente, fait la différence entre les branchements inconditionnels toujours pris, et les branchements conditionnels au résultat variable. L'idée est de prédire les branchements inconditionnels comme toujours pris alors que les branchements conditionnels sont prédits comme non-pris (ce qui est une approximation). L'implémentation est relativement simple : on utilise un BTB qui ne mémorise que les branchements inconditionnels, et basta ! Les branchements conditionnels sont simplement ignorés, traités comme n'importe quelle autre instruction. Cette méthode est particulièrement inefficace pour les branchements de boucle, où la condition est toujours vraie, sauf en sortie de boucle ! Il a donc fallu affiner légèrement l'algorithme de prédiction statique. Une implémentation plus subtile fait la différence entre les branchements des boucles et les autres. Reste à identifier ceux-ci d'une manière ou d'une autre. Et un bon moyen est de regarder si le branchement revient en arrière ou au contraire saute une portion de code. Un programme s'exécute des adresses basses vers les adresses hautes, les instructions vont naturellement dans le sens croissant des adresses. Un branchement de boucle fait revenir en arrière, histoire de répéter un morceau de code, pas les autres branchements (du moins pas forcément). Et cela nous amène à faire la distinction entre les branchements conditionnels ascendants et descendants. Un '''branchement conditionnel ascendant''' est un branchement qui demande au processeur de reprendre plus loin dans la mémoire : l'adresse de destination est supérieure à l'adresse du branchement. Un branchement '''conditionnel descendant''' a une adresse de destination inférieure à l'adresse du branchement : le branchement demande au processeur de reprendre en arrière dans la mémoire, avant le branchement. Les branchements ascendants sont rarement pris (ils servent dans les conditions de type SI…ALORS), contrairement aux branchements descendants (qui servent à fabriquer des boucles). On peut ainsi modifier l’algorithme de prédiction statique comme suit : * les branchements inconditionnels sont toujours pris ; * les branchements descendants sont toujours pris ; * les branchements ascendants ne sont jamais pris. L'implémentation demande d'élargir le BTB. Le BTB n'a pas seulement à mémoriser l'adresse du branchement et son adresse de destination, mais aussi le type du branchement. En général, il se contente de deux bits, qui indiquent si le branchement est conditionnel, inconditionnel, ou un retour de fonction. Les retours de fonction sont traités à part, histoire de profiter du ''return stack predictor''. {|class="wikitable" |+ BTB avec prédiction de branchement statique évoluée |- ! Adresse du branchement !! Adresse de destination !! Type du branchement |- | 0x4165485 || 0x14657445541 | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Retour de fonction |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... || ... |} ===La prédiction avec des compteurs à saturation=== Les méthodes de prédiction de branchement statique sont intéressantes, mais elles montrent rapidement leurs limites. Pour plus de performances, il faut utiliser la '''prédiction dynamique''', qui extrait des statistiques sur l’exécution des branchements. Pour le reste du chapitre, toutes les techniques utilisent la prédiction dynamique, qu'elles soient locales ou globales. Sa version la plus simple se contente de mémoriser ce qui s'est passé lors de l’exécution précédente du branchement. Si le branchement a été pris, alors on suppose qu'il sera pris la prochaine fois. De même, s'il n'a pas été pris, on suppose qu'il ne le sera pas lors de sa prochaine exécution. Pour cela, chaque entrée du BTB contient un bit qui mémorise la dernière exécution du branchement : 1 s'il a été pris, 0 s'il n'a pas été pris. C'est ce qu'on appelle la '''prédiction de branchements sur 1 bit'''. Au vu de sa simplicité, il ne faut pas s'attendre à des miracles. La méthode fonctionne bien pour les branchements des boucles et les branchements inconditionnels, mais elle n'est pas assez performante. Une forme plus élaborée de prédiction utilise des '''compteurs à saturation'''. Un compteur à saturation est un compteur qui est incrémenté à chaque fois qu'un branchement est pris, décrémenté s'il est non-pris. Le compteur est initialisé à la moitié de sa valeur maximale, ce qui correspond à une probabilité proche de 50%. Les compteurs à saturation sont très utilisés et pas seulement pour de la prédiction locale, les méthodes globales en utilisent aussi. Pour faire la prédiction, l'unité de prédiction regarde le bit de poids fort du compteur. Le branchement est considéré comme pris si ce bit de poids fort est à 1, et non pris s'il vaut 0. En clair : on considère que le branchement est pris si le compteur indique une probabilité supérieure à 50%. [[File:Compteur à saturation.png|centre|vignette|upright=2|Compteur à saturation.]] : Pour la culture générale : le compteur à saturation du Pentium 1 était légèrement bogué, ce qui fait que sa prédiction de branchement était sous-optimale. La plupart des processeurs utilisent un compteur à saturation par branchement, ce qui veut dire un compteur à saturation par entrée dans le BTB. Vu chaque compteur est associé à une entrée du BTB, le phénomène d’''aliasing'' mentionné plus haut peut perturber les prédictions de branchement. Deux branchements peuvent se voir associés à la même entrée du BTB, et agir sur le même compteur à saturation, ce qui entraine l'apparition d''''interférences entre branchements'''. L'interférence peut avoir des effets positifs, neutres ou négatifs, suivant la corrélation entre les deux branchements. L’''aliasing'' n'est pas un problème si les deux branchements sont fortement corrélés, par exemple si les deux sont tout le temps pris ou tout le temps non-pris. Si les deux branchements vont dans le même sens, l'interférence est neutre, voire légèrement positive. Mais si l'un est souvent pris et l’autre souvent non-pris, l'interférence est négative. Les deux branchements vont se marcher dessus sans vergogne, chacun interférant sur les prédictions de l'autre ! Il est rare que l’''aliasing'' ait un impact significatif sur les unités de prédiction des deux paragraphes précédents. Il se manifeste surtout quand la BTB est très petite et la seule solution est d'augmenter la taille du BTB. ===La prédiction avec un historique local=== La prédiction avec des compteurs à saturation est très simple, mais il est possible de créer une implémentation alternative, très proche, avec des avantages et désavantages différents. Un compteur à saturation enregistre l'historique d'un branchement, à savoir ses dernières exécutions. L''''historique''' d'un branchement dit quel a été son résultat dans le passé, pour ses exécutions précédentes. Et il est possible d'enregistrer cet historique et l'utiliser pour faire des prédictions. Par exemple, prenons un branchement sont ses 6 dernières exécutions sont "pris, non-pris, pris, pris, pris, non-pris". Le processeur peut encoder cet historique sur 6 bits. Un branchement pris correspond à un bit à 1, un branchement non-pris correspond à un 0. Le bit de poids fort correspond à l'exécution la plus ancienne, le bit de poids faible à l'exécution la plus récente. Par exemple, si l'historique contient 010, cela veut dire que le branchement a été non pris, puis pris, puis non pris. L'historique d'un branchement est appelé son '''historique local''', mais nous parlerons simplement l'historique. Le processeur peut mémoriser l'historique de chaque branchement. Pour cela, il réserve n bits par branchements, pour mémoriser ses n exécutions précédentes. L'historique d'un branchement est mémorisé dans son entrée du BTB. Et plus précisément dans un champ séparé qui est alimentée comme un registre à décalage. Reste à exploiter cet historique local pour faire des prédictions. Une idée assez simple serait de l'utiliser pour calculer une moyenne des N exécutions précédentes. Si la probabilité d'être pris est supérieure à 50 %, le branchement est considéré comme pris. Dans le cas contraire, il est considéré comme non pris. Le calcul de la moyenne utilise une simple porte à majorité, qui prend en entrée tous les bits de l'historique. Cependant, cette méthode est équivalente à utiliser un compteur à saturation par branchement. La sortie de la porte à majorité est équivalente à ce qu'on aurait dans un compteur à saturation. La seule différence : le cout en circuit et la simplicité de conception. Prenons l'exemple des premiers processeurs PA RISC. Ils mémorisaient, pour chaque branchement, les trois dernières exécutions du branchement, avec trois bits par entrée du BTB. La prédiction était réalisée avec une porte à majorité, dont le résultat était utilisé comme prédiction. Ces processeurs ont préféré utiliser un historique de 3 bits, car il était plus simple à concevoir qu'un compteur à saturation équivalent de deux bits. Il suffisait d'alimenter un registre à décalage avec un bit, pas besoin d'incrémenter ou de décrémenter un compteur, pas besoin de signaux d'incrémentation/décrémentation séparés, etc. Mais pour des historiques plus longs, la donne s'inverse et des compteurs à saturation de deux-trois bits l’emportent. Un défaut de la méthode précédente, qui est partagée avec les compteurs à saturation, est qu'elle marche mal pour les branchements qui suivent un ''pattern''. Je veux dire par là que l'exécution d'un branchement suit un cycle, par exemple pris, pris, non-pris, pris, pris, non-pris, et ainsi de suite en boucle. Notons que l'apparition de tels motifs correspond le plus souvent à la présence de boucles dans le programme. Quand une boucle s’exécute, les branchements qui sont à l'intérieur tendent à exprimer de tels motifs. Avec de tels motifs, les compteurs à saturation feront des prédictions incorrectes, là où les prédicteurs qu'on va voir font des prédictions parfaites. Par exemple, prenons un branchement qui suit le ''pattern'' pris, pris, pris, non-pris. Si l'historique local vaut 1110, le branchement est censé refaire une boucle et être pris. Et certes, on a supposé que l'historique faisait 4 bits. Mais le''pattern'' sera visible tant que l'historique fait plus de 4 bits. Par exemple, le ''pattern'' serait 01110 pour un historique local de 5 bits, 101110 pour un historique local de 6 bits, 1101110 pour un historique local de 7 bits, etc. Un ''pattern'' correspond donc à une valeur bien précise de l'historique local. L'historique local permet de voir ces cycles, reste à les exploiter. Prédire correctement les ''pattern'' demande simplement d'associer un compteur à saturation à chaque valeur possible de l'historique local. Le compteur à saturation dira, quand tel pattern est rencontré, si le branchement sera pris ou non-pris par la suite. Au total, il faut donc un registre d'historique local de n bits couplé à 2^n compteurs à saturation. Et ce pour chaque branchement possible. Les compteurs à saturation sont regroupés dans une '''''Pattern History Table''''', que nous noterons PHT dans ce qui suit. Le résultat est une '''unité de prédiction à deux niveaux locale''', qui contient plusieurs registres d'historique et tout autant de PHT locales. En tout, il y a une PHT et un historique par branchement, donc par entrée du BTB. Quand un branchement est exécuté, son historique local indique quel compteur à saturation choisir dans la PHT locale. En général, l'historique local est mémorisé dans le BTB, reste à gérer la PHT. [[File:Unité de prédiction à deux niveaux purement locale.png|centre|vignette|upright=2.5|Unité de prédiction à deux niveaux avec des historiques et des PHT locales.]] Une unité de prédiction locale à deux niveaux contient plusieurs PHT, reste à savoir quelle PHT correspond à tel branchement. La correspondance se fait idéalement à partir de l'adresse du branchement, mais c'est là une implémentation idéale, qui demande beaucoup de circuits. Généralement, l'unité de prédiction n'utilise que les bits de poids faible de l'adresse du branchement, ce qui permet de faire un choix correct, mais avec une possibilité d'''aliasing''. Il est aussi possible de séparer l'historique local du BTB. Les historiques locaux sont alors mémorisés dans une mémoire RAM dédiée, appelé la '''''Branch History Table''''', ou encore la '''table des historiques locaux''', que nous noterons BHT dans ce qui suivra. Chaque byte de la BHT contient l'historique local associé à un branchement. Pour récupérer l'historique local voulu, l'adresse du branchement est envoyée à la BHT, sur les entrées d'adresse de la BHT, directement. Seuls les bits de poids faible de l'adresse sont utilisés. L'historique local est ensuite envoyé à la PHT adéquate, choisie en fonction des mêmes bits de poids faible du PC. Un bon moyen pour cela est d'accéder à toutes les PHT en parallèle, mais de sélectionner la bonne avec un multiplexeur, ce dernier étant commandé par les bits de poids faible du PC. [[File:Table des historiques locaux.jpg|centre|vignette|upright=2|Table des historiques locaux]] Un défaut des unités de prédiction purement locales de ce type est leur cout en termes de circuits. Utiliser un registre d'historique et une PHT par branchement a un cout en transistor élevé, avec tous les défauts que ca implique en termes de consommation électrique et thermique. Elles ont aussi des temps de calcul un peu plus important que les unités purement globales, mais pas de manière flagrante. Les gains en performance avec un historique local s'observent surtout pour les branchements qui sont dans des boucles ou pour les branchements utilisés pour concevoir des boucles, alors que l'implémentation globale marche bien pour les if..else (surtout quand ils sont imbriqués). Les deux sont donc complémentaires. Dans les faits, elles sont rarement utilisées seules du fait de leurs défauts, au profit d'unités de prédiction hybrides, qui mélangent historiques et PHT locaux et globaux. ==La prédiction de branchement globale/mixte== Utiliser un historique local avec une PHT est très puissant. Mais le cout en circuit est très important. Cependant, on peut adapter l'idée pour faire de la prédiction globale. La '''prédiction globale''' fait des prédictions holistiques, qui tiennent compte de tous les branchements, sans les traiter séparément. Elle ne sépare pas les branchements, tous sont mis dans le même panier. La prédiction globale fait donc le compromis inverse : elle exploite les corrélations entre branchements, mais perd les informations statistiques précise pour chaque branchement. L'idée est qu'au lieu d'utiliser un historique par branchement, on utilise un '''historique global''' qui mémorise les exécutions de tous les branchements conditionnels. L'historique enregistre les N exécutions de branchements conditionnels. L'historique global est mémorisé dans un registre unique, appelé le '''registre d'historique global'''. C'est un registre à décalage mis à jour à chaque fois que le processeur exécute un branchement : on fait rentrer un 1 si le branchement est pris, et un 0 sinon. Une unité de prédiction globale utilise cet historique pour faire sa prédiction. L'unité de prédiction globale utilise un compteur à saturation pour chaque valeur possible du registre d'historique. En clair, pour un registre de N bits, elle utilise 2^n compteurs à saturation. Là encore, les compteurs à saturation sont regroupés dans une ''Pattern History Table''. Nous parlerons de '''PHT globale''' pour désigner une PHT commandée par un historique global, de ''PHT locale'' si elle est adressée par un historique local. [[File:Prédiction dynamique à deux niveaux.png|centre|vignette|upright=2|Prédiction dynamique à deux niveaux.]] Il est admis que l'historique global donne de meilleures prédictions que l'historique local. Mais les deux sont complémentaires, ce qui fait qu'il existe des approches hybrides. Les approches hybrides exploitent à la fois les statistiques pour chaque branchement et les corrélations entre branchements. Les prédicteurs hybrides ont de meilleures performances que les prédicteurs purement globaux ou locaux. Avec un historique global, la prédiction tient compte d'éventuelles corrélations entre branchements consécutifs/proches pour faire les prédictions. C'est un avantage pour les applications qui enchaînent des if...else ou qui contiennent beaucoup de if...else imbriqués les uns dans les autres, qui s’exécutent plusieurs fois de suite. Le résultat de chaque if...else dépend généralement des précédents, ce qui rend l'utilisation d'un historique global intéressant. Par contre, ils sont assez mauvais pour le reste des branchements. Mais cette qualité devient un défaut quand elle détecte des corrélations fortuites ou parasites, inutiles pour la prédiction (corrélation n'est pas causalité, même pour prédire des branchements). Du fait de ce défaut, la prédiction globale a besoin de registres d'historiques globaux très larges, de plusieurs centaines de bits au mieux. [[File:2 level branch predictor.svg|centre|vignette|upright=2|Unités de prédiction de branchement à deux niveaux avec une seule PHT.]] Le circuit précédent a cependant un problème : si deux branchements différents s'exécutent et que l'historique est le même, le processeur n'y verra que du feu. Il y a alors une interférence, à savoir que les deux branchements vont agir sur le même compteur à saturation. On se retrouve alors dans une situation conceptuellement identique à l’''aliasing'' dans le BTB ou avec des compteurs à saturation. Sauf que les interférences sont alors beaucoup plus nombreuses et qu'elles sont clairement un problème pour les performances ! Mitiger les interférences devient alors bien plus important qu'avec les unités de prédiction précédentes. Heureusement, si augmenter la taille de la PHT peut mitiger le problème, d'autres solutions sont possibles sur les unités de prédiction globales. Elles sont au nombre de deux : mitiger les interférences liées aux branchements biaisés, ajouter des informations qui discriminent les branchements. Voyons ces solutions dans le détail. ===La mitigation des interférences par usage de l'adresse de branchement=== La première solution pour réduire l'impact de l’''aliasing'' est d'augmenter la taille de la PHT et de l'historique. Plus l'historique et la PHT sont grands, moins l’''aliasing'' est fréquent. Mais avoir des historiques et des PHT de très grande taille n'est pas une mince affaire et le cout en termes de performances et de portes logiques est souvent trop lourd. Une autre solution consiste à utiliser, en plus de l'historique, des informations sur le branchement lui-même pour choisir le compteur à saturation à utiliser. Typiquement, on peut utiliser l'adresse du branchement en plus de l'historique, pour choisir le compteur à saturation adéquat. Pour être précis, on n'utilise que rarement l'adresse complète du branchement, mais seulement les bits de poids faible. La raison est que cela fait moins de bits à utiliser, donc moins de circuits, mais l’''aliasing'' est juste atténué, pas éliminé. Des branchements différents ont beau avoir des adresses différentes, les bits de poids faible de leurs adresses peuvent être identiques. Des confusions sont donc possibles, mais sont assez rares pour que cela suffise en pratique. Une unité de prédiction de ce type est illustrée ci-dessous. Il y a plusieurs PHT séparées, donc plusieurs ensembles de 2^n compteurs à saturation. Le choix de la PHT est réalisé par l'adresse du branchement, alors que le choix du compteur à saturation est réalisé par l'historique. Les compteurs à saturation d'un branchement sont mis à jour seulement quand le branchement s'est chargé pendant que l'historique associé était dans le registre d'historique. Cette méthode a cependant le défaut de gâcher énormément de transistors. [[File:Unité de prédiction de branchement basée sur des compteurs à saturation sélectionnés par l'historique global.jpg|centre|vignette|upright=2|Unité de prédiction de branchement basée sur des compteurs à saturation sélectionnés par l'historique global]] D'autres unités de prédiction fonctionnent sur le même principe que l'unité précédente, sauf que les différentes PHTs sont fusionnées en une seule grosse PHT. C'est ce que font les unités de prédiction '''''gshare''''' et '''''gselect''''', ainsi que leurs dérivées. Avec les unités de prédiction '''''gselect''''', on concatène les bits de poids faible de l'adresse du branchement et l'historique pour obtenir le numéro du compteur à saturation à utiliser. [[File:Prédiction « gselect ».png|centre|vignette|upright=2|Prédiction « gselect ».]] Avec les unités de prédiction '''''gshare''''', on fait un XOR entre le registre d'historique et les bits de poids faible de l'adresse du branchement. Le résultat de ce XOR donne le numéro du compteur à utiliser. Avec cette technique, on utilise une PHT globale et un historique global, mais le registre d'historique est manipulé de manière à limiter l'''aliasing''. [[File:Prédiction « gshare ».png|centre|vignette|upright=2|Prédiction « gshare ».]] Intuitivement, on pourrait croire que les unités de prédiction gselect ont de meilleures performances. C'est vrai que les unités gshare combinent l'adresse du branchement et l'historique d'une manière qui fait perdre un peu d'information (impossible de retrouver l'adresse du branchement et l'historique à partir du résultat du XOR), alors que les unités gselect conservent ces informations. Mais il faut prendre en compte le fait que les deux unités doivent se comparer à PHT identique, de même taille. Prenons par exemple une PHT de 256 compteurs, adressée par 8 bits. Avec une unité gselect, les 8 bits sont utilisés à la fois pour l'historique et pour les bits de poids faible de l'adresse du branchement. Alors qu'avec une unité gshare, on peut utiliser un historique de 8 bits et les 8 bits de poids faible de l'adresse du branchement. L'augmentation de la taille de l'historique et du nombre de bits utilisés fait que l’''aliasing'' est réduit, et cela compense le fait que l'on fait un XOR au lieu d'une concaténation. Les unités précédentes réduisent l'''aliasing'' en segmentant la PHT : telles entrées sont allouées à un branchement, telles entrées à tel autre branchement, etc. Cependant, l'historique local n'est pas utilisé. Mais quitte à allouer des entrées à un branchement, autant en profiter pour leur associer son historique local. Il s'agit alors d'approches hybrides. Par exemple, on peut citer la '''prédiction mixte''' (''alloyed predictor''). Avec celle-ci, la table des compteurs à saturation est adressée avec la concaténation : de bits de l'adresse du branchement, du registre d'historique global et du registre d'historique local adressé par l'adresse du branchement. [[File:Prédiction mixte.jpg|centre|vignette|upright=2|Prédiction mixte.]] Une version optimisée de ce circuit permet d’accéder à la PHT et à la BHT en parallèle. Avec elle les bits de poids faible de l'adresse du branchement sont concaténés avec l'historique global. Le résultat est ensuite envoyé à plusieurs PHT locales distinctes, en parallèle. Les différences PHT envoient leurs résultats à un multiplexeur, commandé par l'historique local, qui sélectionne le résultat adéquat. L'inconvénient de cette mise en œuvre est que le circuit est assez gros, notamment en raison de la présence de plusieurs PHT séparées. [[File:Alloyed predictor optimisé.jpg|centre|vignette|upright=2|''Alloyed predictor'' optimisé]] ===La mitigation des interférences par la structuration en caches des PHTs=== Les techniques précédentes utilisent les bits de poids faible de l'adresse pour sélectionner la PHT adéquate, ou pour sélectionner le compteur dans la PHT. Mais le reste de l'adresse n'est pas utilisé. Cependant, il est théoriquement possible de conserver les bits de poids fort, afin d'identifier le branchement associé à un compteur. Pour cela, chaque compteur à saturation se voit associé à un ''tag'', qui mémorise le reste de l'adresse du branchement associé au compteur, comme pour les mémoires caches. Lorsque l'unité de prédiction démarre une prédiction, elle récupère les bits de poids fort de l'adresse du branchement et compare ceux-ci avec le ''tag''. S’il y a un ''match'', c'est signe que le compteur est bien associé au branchement à prédire. Dans le cas contraire, c'est signe qu'on fait face à une situation d’''aliasing'' et le compteur n'est pas utilisé pour la prédiction. Le résultat transforme la PHT en une sorte de cache assez particulier, qui associe un compteur à saturation à chaque couple adresse-historique. La technique du paragraphe précédent peut être encore améliorée afin de réduire l’''aliasing''. L’''aliasing'' sur une unité de prédiction de branchement est similaire aux conflits d'accès au cache, où deux données/adresses atterrissent dans la même ligne de cache. Une PHT peut formellement être vue comme un cache directement adressé. Une solution pour limiter ces conflits d'accès à de tels, est de les transformer en un cache associatif à n voies. On peut faire la même chose avec les unités de prédiction de branchement, ce qui revient à dupliquer les PHT, de manière à ce que les bits de poids faible de l'adresse se voient attribuer à plusieurs compteurs à saturation, un par branchement possible. Ce faisant, on réduit les interférences entre branchements. Mais cette technique augmente la quantité de circuits utilisés et la consommation d'énergie, ainsi que le temps d'accès, sans compter qu'elle requiert d'utilisation de ''tags'' pour fonctionner à la perfection. Pour les unités de prédiction de branchement, les mesures à ce sujet ne semblent pas montrer un impact réellement important sur les performances, comparé à une organisation de type directement adressé. Il est possible de pousser la logique encore plus loin en ajoutant des caches de victime et d'autres mécanismes courant sur les caches usuels. ===La mitigation des interférences liées aux branchements biaisés=== L’''aliasing'' a un impact négatif quand deux branchements aliasés (qui sont attribués au même compteur à saturation) vont dans des sens différents : si l'un est non-pris, l'autre a de bonnes chances d'être pris (ou inversement). Or, la plupart des branchements sont biaisés, à savoir très fortement orientés dans une direction qu'une autre (presque toujours pris ou presque toujours non-pris, à plus de 90/99% de chances). Plus un branchement a une probabilité élevée d' être pris ou non-pris, plus sa capacité à interférer avec les autres est forte. Le pire des cas est quand deux branchements sont biaisés mais dans des directions inverses : l'interférence est maximale. En conséquence, les branchements biaisés ou quasi biaisés sont les plus problématiques pour l’''aliasing''. Or, il est possible de concevoir des prédicteurs de branchements qui atténuent fortement les interférences des branchements biaisés ou quasi biaisés. C’est le cas de l’''agree predictor'' et de l'unité de prédiction bimodale, que nous allons voir dans ce qui suit. Les branchements très souvent pris vont interférer très fortement avec les branchements très souvent non-pris, et inversement. Par contre, les branchements très souvent pris n'interféreront pas entre eux, de même que les branchements non-pris iront bien ensemble. D'où l'idée de séparer ces deux types de branchements dans des PHT séparées, pour éviter le mauvais ''aliasing''. L''''unité de prédiction bimodale''' part d'une unité gshare, et sépare la PHT globale en deux : une PHT dédiée aux branchements qui sont très souvent pris et une autre pour les branchements très peu pris. Les deux PHT fournissent chacune une prédiction. La bonne prédiction est choisie par un multiplexeur commandé par une unité de sélection, qui se charge de déduire quelle unité a raison. Cette unité de sélection est une unité de prédiction basée sur des compteurs à saturation de 2 bits. On peut encore améliorer l'unité de sélection de prédiction en n'utilisant non pas des compteurs à saturation, mais une unité de prédiction dynamique à deux niveaux, ou toute autre unité vue auparavant. [[File:Prédiction bimodale.jpg|centre|vignette|upright=2|Prédiction bimodale.]] La '''prédiction par consensus''' (''agree predictor'') combine une unité de prédiction à historique global (idéalement de type gshare ou gselect, mais une unité normale marche aussi) et des compteurs à saturation. Il s'agit techniquement d'une technique de prédiction mixte, à la fois globale et locale. Le BTB intègre un compteur à saturation de un bit, qui indique la direction privilégiée du branchement. L'unité à historique global a un fonctionnement changé : elle calcule non pas la probabilité que le branchement soit pris ou non-pris, mais la probabilité que le résultat du branchement soit compatible avec le résultat de l'unité de prédiction de 1 bit. La prédiction finale vérifie que ces deux circuits sont d'accord, en faisant un NXOR entre les résultats des deux unités de prédiction de branchement. [[File:Prédiction par consensus.png|centre|vignette|upright=2|Prédiction par consensus.]] Des calculs simples de probabilités montrent que l'''agree predictor'' a des résultats assez importants. Ses résultats sont d'autant meilleurs que les branchements sont biaisés et penchent fortement vers le côté pris ou non-pris. ==La prédiction de branchement avec des perceptrons== Les méthodes précédentes utilisent au moins un compteur à saturation par historique possible. Sachant qu'il y a 2^n valeurs possibles pour un historique de n bits, le nombre de compteurs à saturation augmente exponentiellement avec la taille de l'historique. Ce qui limite grandement la taille de l'historique, alors qu'on aurait au contraire besoin d'un historique assez large pour faire des prédictions excellentes. Pour contourner le problème, des alternatives basées sur des techniques d'apprentissage artificiel (''machine learning'') existent. Un exemple est celui des unités de prédiction de branchement dites de ''neural branch prediction'', basée sur des perceptrons. Dans ce qui suit, nous les appelerons des '''unités perceptron'''. Les perceptrons sont un algorithme d'apprentissage automatique très simple, ce qui fait que les explications qui vont suivre devraient être simples. En théorie, il est possible d'utiliser des techniques d'apprentissage automatique plus élaborées, mais cela n'est pas possible en pratique. Rappelons qu'une unité de prédiction de branchement doit idéalement fournir sa prédiction en un cycle d'horloge. L'implémentation d'un perceptron est déjà problématique de ce point de vue, car elle demande d'utiliser beaucoup de circuits arithmétiques complexes (multiplication et addition). Autant dire que les autres techniques de ''machine learning'', encore plus gourmandes en calculs, ne sont pas praticables. ===Les perceptrons et leur usage en tant qu'unité de prédiction de branchements=== L'idée derrière les unités perceptron est que certains bits de l'historique sont plus importants que d'autres. En soi, ce genre de situation est réaliste. Il arrive que certains branchements soient pris uniquement si les deux branchements précédents ne le sont pas, ou que si l'avant-dernier branchement est lui aussi pris. Mais les autres résultats de branchement présents dans l'historique ne sont pas utiles. En conséquence, deux historiques semblables, qui ne sont différents que d'un ou deux bits, peuvent donner des prédictions presque identiques. Dans ce cas, on peut faire la prédiction en n'utilisant que les bits identiques entre ces historiques, ou du moins en leur donnant plus d'importance qu'aux autres. On exploite alors des corrélations avec certains bits de l'historique, plutôt qu'avec l'historique entier lui-même. Pour coder ces corrélations, on associe un coefficient à chaque bit de l'historique, qui indique à quel point ce bit est important pour déterminer le résultat final. Ce coefficient est proportionnel à la corrélation entre le bit de l'historique associé, et le résultat du branchement à prédire. Les coefficients sont des nombres entiers qui peuvent être positifs, nuls ou négatifs. Un coefficient positif signifie que si le branchement associé a été pris, alors le branchement à prédire a de bonnes chances de l'être aussi (et inversement). Un coefficient nul signifie que les deux branchements sont indépendants et que le bit de l'historique associé n'est pas à prendre en compte. Pour un coefficient négatif, cela signifie que si le branchement associé a été pris, alors le branchement à prédire a de bonnes chances d'être non-pris (et inversement). Les coefficients en question sont généralement compris entre -1 et 1. Une fois qu'on connait ces coefficients de corrélations, on peut calculer la probabilité que le branchement à prédire soit pris, avec des calculs arithmétiques simples. Dans les calculs qui vont suivre, un branchement pris est codé par un 1, alors qu'un branchement non-pris est codé par un -1 ! Ce détail permet de simplifier grandement les calculs. Dans sa version la plus simple, le perceptron calcule un nombre Z qui est positif ou nul si le branchement est pris, négatif s'il ne l'est pas. Tous les coefficients <math>p_i</math> sont alors des nombres relatifs, pouvant être positifs, nuls ou négatifs. Ils sont généralement compris entre -1 et 1. La formule devient donc celle-ci : : <math>Z = w_0 + \sum_i (h_i \times w_i)</math> [[File:ArtificialNeuronModel francais.png|centre|vignette|upright=2|Perceptron.]] : Sur le plan mathématique, le perceptron effectue un produit scalaire entre deux vecteurs : l'historique et le vecteur des poids. Choisir les coefficients adéquats est le but de l'algorithme du perceptron. L'unité de prédiction part de poids par défaut, qui sont mis à jour à chaque bonne ou mauvaise prédiction. Toute la magie de cet algorithme tient dans la manière dont sont mis à jour les poids. L'idée est de prendre le vecteur des poids, puis d'ajouter ou soustraire l'historique suivant le résultat du branchement. Les poids évoluent ainsi à chaque branchement, améliorant leurs prédictions d'une exécution à l'autre. La règle de mise à jour des poids est donc la suivante : : <math>w_i \leftarrow w_i + t \times h_i</math>, avec t = 1 pour un branchement pris, -1 pour un branchement non-pris. ===L'implémentation matérielle des perceptrons et ses optimisations=== Les calculs réalisés par un perceptrons sont simples, juste des multiplications et des additions et cela se sent dans la conception du circuit. L'unité perceptron utilise un perceptron par entrée du BTB, un par branchement pour simplifier. Pour cela, on utilise une mémoire SRAM qui mémorise les poids de chaque perceptron. La SRAM est adressée par les bits de poids faible du ''program counter'', de l'adresse du branchement. Une fois les poids récupérés, ils sont envoyés à un circuit de calcul de la prédiction, composé de circuits multiplieurs suivis par un additionneur multi-opérande. Le circuit de mise à jour des poids récupère la prédiction, les poids du perceptron, mais aussi le résultat du branchement. La mise à jour étant une simple addition, le circuit est composé d'additionneurs/soustracteurs, couplés à des registres pour mémoriser la prédiction et les poids du perceptron en attendant que le résultat du branchement soit disponible. Vu qu'il y a un délai de quelques cycles avant que le résultat du branchement soit disponible et que les prédictions doivent continuer pendant ce temps, les poids du perceptron et la prédiction sont stockés dans des FIFOs lues/écrites à chaque cycle. [[File:Unité de prédiction de branchement basée sur des perceptrons.png|centre|vignette|upright=2|Unité de prédiction de branchement basée sur des perceptrons]] Rien de bien compliqué sur le principe, mais rappelons que le perceptron doit fournir un résultat en moins d'un cycle d'horloge, et cela tient compte de l'accès au BTB. C'est un temps très court, surtout pour un circuit qui implique des multiplications. Les multiplications prennent facilement deux à trois cycles d'horloge. Autant dire que si on ajoute le temps d'accès à une petite SRAM, pour récupérer le perceptron, c'est presque impossible. Mais quelques optimisations simples permettent de rendre le calcul plus rapide. Premièrement, la taille de la SRAM est réduite grâce à quelques économies assez simples. Notamment, le perceptron utilise des poids codés sur quelques bits, généralement un octet, parfois 7 ou 5 bits, guère plus. Les poids sont encodés en complément à 1 ou en complément à 2, là où les perceptrons normaux utilisent des nombres flottants. Rien que ces deux choix simplifient les circuits et les rendent plus rapides. Le désavantage d'utiliser peu de bits par poids est une perte mineure en termes de taux de prédiction, qui est plus que compensée par l'économie en portes logiques, qui permet d'augmenter la taille de la SRAM. D'autres optimisations portent sur le circuit de calcul. Par exemple, la multiplication <math>h_i \times w_i</math> est facultative. Vu que l'historique contient les valeurs 1 et -1, il suffit d'additionner le poids associé si la valeur est 1 et de le soustraire s'il vaut -1. On peut même simplifier la soustraction et se limiter à une complémentation à un (une inversion des bits du poids). En faisant cela, les circuits multiplieurs disparaissent et le circuit de prédiction se résume à un simple additionneur multi-opérande couplé à quelques inverseurs commandables. Certains perceptrons prennent en compte à la fois l'historique global et l'historique local pour faire leur prédiction. Les deux historiques sont simplement concaténés et envoyés en entrée du perceptron. Cela marche assez bien, car les perceptrons peuvent utiliser des historiques de grande taille sans problèmes. Par contre, il y a besoin d'ajouter une ''branch history table'' au circuit, ce qui demande des portes logiques en plus, mais aussi un temps de calcul supplémentaire (l'accès à cette table n'est pas gratuit). ===Les avantages et inconvénients des perceptrons pour la prédiction de branchements=== L'avantage des unités perceptron est qu'elles utilisent un nombre de portes logiques qui est proportionnel à la taille de l'historique, et non pas exponentiel. Cela permet d'utiliser un historique de grande taille, et donc d'obtenir un taux de bonnes prédictions très élevé. Leur désavantage est que leurs performances de prédiction sont moins bonnes à historique équivalent. Les unités perceptrons ont des taux de prédiction élevés, mais seulement quand certaines conditions mathématiques sont respectées par les historiques. La condition en question est que les historiques correspondants à un branchement pris et les historiques où ce même branchement est non-pris doivent être linéairement séparables. Heureusement, les branchements des programmes informatiques sont une majorité à respecter la condition de séparation linéaire, ce qui rend les perceptrons parfaitement adaptés à la prédiction de branchements. La notion de linéairement séparable est un peu difficile à comprendre. Pour l'expliquer, imaginez que les N bits de l'historique forme un espace à N-dimensions discrets. Chaque historique est un point dans cet espace N-dimensionnel et chaque bit sert de coordonnée pour se repérer dans cet espace. Une fonction est linéairement séparable si l'espace N-dimensionnel peut être coupé en deux par un hyperplan : d'un côté de ce plan se trouvent les historiques des branchements pris, de l'autre se trouvent les historiques des branchements non-pris. Cet hyperplan est défini par l'ensemble des points qui respecte l'équation suivante : : <math>w_0 + \sum_i (h_i \times w_i) = 0</math> Un exemple où cette condition est respectée est quand le résultat d'une prédiction se calcule avec un ET entre plusieurs branchements de l'historique. Si un branchement est pris quand plusieurs branchements de l’historique sont tous pris ou tous non-pris, la condition est respectée et le perceptron donnera des prédictions correctes. Un exemple où cette condition n'est pas respectée est un banal XOR. Prenons l'exemple d'un historique global de 2 bits. Imaginons que le branchement à prédire soit pris : soit quand le premier branchement de l'historique est pris et le second non-pris, soit quand le premier est non-pris et le second pris. Concrètement, la prédiction exacte se calcule en faisant un XOR entre les deux bits de l'historique. Dans ce cas, la condition "linéairement séparable" n'est pas respectée et le perceptron n'aura pas des performances optimales. Les unités perceptron utilisent idéalement un perceptron par branchement. Cela demande d'associer, l'adresse du branchement pour chaque perceptron, généralement en donnant un perceptron par entrée du BTB. Mais une telle organisation n'est pas possible en pratique, car elle demanderait d'ajouter un ''tag'' à chaque perceptron/entrée du BTB, ce qui demanderait beaucoup de circuits et de ressources matérielles. Dans les faits l'association entre un branchement et un perceptron se fait en utilisant les bits de poids faible de l'adresse de branchement. Mais faire cela entrainera l'apparition de phénomènes d'''aliasing'', comme pour les unités de prédiction à deux niveaux. Les techniques vues précédemment peuvent en théorie être adaptées sur les unités à perceptrons, dans une certaine mesure. Notons que les perceptrons sont des algorithmes ou des circuits qui permettent de classer des données d'entrée en deux types. Ici, la donnée d'entrée est l'historique global et la sortie du perceptron indique si le branchement prédit est pris ou non-pris. Ils ne sont donc pas adaptés à d'autres tâches de prédiction, comme pour le préchargement des lignes de cache ou la prédiction de l'adresse de destination d'un branchement, ou toute autre tâche d'exécution spéculative un tant soit peu complexe. Leur utilisation reste cantonnée à la prédiction de branchement proprement dit, guère plus. Par contre, les autres techniques vues plus haut peuvent être utilisées pour le préchargement des lignes de cache, ou d'autres mécanismes de prédiction plus complexes, que nous aborderons dans la suite du cours. ==La prédiction avec plusieurs unités de branchement distinctes== Il est possible de combiner plusieurs unités de prédiction de branchement différentes et de combiner leurs résultats en une seule prédiction. On peut par exemple combiner une unité de prédiction spécialisée dans les boucles, une unité de prédiction 2 bits basée sur des compteurs à saturation, une unité de prédiction statique et une unité ''gshare''. Tout le problème est de décider quelle unité a fait la bonne prédiction, comment combiner les résultats des différentes unités de prédiction. Soit on choisit un résultat qui parait plus fiable que les autres, soit on combine les résultats pour faire une sorte de moyenne des prédictions. La méthode la plus simple de choisir le résultat d'une unité de prédiction parmi toutes les autres. Pour implémenter le tout, il suffit d'un multiplexeur qui effectue le choix de la prédiction. Reste à commander le multiplexeur, ce qui demande un '''circuit méta-prédicteur''' qui sélectionne la bonne unité de prédiction. Il arrive que le circuit méta-prédicteur soit conçu avec les mêmes techniques que les unités de prédiction de branchement elle-mêmes. Dans le cas le plus simple, il s'agit d'un simple compteur à saturation, mis à jour suivant la concordance des prédictions avec le résultat effectif du branchement. Mais c'est loin d'être systématique. Une autre solution combine le résultat des différentes unités de prédiction avec une fonction mathématique adéquate. Avec un nombre impair d'unités de prédiction, une méthode assez efficace est de simplement prendre le résultat majoritaire. Si une majorité d'unités pense que le branchement est pris, alors on le considère comme pris, et comme non pris dans le cas contraire. Par contre, avec un nombre pair d'unités, cette méthode simple ne marche pas, il y a un risque d'égalité. L'unité de prédiction de branchement « '''''e-gskew''''' » utilise trois unités gshare et combine leurs résultats avec un vote à majorité. Les trois unités gshare utilisent des XOR légèrement différents, dans le sens où les bits de l'adresse du branchement choisi ne sont pas les mêmes dans les trois unités, sans compter que le résultat du XOR peut subir une modification qui dépend de l'unité de prédiction choisie. Autrement dit, la fonction de hachage qui associe une entrée à un branchement dépend de l'unité. [[File:Unité de prédiction « e-gskew ».png|centre|vignette|upright=2|Unité de prédiction « e-gskew ».]] Enfin, une dernière solution est que certaines unités de prédiction ont la priorité sur toutes les autres. Par exemple, prenons un processeur avec un ''return stack predictor'', une unité qui détecte les boucles et une unité de prédiction avec un historique. Si le ''return stack predictor'' détecte une instruction de retour de fonction, sa prédiction passera avant toutes les autres unités de prédiction. Et dans ce qui va suivre, c'est cette solution qui sera utilisé pour les unités de prédiction qu'on va étudier. ===La prédiction des branchements des boucles=== Les boucles sont à part, pour ce qui est de la prédiction de branchement. Rappelons qu'une boucle FOR/WHILE répète une série d'instruction N fois, le nombre N étant appelé le '''compteur de boucle'''. Ce qui fait que les branchements d'une boucle FOR sont pris n − 1 fois, la dernière exécution étant non prise. La conséquence est que les unités de prédiction usuelles sont assez mauvaises pour prédire les branchements des boucles. Par contre, la prédiction statique marche particulièrement bien comparé au reste. La prédiction statique prédit que les branchements des boucles sont toujours pris, ce qui donne des prédictions qui sont d'autant meilleures que la boucle est répétée un grand nombre de fois. Reste à gérer les branchements des boucles au mieux, en sachant cela. L'idée est d'utiliser plusieurs unités de prédiction séparées : une spécialisée dans les boucles et d'autres unités de prédiction. Uen unité de méta-prédiction se charge d'activer l'unité de prédiction des boucles dès qu'une boucle est détéctée. Les unités de prédiction modernes essayent de détecter les branchements des boucles afin de les mettre à part. En soi, ce n'est pas compliqué les branchements des boucles sont presque toujours des branchements descendants et réciproquement. De plus, ces branchements sont pris à chaque répétition de la boucle, et ne sont non-pris que quand on quitte la boucle. Le compteur de boucle analyse l'exécution des branchements, pour détecter les branchements des boucles. Un branchement est considéré comme un branchement de boucle si : il est pris N fois, avant de ne pas être pris une fois. Au moment où le branchement n'est plus pris, le ''Loop Predictor'' lui attribue une entrée dédié. L'entrée mémorise mémorise le nombre de fois que ce branchement a été pris, à savoir N, et est associée à un compteur. Le compteur est incrémenté/décrémenté à chaque exécution du branchement, et sa valeur est comparée à la valeur N. Quand le compteur atteint N+1, il est réinitialisé. Il s'agit d'une implémentation simple, mais est visiblement proche de celle utilisée sur le Pentium M, si j'en crois l'article "The Intel Pentium M Processor : Microarchitecture and Performance". Il faut noter que la boucle doit s'exécuter au moins une fois pour être détectée, et seules les exécutions suivantes sont prédites correctement. La boucle doit donc s'exécuter plusieurs fois, ce qui implique qu'elle est elle-même dans une boucle plus grande qu'elle. Une telle unité est donc capable de prédire spécifiquement les branchements de boucles FOR imbriquées, à savoir où une boucle FOR est dans une autre boucle FOR. Concrètement, cela permet de répéter la première boucle FOR plusieurs fois de suite, souvent un grand nombre de fois. Avec les boucles imbriquées, le compteur de boucle peut changer d'une répétition à l'autre, mais ce n'est que rarement le cas. Dans de nombreux cas, le compteur de boucle reste le même d'une répétition à l'autre. Ce qui fait qu'une fois la boucle exécutée une première fois, on peut prédire à la perfection les exécutions suivantes. Pour cela, il suffit de déterminer le compteur de boucle après la première exécution de la boucle, puis de le mémoriser dans un compteur. Lors des exécutions suivantes de la boucle, on compte le nombre de fois que le branchement de la boucle s'exécute : il est prédit comme pris tant que le compteur est différent du compteur de boucle, mais non-pris en cas d'égalité. ===Le prédicteur L-TAGE=== Il est possible d'utiliser plusieurs unités de prédiction, chacune utilisant des historiques de taille différente. Pour donner un exemple simple, on pourrait avoir une unité avec un historique de 2 bits, une autre avec un historique de 3 bits, une autre de 4 bits, etc. Une méta-unité de branchement prédit alors quelle taille d'historique donne les meilleurs résultats. Il est fréquent que les tailles d'historiques utilisées suivent une formule mathématique précise, appelée suite géométrique, ce qui est un mot bien barbare pour dire que l'on passe d'un historique au suivant en multipliant sa taille par une constante bien précise. Par exemple, on pourrait avoir des historiques de taille 2, 4, 8, 16, 32. C'est d'ailleurs le principe derrière les '''unités de branchement TAGE (''TAgged GEometric length predictor'')'''. Elles utilisent plusieurs registres d'historiques, chacun avec sa propre PHT, dont la taille suit une suite géométrique. Avec cette méthode, les branchements qui ont un motif répétitif sont alors parfaitement détectés, peu importe sa taille. Par exemple, un branchement avec un historique dont le cycle est pris, non-pris, non-pris, sera parfaitement prédit avec un historique de 3 bits, 6 bits, ou de 3*n bits, mais pas forcément avec un historique de 4 ou 8 bits. De ce fait, utiliser plusieurs unités de branchement avec plusieurs tailles d'historique fonctionne plutôt bien. Le prédicteur TAGE a été raffiné avec le temps, sur plusieurs points, que je ne vais pas détailler dans ce qui suit. Précisons cependant que les PHT sont structurées en caches, afin d'éliminer les phénomènes d'''aliasing''. L'ajout d'une unité de prédiction pour les boucles, a donné le '''prédicteur L-TAGE''', qui a lui-même été amélioré par la suite. Les unités de branchement TAGE sont les plus utilisées au moment où j'écris ces lignes (2026), et la majorité des processeurs x86 commerciaux utilisent une variante de ce prédicteur, à quelques exceptions près. Le '''prédicteur ITTAGE''' peut être adapté pour prédire des branchements indirects. La seule différence est que chaque table d'historique est associée non seulement à une prédiction pris/non-pris, mais aussi à une adresse de destination. L'idée derrière l'ITTAGE est que l'adresse de destination dépend de l'historique. L'adresse de destination dépend de ce qui se passe avant qu'on exécute le branchement indirect, donc de l'historique global. Il est donc fréquent que des historiques différents donne un résultat différent, que ce soit pour la prédiction pris/non-pris, mais aussi pour l'adresse de destination. L'ITTAGE associe donc une adresse de destination par ''pattern'' prédit. ==Les optimisations de la prédiction de branchements== La prédiction de branchement est complexe et toute économie est bonne à prendre. Pour améliorer les performances, ou simplement améliorer l'utilisation du BTB et des PHT, diverses techniques ont été inventées. Ces techniques marchent quel que soit l'unité de prédiction prise en compte. Elles peuvent s'appliquer aussi bien aux unités basées sur des perceptrons, que sur des unités à deux niveaux ou des unités de prédiction dynamique en général. Ces techniques sont assez variés : certaines profitent du fait que de nombreux branchements sont biaisés, d'autres tentent de réduire les interférences entre branchements sans utiliser l'historique global/local, etc. ===Le filtrage de branchements=== Une première optimisation permet d'économiser l'usage des différentes ressources matérielles, comme la BTB ou les PHT, en les réservant aux branchements non-biaisés. Idéalement, les branchements biaisés peuvent être prédits en utilisant des techniques très simples. D'où l'idée d'utiliser deux unités de prédiction spécialisées : une pour les branchements biaisés et une autre plus complexe. L'unité de prédiction pour les branchements biaisés est une unité de prédiction à 1 bit qui indique si le branchement est pris ou non, ce qui suffit largement pour de tels branchements. Cette technique s'appelle le '''filtrage de branchement''' et son nom est assez parlant. On filtre les branchements biaisés pour éviter qu'ils utilisent des PHT et d'autres ressources qui gagneraient à être utilisées pour des branchements plus difficiles à prédire. Appliquer cette idée demande de reconnaître les branchements fortement biaisés d'une manière ou d'une autre, ce qui est plus facile à dire qu'à faire. Une solution similaire enregistre quels branchements sont biaisés dans le BTB, et utilise cette information pour faire des prédictions. ===L'usage de fonctions de hashage pour indexer les diverses SRAM/tables=== Plus haut, nous avons vu que les unités de prédiction de branchement contiennent des structures qui sont adressées par l'adresse de branchement. Tel est le cas du ''Branch Target Buffer'', de certaines PHT globales ou locales, de la ''Branch History Table'' qui stocke les historiques locaux, ou encore de la SRAM des poids des unités à base de perceptrons. En pratique, ces structures sont adressées non pas par l'adresse de branchement complète, mais pas les bits de poids faible de cette adresse. Cela a pour conséquence l'apparition d'un ''aliasing'' lié au fait que ces structures vont confondre deux branchements pour lesquels les bits de poids faible de l'adresse sont identiques. Il est possible d'utiliser autre chose que les bits de poids faible de l'adresse du branchement, afin de limiter les interférences. Et les possibilités sont multiples. Les possibilités en question s'inspirent des traitements effectués sur les adresses des banques dans les mémoires évoluées. Une première solution serait de faire un XOR entre les bits de poids faible de l'adresse du branchement, et d'autres bits de cette même adresse. Ainsi, deux branchements éloignés en mémoire donneraient des résultats différents, même si leurs bits de poids faible sont identiques. Une autre possibilité serait de diviser l'adresse du branchement par un nombre et de garder le reste. Ce calcul de modulo n'est en soi pas très différent du fait de conserver seulement les bits de poids faible. Conserver les N bits de poids faible consiste en effet à prendre le modulo par 2^N de l'adresse. Ici, l'idée serait de faire un modulo par un nombre P, qui serait un nombre premier proche de 2^N. Le fait que le nombre soit premier limite les cas où deux adresses différentes donneraient le même reste, ce qui réduit l'''aliasing''. Le fait de prendre un nombre proche de 2^N pour une entrée de N bits est que le résultat reste assez proche de ce qu'on obtiendrait en gardant les bits de poids faible, cette dernière étant une solution pas trop mauvaise. D'autres possibilités similaires se basent sur des réductions polynomiales ou d'autres astuces impliquant des nombres premiers. L'efficacité de ces méthodes dépend grandement de la taille de la SRAM/table considérée, des adresses des branchements et de beaucoup d'autres paramètres. La réduction des interférences par telle ou telle méthode dépend aussi de l'unité de prédiction considérée. Les résultats ne sont pas les mêmes selon que l'on parle d'une unité à perceptrons ou d'une unité à deux niveaux ou d'unités à base de compteurs à saturation, ou que l'on parle du BTB. Par contre, toutes les méthodes ne sont pas équivalentes en termes de temps de calcul ou de portes logiques. Autant la première solution avec un XOR ajoute un temps de calcul négligeable et quelques portes logiques, autant les autres méthodes requièrent l'ajout d'un diviseur très lent et gourmand en portes logiques pour calculer des modulos. Elles ne sont généralement pas praticables pour ces raisons, le temps de calcul serait trop élevé. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Le pipeline | prevText=Le pipeline | next=Les optimisations du chargement des instructions | nextText=Les optimisations du chargement des instructions }} </noinclude> 1jdz64wz4z92kjv168u3zzcjnqzz5hm 774414 774413 2026-10-07T22:37:06Z Mewtow 31375 /* La prédiction de branchement */ 774414 wikitext text/x-wiki Les branchements sont pris en charge par plusieurs étages du pipeline. Exécuter un branchement demande de savoir deux choses : s'il est pris ou non, quelle est l'adresse de destination. L'adresse de destination est localisée grâce au mode d'adressage du branchement. Avec le mode d'adressage direct, elle est extraite de l'instruction par le séquenceur. Avec l'adressage indirect, elle est lue dans un registre. Avec les branchements relatifs, elle est calculée via un additionneur, qui additionne un décalage au ''program counter''. L'additionneur peut être soit dans l'étage de ''PC Update'', soit dans l'unité de calcul de l'étage d'exécution. Passons maintenant au résultat du branchement, à savoir s'il est pris ou non. Pour savoir si le branchement est pris, il faut distinguer les branchements conditionnels et inconditionnels. Pour rappel, les branchements inconditionnels regroupent les appels de fonction, les simples instructions de saut, et quelques autres. Ils sont toujours pris, ils s'exécutent toujours. À partir du moment où le décodeur a décodé un branchement inconditionnel, il sait que le branchement est pris. Pour les branchements conditionnels, ils sont pris seulement si une condition précise est respectée. On sait s'ils sont exécutés une fois que la condition est calculée par l'unité de calcul, ou par une unité de branchement spécialisée. [[File:Gestion des branchements sur un pipeline.png|centre|vignette|upright=2.0|Gestion des branchements conditionnels sur un pipeline.]] Sur le schéma précédent, les branchements inconditionnels ne sont pas montrés. Pour cela, il faudrait rajouter une connexion du décodeur vers le multiplexeur de branchement. De plus, il faudrait aussi rajouter de quoi retarder l'exécution du branchement seulement une fois que l'adresse est connue. Par exemple, elle est connue un cycle d'horloge plus si l'adresse est lue depuis les registres, comparé à un branchement direct. ==Les branchements et les exceptions imprécises/précises== Un branchement est censé altérer le ''program counter'' immédiatement, en un cycle d'horloge. Mais sur un pipeline, Le branchement fait bien reprendre le processeur au bon endroit, mais avec du retard. La raison est qu'un branchement met plusieurs cycles pour s'exécuter. Il faut plusieurs cycles pour déterminer quelle est l'adresse de destination, temps qui dépend de là où est lue l'adresse. Et on sait si le branchement est pris seulement au niveau du décodeur, voire au niveau de l'unité de calcul. Le branchement doit traverser plusieurs étages avant qu'on connaisse son résultat. Il y a bien 3 à 4 cycles avant cela. Et pendant ce temps de trajet, des instructions seront chargées dans le pipeline, potentiellement à tort. Si le branchement n'est pas pris, ce n'est pas grave : les instructions n'ont pas été chargées à tort. Mais dans le cas contraire, les instructions n'auraient pas dû rentrer dans le pipeline. Le problème est similaire à celui observé avec les exceptions précises/imprécises. La différence est que les branchements sont des instructions machines, pas les exceptions matérielles. Mais le problème des instructions chargées à tort est le même dans les grandes lignes. Une différence est que les branchements sont résolus plus ou moins au même étage, alors que les exceptions peuvent survenir dans tout le pipeline. Une autre différence, bien plus importante, est que les branchements non-pris ne chargent pas d'instructions à tort et qu'il faut faire la différence entre branchement pris et non-pris. Dans la suite, nous parlerons d'instructions fautives pour parler des instructions chargées à tort. Pour corriger ce problème, il existe une solution logicielle. Le compilateur fait suivre les branchements par des instructions qui ne font rien, des NOP (No OPeration) : c'est ce qu'on appelle un '''délai de branchement'''. L'idée est que toutes les instructions chargées à tort sont des NOPs, qui ne font rien. Mais le nombre de NOP à ajouter dépend du pipeline du processeur, ce qui peut poser des problèmes de compatibilité. Mais d'autres solutions, matérielles cette fois, sont utilisées de nos jours. ===L'annulation des instructions au décodage=== Les branchements inconditionnels sont dans un cas plus favorable : ils sont identifiés dans l'unité de décodage et sont toujours pris. D'autres branchements sont dans ce cas : les appels de fonctions, les interruptions logicielles. Avec eux, le décodeur annule les instructions fautives, il les remplace par des instructions NOP. L'étage de décodage sait que les instructions suivant un branchement inconditionnel ont été chargées à tort. Précisément N instructions, avec N le nombre d'étages entre le chargement et le décodage (inclus). Les N instructions suivantes sont tout simplement annulés, elles sont remplacées par des NOPs. L'implémentation demande juste un simple compteur et quelques circuits annexes. Cependant, la technique ne marche pas du tout pour les branchements conditionnels. En effet, on ne sait pas s'ils sont pris ou non à l'étage de décodage. Du moins pas avec un pipeline normal. Mais certains processeurs, basés sur le pipeline RISC classique, calculaient les branchements pendant l'étape de décodage, dans une ALU spécialisée qui travaille en parallèle de l'unité de décodage. Le défaut de cette technique est que l'ALU en question doit lire les registres. Pour rappel, sur ce pipeline, l'étape de décodage effectue la lecture des opérandes dans les registres, en parallèle de l'unité de décodage. Mais ce qu'on n'a pas dit dans le chapitre précédent, c'est qu'elle effectue aussi le calcul des branchements dans une mini-ALU séparée ! Ce faisant, le résultat du branchement est connu dès la fin de l'étape de décodage. ===L'usage du système d'exceptions précises pour les branchements conditionnels=== La solution gère le problème en empêchant les instructions chargées à tort d'enregistrer leurs résultats, afin qu'elles n'aient pas le moindre effet sur l'état du processeur. Le hardware pour les exceptions précises, décrit quelques paragraphes avant, est réutilisé pour cela. Les branchements conditionnels peuvent être pris ou non-pris et on doit tenir compte de ces deux cas. Si le branchement n'est pas pris, les instructions n'ont pas été chargées à tort, elles peuvent enregistrer leur résultat dans les registres. Par contre, ce n'est pas le cas pour les branchements pris, les mécanismes d'exceptions précise doivent alors d'activer. Pour cela, la meilleure solution est que l'unité de calcul génère un indicateur d'exception si le branchement n'est pas pris. L'ALU effectue la comparaison et génère ou non l'indicateur d'exception adéquat suivant le résultat de celle-ci. L'idée est d'utiliser un indicateur d'exception particulier, qui précise qu'un branchement a été pris, signe que des instructions ont été chargées à tort. L'indicateur est propagé dans le pipeline, jusqu'à atteindre l'étage d'enregistrement dans les registres. Cet étage décide s'il faut autoriser ou non les écritures qu'il recoit, pour annuler les instructions chargées à tort. Le ''program counter'' est corrigé immédiatement, simplement en exécutant le branchement. Reste à savoir qui génère cet indicateur d'exception de branchement pris. Diverses optimisations visent à éliminer les instructions chargées à tort, à faire en sorte que des instructions utiles soient chargées. Le chapitre suivant va détailler ces optimisations dans le détail, qui sont regroupées sous le terme de prédiction de branchement. Elles ne fonctionnent pas pour les exceptions précises, seulement les branchements. ===La prédiction de branchement=== Les techniques précédentes ont pour défaut que de nombreux cycles d'horloge sont gâchés à cause des instructions fautives. Pour éviter ce gâchis, les concepteurs de processeurs ont inventé l'''exécution spéculative de branchement'', qui consiste à deviner l'adresse de destination du branchement et charger les bonnes instructions dès le départ. Le but est de ne pas charger d'instruction à tort, en prédisant quelles seront les bonnes instructions à charger. Et cela demande de résoudre trois problèmes : * reconnaître les branchements ; * savoir si un branchement sera exécuté ou non : c'est la '''prédiction de branchement''' ; * dans le cas où un branchement serait exécuté, il faut aussi savoir quelle est l'adresse de destination : c'est la '''prédiction de l'adresse de destination''' d'un branchement, aussi appelée ''branch target prediction''. Pour résoudre le second problème, le processeur contient un circuit qui déterminer si le branchement est pris (on doit brancher vers l'adresse de destination) ou non pris (on poursuit l’exécution du programme immédiatement après le branchement) : c'est l''''unité de prédiction de branchement'''. La prédiction de direction de branchement est elle aussi déléguée à un circuit spécialisé : l’'''unité de prédiction de direction de branchement'''. C'est l'unité de prédiction de branchement qui autorise l'unité de prédiction de destination de branchement à modifier le ''program counter''. Dans certains processeurs, les deux unités sont regroupées dans le même circuit. [[File:Relation entre prédiction de branchement et de direction de branchement.png|centre|vignette|upright=2|Relation entre prédiction de branchement et de direction de branchement.]] : Au lieu de parler d'unité de prédiction de branchement, j'utiliserais parfois le terme '''prédicteur''', sous-entendu ''prédicteur de branchement''. Avant de poursuivre, faisons une remarque très importante. J'ai parlé d'unité de prédiction de branchement, comme s'il n'y en avait qu'une seule. Mais en réalité, n'en avoir qu'une seule est l'exception. La règle est d'avoir plusieurs unités de branchements spécialisées. Un processeur moderne utilise plusieurs prédicteurs séparés, chacun prédisant un type de branchement bien précis. Il y a pas exemple un prédicteur spécialisé pour les boucles, un autre pour les instructions de retour de fonctions, un autre pour les branchements directs, un autre pour les branchements indirects. Les processeurs modernes utilisent une prédiction de branchement à deux niveaux. Un premier prédicteur fournit des résultats rapides et peu précis, un second a des résultats plus précis après quelques cycles d'horloge. Le prédicteur rapide envoit l'adresse prédite au cache d'instruction, pour charger les instructions prédites et mettre à jour le ''program counter''. Le prédicteur lent envoient leurs résultats au décodeur d'instruction, histoire d'annuler des branchements considérés à tort comme pris/non-pris. Il y a donc des prédicteurs rapides, dont le résultat est corrigé par des prédicteurs plus lents. La seconde unité a le dernier mot, elle corrige ou annule les prédictions de la première. Ce qui lui vaut le nom, en anglais, d'''overiding predictor''. La correction se fait simplement en corrigeant le ''program counter'' et en forçant le décodeur à émettre des NOPs pendant quelques cycles, le temps d'éliminer les instructions chargées à tort. Ce détail aura rapidement son importance. ==Les erreurs de prédiction de branchement== Le processeur peut parfaitement se tromper en faisant ses prédictions, ce qui charge des instructions par erreur dans le pipeline. Dans ce cas, on parle d''''erreur de prédiction'''. Les erreurs de prédiction sont corrigées par le système d'exception précises, ou un équivalent. En clair, les instructions chargées à tort se propagent jusqu'à la sortie du pipeline, où leurs résultats sont alors annulés. N'importe quelle technique de gestion des exceptions précise fait le travail, mais les processeurs modernes utilisent un tampon de réordonnancement (''reorder buffer'') qu'on détaillera dans quelques chapitres. Détecter les erreurs de prédiction demande d'ajouter un circuit dans le processeur : l’''unité de vérification de branchement'' (''branch verification unit''). Elle génère un indicateur d'exception, qui est géré par le système d'exception précise. Pour générer l'indicateur, elle compare l'adresse de destination prédite et celle calculée en exécutant le branchement. Pour cela, l'adresse prédite doit être propagée dans le pipeline jusqu’à ce que l'adresse de destination du branchement soit calculée. [[File:Unité de vérification de branchement.png|centre|vignette|upright=2|Unité de vérification de branchement]] ===La pénalité de mauvaise prédiction=== Le temps pour vider le pipeline des instructions fautives dépend du pipeline. La dernière instruction à être chargée dans le pipeline le sera en même temps qu'on détecte l'erreur de prédiction. Il faudra attendre que cette instruction quitte le pipeline, et donc qu'elle traverse tous les étages. Il y a donc un temps d'attente pour collecter les résultats de ces instructions et la décision de ne pas enregistrer leurs résultats dans les registres Le nombre d'instructions chargées à tort dépend de où est détectée l'erreur de prédiction. Plus le résultat du branchement est connu tard, plus le nombre d'instructions chargées à tort sera grand, et plus le nombre de cycles d'horloge gâchés le sera aussi. Le nombre de cycles gâchés s'appelle la '''pénalité de mauvaise prédiction'''. Elle correspond précisément au nombre de cycles/étages entre le premier étage du pipeline, et l'étage où le branchement est résolu (on connait l'adresse à laquelle brancher). Plus celui-ci est éloigné du début du pipeline, plus la pénalité est importante. [[File:MIT 6.823 Computer System Architecture, Fall 2005 - P-3 vs. P-4 Pipelines - 4473425702.jpg|centre|vignette|upright=2.5|Comparaison de la pénalité de mauvaise prédiction entre le Pentium 3 et le Pentium 4 d'Intel.]] Elle est généralement d'autant plus longue que le pipeline lui-même est long. Les concepteurs de processeurs font donc face à un compromis : un pipeline long permet d'exécuter plus d'instructions en parallèle, mais cela augmente aussi la pénalité de mauvaise prédiction. Un pipeline de 10-15 étapes est un bon compromis. Les pipelines plus longs ont besoin d'une prédiction de branchement élaborée pour fonctionner correctement. Plus la prédiction de branchement est performante, plus le pipeline peut être long. Les pénalités de mauvaise prédiction seront plus importantes, mais les mauvaises prédictions seront plus rares, ce qui compense complétement. ===La ''minimal control dependency''=== En utilisant une technique du nom de ''minimal control dependency'', seules les instructions qui dépendent du résultat du branchement sont supprimées du pipeline en cas de mauvaise prédiction, les autres n'étant pas annulées. Sans ''minimal control dependency'', toutes les instructions qui suivent un branchement dans notre pipeline sont annulées. Pourtant, certaines d'entre elles pourraient être utiles. Prenons un exemple : supposons que l'on dispose d'un processeur de 31 étages (un Pentium 4 par exemple). Supposons que l'adresse du branchement est connue au 9éme étage. On fait face à un branchement qui envoie le processeur seulement 6 instructions plus loin. Si toutes les instructions qui suivent le branchement sont supprimées, les instructions en rouge sont celles qui sont chargées incorrectement. On remarque pourtant que certaines instructions chargées sont potentiellement correctes : celles qui suivent le point d'arrivée du branchement. Elles ne le sont pas forcément : il se peut qu'elles aient des dépendances avec les instructions supprimées. Mais si elles n'en ont pas, alors ces instructions auraient dûes être exécutées. Il serait donc plus efficace de les laisser enregistrer leurs résultats au lieu de les ré-exécuter à l’identique un peu plus tard. Ce genre de choses est possible sur les processeurs qui implémentent une technique du nom de ''Minimal Control Dependancy''. En gros, cette technique fait en sorte que seules les instructions qui dépendent du résultat du branchement soient supprimées du pipeline en cas de mauvaise prédiction. [[File:Minimal cntrol dependency.png|centre|vignette|upright=2|Minimal control dependency]] ==La reconnaissance des branchements== Pour prédire des branchements, le processeur doit faire la différence entre branchements et autres instructions, dès l'étage de chargement. Or, un processeur « normal », sans prédiction de branchement, ne peut faire cette différence qu'à l'étage de décodage, et pas avant. Les chercheurs ont donc dû trouver une solution. La première solution se base sur les techniques de prédécodage vues dans le chapitre sur le cache. Pour rappel, ce prédécodage consiste à décoder partiellement les instructions lors de leur chargement dans le cache d'instructions et à mémoriser des informations utiles dans la ligne de cache. Dans le cas des branchements, les circuits de prédécodage peuvent identifier les branchements et mémoriser cette information dans la ligne de cache. Une autre solution mémorise les branchements déjà rencontrés, dans un cache dédié. Ce cache mémorise l'adresse (le ''program counter'') des branchements déjà rencontrés : il s'appelle le '''tampon d’adresse de branchement''' (''branch adress buffer''). Il est intégré à l'unité de prédiction de branchement, mais est alimenté par le décodeur d'instruction. Reconnaitre un branchement demande l'implication du décodeur d'instruction, qui sait si l'instruction qu'il vient de décoder est un branchement ou non. Il peut même savoir si le branchement est conditionnel, inconditionnel, un appel de retour de fonction, etc. Lorsque le décodeur d'instruction reconnait un branchement, il ajoute ce branchement dans le cache Le tapon d'adresse de branchement contient une adresse mémoire par branchement, et éventuellement d'autres informations. Et pour comprendre quelle adresse est mémorisée, il faut faire attention à un détail. Les explications qui vont suivre vont faire intervenir deux adresses. * La première est l''''adresse de destination''' du branchement, à savoir l'adresse à laquelle le processeur doit reprendre son exécution si le branchement est pris. * L'autre adresse est l''''adresse du branchement''' lui-même. Elle correspond à sa position en mémoire RAM. L'adresse du branchement est la valeur du ''program counter'' quand le branchement est chargé dans le cache, alors que l'adresse de destination est celle du ''program Counter'' une fois le branchement exécuté. Il faudra faire bien attention à ne pas confondre les deux adresses dans ce qui suit. L'adresse du branchement est celle qui est utilisée pour la reconnaissance du branchement. Le tampon d'adresse de branchement mémorise, pour chaque branchement, son adresse de branchement (d'où son nom). À chaque cycle d'horloge, l'unité de chargement envoie le ''program counter'' en entrée du tampon d’adresse de branchement. Un succès dans ce cache indique qu'un branchement déjà rencontré est ré-executé, on peut utiliser la prédiction de branchement. Dans le cas contraire, la prédiction de branchement n'est pas utilisée, et l'instruction est chargée normalement. ==La prédiction de l'adresse de destination== La '''prédiction de l'adresse de destination d'un branchement''' détermine l'adresse de destination d'un branchement. Rappelons qu'il existe plusieurs modes d'adressage pour les branchements et que chacun d'entre eux précise l'adresse de destination à sa manière. Suivant le mode d'adressage, l'adresse de destination est soit dans l'instruction elle-même (adressage direct), soit calculée à l’exécution en additionnant un ''offset'' au ''program counter'' (adressage relatif), soit dans un registre du processeur (branchement indirect), soit précisée de manière implicite (retour de fonction, adresse au sommet de la pile). : Histoire de simplifier la terminologie, je parlerais parfois de '''prédiction de destination''' pour parler de la ''prédiction de l'adresse de destination d'un branchement''. Pour rappel, les branchements se classent en trois types : * les branchements directs pour lequel l'adresse de destination est toujours la même ; * les branchements indirects où l'adresse de destination est dans un registre et peut changer ; * les instructions de retour de fonction. Les branchements directs sont facilement prévisibles, vu que l'adresse vers laquelle il faut brancher est toujours la même. La prédiction des branchements directs et relatifs se fait globalement de la même manière, ce qui fait que nous ferons la confusion dans ce qui suit. Pour les instructions de retour de fonction, une prédiction parfaite est possible. Et c'est ce qui explique qu'elles sont traitées à part, par une unité de branchement dédiée. Pour les branchements indirects, vu que cette adresse change, la prédire celle-ci est particulièrement compliqué (quand c'est possible). Les branchements indirects sont eux aussi traités par une unité de prédiction dédiée rien que pour eux. ===La prédiction de destination pour les branchements directs=== Lorsqu'un branchement est exécuté, on mémoriser l'adresse de destination et la réutiliser lors d'exécutions ultérieures du branchement. Cette technique marche à la perfection pour les branchements directs, pour lesquels cette adresse est toujours la même, mais donne des résultats mitigés pour les branchements indirects. Pour mémoriser l'adresse de destination, on utilise un cache qui mémorise les correspondances entre l'adresse du branchement et l'adresse de destination. Ce cache est appelé le '''''branch target buffer''''' en anglais, qui sera abrévié en BTB dans ce qui suit. Le BTB est une amélioration du tampon d'adresse de branchement vu plus haut, auquel on aurait ajouté les adresses de destination des branchements. Le BTB est utilisé comme suit : on envoie en entrée l'adresse du branchement, le BTB répond au cycle suivant en précisant : s’il reconnaît l'adresse d'entrée, et quelle est l'adresse de destination si c'est le cas. Le BTB peut être un cache totalement associatif, associatif par voie, ou directement adressé, mais ces derniers sont les plus fréquents. Les BTB sont généralement des caches de type directement adressés, où seuls les bits de poids faible de l'adresse du branchement adressent le cache, le reste de l'adresse est tout simplement ignoré. Il existe aussi des BTB qui sont construits comme les caches associatifs à plusieurs voies, mais ceux-ci impliquent généralement la présence de ''tags'', ce qui fait qu'ils sont un peu moins fréquents. [[File:Branch target buffer directement adressé.png|centre|vignette|upright=2|Branch target buffer directement adressé.]] Comme pour tous les caches, un accès au BTB peut entraîner un défaut de cache, c’est-à-dire qu'un branchement censé être dans le BTB n'y est pas. Comme pour les autres caches, les défauts de cache peuvent se classer en trois types : les défauts de cache à froid (''cold miss'') causés par la première exécution d'un branchement, les défauts liés à la capacité du BTB/cache, et les défauts par conflit d'accès au cache. Les défauts liés à la capacité du BTB sont les plus simples à comprendre. La capacité limitée du BTB fait que d'anciens branchements sont éliminées pour laisser la place à de nouveaux. En conséquence, ces branchements peuvent donner des erreurs de prédiction si leur correspondance a été éliminée du cache. On peut réduire des défauts en augmentant la taille du BTB. Les défauts liés aux conflits d'accès au BTB sont eux beaucoup plus intéressants, car la conception du BTB est assez spéciale dans la manière dont sont gérés ce genre de défauts. Le BTB est un cache partiellement associatif, ce qui fait que des défauts de cache par conflit d'accès peuvent avoir lieu. Pour rappel, les défauts par conflit d'accès ont lieu quand deux adresses mémoires se voient attribuer la même ligne de cache. Pour un BTB, cela correspond au cas où deux branchements se voient attribuer la même entrée dans le BTB, ce qui porte le nom d’''aliasing''. Ne pas détecter ce genre de situation sur un cache normal entraînerait des problèmes : la donnée lue/écrite ne serait pas forcément la bonne. Les caches normaux utilisent un système de tags pour éviter ce problème et on s'attendrait à ce que les BTB fassent de même. Mais pour un BTB, l'absence de ''tags'' n'est pas un problème : la seule conséquence est une augmentation du taux de mauvaises prédictions, dont les conséquences en termes de performance peuvent être facilement compensées. Les BTB peuvent donc se passer de tags, ce qui a de nombreux avantages : le circuit est plus rapide, prend moins de portes logiques, consomme moins d'énergie, etc. Ces économies de portes logiques et de performances peuvent être utilisées pour augmenter la taille du BTB, ce qui compense, voire surcompense les mauvaises prédictions induites par l’''aliasing''. Mais ce n'est pas systématique : d'autres BTBs utilisent des ''tags'' pour améliorer le taux de prédiction. Nous ferons donc la différence entre '''BTB tagués''' et '''BTB non-tagués'''. Des processeurs se passent de BTB. À la place, ils mémorisent les correspondances entre branchement et adresse de destination dans les bits de contrôle du cache d'instructions. Mais nous verrons cela en détail dans le chapitre suivant. Plus haut, j'ai dit qu'un processeur moderne utilisait à la fois un prédicteurs rapide et imprécis, avec un prédicteur lent mais précis. Le prédicteur lent a le dernier mot, il corrige ou annule les prédictions du prédicteur rapide. Ce qui lui vaut le nom, en anglais, d'''overiding predictor''. La correction se fait simplement en corrigeant le ''program counter'' et en forçant le décodeur à émettre des NOPs pendant quelques cycles, le temps d'éliminer les instructions chargées à tort. [[File:Double BTB sur les CPU modernes.png|centre|vignette|upright=2|Double BTB sur les CPU modernes.]] Les deux unités de prédiction utilisent des BTB séparés, de taille différente, au moins deux. Vu que les BTB sont des caches, on les appelle des '''BTB de niveau L1 et L2'''. Par exemple, les processeurs Bulldozer d'AMD disposent de deux BTBs : un BTB L1 de 512 entrées, et un BTB L2 de 5120 entrées. Le BTB de niveau L2 est souvent un cache de victime, qui mémorise les branchements retirés de la BTB de niveau L1. La BTB pour les branchements indirects vient en plus de ces deux BTBs, elle complète souvent la BTB de niveau L2. Le ''return stack predictor'' vient en plus des BTB L1 et L2, mais sa petite taille et sa rapidité font qu'il complète l'unité/BTB de niveau L1. Quelques processeurs ont trois niveaux de BTB, avec un niveau L0, L1 et L2. La BTB L0 aliment le cache d'instruction, alors que la L1 et la L2 préviennent les décodeurs d'instruction. La différence entre la L1 et la L2 est que la L2 met plus de temps à prévenir les décodeurs. Les branchements peuvent donc être annulés/forcés à deux étages de décodage différents. Pour donner quelques exemples, les processeurs Zen d'AMD disposent de plusieurs BTBs, avec des niveaux L0, L1 et L2. Le tableau ci-dessous vous donne la taille et la latence de chaque niveau de BTB. La BTB de niveau L0 alimente le cache d'instruction, la L1 permet d'annuler les instructions au premier cycle de décodage, la BTB L2 annule les instructions vers le milieu/fin du décodage. {|class="wikitable" |- ! rowspan="3" | Zen 2 | 16 || 0 |- | 512 || 1 |- | 7168 || 4 |- ! rowspan="3" | Zen 3 | 16 || 0 |- | 1024 || 1 |- | 6656 + 1536 (branchements indirects)|| 4 |} ===La prédiction de destination pour les branchements indirects=== Un BTB classique peut fonctionner pour les branchements indirects. Son seul défaut est qu'il mémorisera la dernière adresse de destination prise par le branchement. Et cela peut fonctionner pour les branchements indirects fortement biaisés, qui ont presque toujours la même adresse de destination. Mais la majorité des branchements indirects change d'adresse de destination régulièrement. Tous les processeurs commerciaux datant d'avant le Pentium M utilisaient un BTB normal pour prédire les branchements indirects. Les processeurs qui les ont suivi sont capables de prédire l'adresse de destination d'un branchement indirect. , grâce à une '''unité de prédiction des branchements indirects''', séparée de l'unité de prédiction de branchement normale. Elle utilise un BTB amélioré, qui mémorise plusieurs adresses de destination pour un seul branchement, en compagnie d'informations pour prédire laquelle est la bonne. Il est possible d'utiliser un BTB unique, pour tous types de branchements, appelé un '''BTB unifié'''. Un BTB unifié a plusieurs adresse de destination par entrée du BTB. Mais une seule est utilisée pour les branchements directs. Vu que les branchements directs sont majoritaires, cela fait beaucoup d'entrées inutilisées. En conséquence, elle est tout sauf optimale, et n'est presque jamais utilisée en pratique. {|class="wikitable" |+ Branch Target Buffer unifié |- ! Adresse du branchement !! Informations de prédiction !! Adresse de destination !! Seconde adresse de destination |- | 0x54462146 || 11101011 || 0x52495674 || 0x24987461 |- | 0x54142446 || 11101101 || 0x15646956 || x0x4158746 |- | 0x25456542 || 10011101 || 0x52495674 || 0x24157461 |- | 0x15455855 || 11010101 || 0x52485754 || 0x24024511 |- | ... || ... || ... || ... |- | ... || ... || ... || ... |- | 0x54462146 || 1001101 || 0x52495674 || 0x24987461 |} Une seconde solution utilise un BTB séparé, distinct du BTB pour les branchements directs. En séparant les deux, on a un BTB simple qui ne mémorise qu'une adresse de destination par branchement, et un BTB pour branchements indirects avec plusieurs adresses de destination par branchement. [[File:BTB avec prédiction de branchements indirects.png|centre|vignette|upright=2.5|BTB avec prédiction de branchements indirects.]] L'idée est que l'on économise des circuits à nombre d'entrées égales. Par exemple, comparons ce que cela donne avec 64 entrées. D'un côté, on a un BTB à 64 entrées, capable de gérer deux adresse de destination par branchement. En pratique, la majorité des branchements sont des branchements directs, qui n'utiliseraient qu'une seule case mémoire. Les circuits du BTB seraient donc sous-utilisés. De l'autre, on a un BTB à 48 entrées pour branchements directs, et un second BTB avec 16 entrées, capable de mémoriser deux adresses par branchements. Le premier BTB utilise moins de circuits, et est utilisé à pleine capacité, il ne gaspille pas de circuits pour mémoriser des adresses de destination vides. Par contre, on perd un peu en flexibilité. L'organisation précédente marche bien si 1/4 des branchements rencontrés sont des branchements indirects. Mais si le ratio est différent, la performance est légèrement sous-optimale. Attention, cela ne veut pas dire qu'il y aura des entrées vides dans un BTB, non. Simplement, le premier BTB risque de manquer de place pour les branchements directs récents, alors que les branchements indirects seront eux bien prédits, même les anciens. À l'opposé, un BTB unique permet de mémoriser les 64 derniers branchements, peu importe qu'ils soient indirects ou non. En clair : un BTB unique est plus flexible, ce qui donne un gain de performance dans certaines situations. Cependant, cet avantage théorique ne tient pas compte de l'économie en circuits, qui peut être utilisée pour faire grossir le BTB pour branchements directs. Par exemple, à budget à transistor équivalent, on a le choix entre BTB à 64 entrées uniques, et un BTB pour branchements directs de 80 entrées avec un BTB indirect de 16 entrées. Le fait qu'un BTB soit plus gros permet des gains de performances qui compensent la perte de flexibilité. On peut ainsi avoir le meilleur des deux monde, à savoir une économie de circuits et les gains en performance qui en découlent, malgré une petite perte de flexibilité. Une troisième solution utilise un BTB normal, mais réserve plusieurs entrées pour un branchement indirect. Par exemple, un branchement indirect qui a trois adresses de destination prendra trois entrées : une par adresse de destination. Mais quelques difficultés surviennent alors. Comment placer trois copies d'un même branchement, alors que ces trois copies ont le même ''program counter'' ? Et que faire pour savoir quelle copie sera prise ? Que faire si l'unité de prédiction de branchement prédit les trois copies comme prises ? La technique du '''''Program Counter'' virtuel''' (''Virtual Program Counter'') résoud ces problèmes d'une manière assez élégante. L'idée est la même que précédemment, à savoir qu'il y a une copie du branchement par adresse de destination, dans le BTB. Mais ces copies n'ont pas exactement le même ''Program counter''. Chaque copie se voit attribuer un ''Program Counter'' virtuel. Et comprendre pourquoi demande de comprendre l'idée sous-jacente. La technique a été inspirée par une optimisation des compilateurs, qui transforme un branchement indirects en une séquence de branchements conditionnels. Par exemple, un branchement indirects qui peut brancher vers les adresses A, B et C est transformé en trois branchements conditionnels : un qui branche vers A, un autre qui branche vers B et un autre vers C. Elle ne marche pas pour tous les branchements indirects, elle demande des circonstances particulières pour fonctionner. Mais l'idée est adaptée pour le ''design'' de l'unité de prédiction de branchement. L'idée est qu'un branchement indirect est déroulé en plusieurs branchements dans le BTB. Les branchements sont traités en séquence : l'unité de prédiction prédit si le premier branchement est pris ou non. S'il est pris, son adresse de destination est envoyée au cache d'instruction. S'il n'est pas pris, l'unité de prédiction tester la seconde copie du branchement, et rebelote. Il teste toutes les copies, une par une, et s’arrête quand il tombe sur un branchement pris. Si aucune copie n'est prise, alors le branchement est prédit comme non-pris. Première remarque : il s'agit d'un processus itératif, où on test un branchement à la fois. Et oui, c'est une prédiction par cycle d'horloge. Prédire un branchement indirect peut donc demander plusieurs cycles d'horloge. Mais ce n'est pas un problème, car le prédicteur peut servir d'''overriding predictor'', d'unité de prédiction lente mais précise. La prédiction peut être utilisée par l'unité de décodage, pour annuler les instructions chargées à tord. Deuxième remarque : comment passer d'un branchement à l'autre ? La première copie a un ''program counter'' normal, qui correspond à l'adresse du branchement. La seconde a un ''program counter'' qui est calculé à partir de cette première adresse, la troisième copie a une adresse calculée à partir de la seconde, etc. Concrètement, n'importe quelle fonction un tant soit peu simple suffit, tant qu'elle est déterministe. L'article qui a proposé cette techniques (''Virtual Program Counter (VPC) Prediction : Very Low Cost Indirect Branch Prediction Using Conditional Branch Prediction Hardware'') se contenait de faire un XOR entre l'adresse précédente et une constante, avec une constante différente par itération. Troisième remarque : l'algorithme demande de faire une parenthèse dans le fonctionnement normal du BTB. Quand un branchement indirect est détecté, il lance une succession de prédiction pour prédire un branchement. L'incrémentation du ''program counter'' normale cesse pendant ce processus. Et cela ne doit être fait que quand un branchement indirect est détecté. Pour cela, le BTB mémorise le type de chaque branchement, sans l'entrée du BTBT : est-ce qu'il est conditionnel, inconditionnel, mais aussi direct ou indirect. Et le type est accesible en sortie du BTB, sur une sortie de quelques bit. Quand l'unité de prédiction remarque qu'un branchement indirect vient de sortir du BTB, alors elle lance le processus. [[File:Virtual program counter.png|centre|vignette|upright=2.5|Virtual program counter]] L'avantage de cette technique est qu'elle s’implémente facilement, son cout en transistors est faible. Elle demande juste d'ajouter quelques circuits autour d'un BTB classique. Elle utilise l'unité de prédiction classique, pas besoin d'une unité de prédiction séparée. Vu qu'il y a un seul BTB, on n'a pas les désavantages d'un BTB séparé pour les branchements indirects. Les entrées du BTB sont réparties entre branchements directs et indirects de manière optimale. Niveau performance, elle est un peu moins bonne que les unités de prédiction ITTAGE qu'on détaillera dans la suite du chapitre. Mais elle est suffisante pour être utilisée sur certains processeurs commerciaux. ===La prédiction de l'adresse de retour d'une fonction=== Presque tous les processeurs conçus après les années 2000 peuvent prévoir l'adresse à laquelle il faudra reprendre lorsqu'une fonction/sous-programme a fini de s’exécuter. Ils font de la '''prédiction de retour de fonction'''. Pour cela, ils possèdent pour une unité de prédiction de branchement spécialisée, appelée le ''return stack predictor'', ou '''''pile de prédiction de retour de fonction'''''. Le terme ''return stack predictor'' étant plus court, nous l'utiliserons dans la suite du cours. Le ''return stack predictor'' est capable de reconnaitre les instructions de retour de fonction, et de prédire l'adresse de retour. Pour comprendre comment elle fait, rappelez-vous le chapitre sur la pile d'appel. Dans ce chapitre, j'avais introduit le concept de ''pile d'adresse de retour'', à savoir une pile d'appel très simplifiée, qui ne contient que les adresses de retour des fonctions en cours d'exécution. Le ''return stack predictor'' est essentiellement composé d'une telle pile d'adresse de retour, avec des circuits pour empiler et dépiler les adresses de retour. Le ''return stack predictor'' doit reconstruire une pile d'adresses de retour, à partir du programme exécuté. La pile d'adresse est mémorisée dans le ''return stack predictor'', elle est totalement séparée de la pile d'appel normale. Le programme exécuté continue d'utiliser la même pile d'appel qu'avant, ce qui fait que les adresses de retour sont dispersées dans la pile d'appel, parfois dans des registres spéciaux du processeur. Mais cela ne pose pas de problèmes pour le processeur, qui reconstruit la pile d'adresse de retour avec l'aide du décodeur. La reconstruction se fait à partir des instructions d'appel et de retour de fonction. Une instruction d'appel de fonction empile une adresse de retour, une instruction de retour de fonction la dépile. Lorsque le décodeur d'instruction reconnait une instruction d'appel de fonction, il extrait l'adresse de retour et l'envoie au ''return stack predictor''. L'adresse de retour est alors empilée, ce qui fait qu'elle se retrouve au sommet de la pile d'adresse de retour. Dépiler une adresse de retour revient à prédire un branchement. Lorsque le ''return stack predictor'' détecte une instruction de retour de fonction, il dépile l'adresse au sommet de la pile. L'adresse est alors envoyée du cache d'instruction, pour l'ire l'instruction adéquate, et le ''program counter'' est mis à jour avec cette adresse. Toute la difficulté revient à détecter les instructions de retour de fonction, sans quoi on ne sait pas quand dépiler l'adresse. La documentation là-dessus n'est pas très fournie, il est difficile de savoir ce qui est fait dans les processeurs modernes. Mais on peut faire quelques suppositions. La première possibilité est l'usage du ''prédécodage'', une technique qui décode partiellement les instructions lors de leur chargement dans le cache d'instruction. Elle sera vue dans le chapitre suivant, mais parlons-en rapidement. Le prédécodage ajoute quelques bits au début de chaque instruction pour préciser si c'est un branchement conditionnel, inconditionnel, un retour de fonction. On peut ainsi savoir rapidement quand dépiler : dès qu'une instruction est lue dans le cache d'instruction, le ''return stack predictor'' vérifie les bits ajoutés et agit en fonction. La seconde possibilité est que le BTB reconnait l'instruction de retour de fonction. Pour chaque branchement, il mémorise non seulement son adresse de retour, mais aussi son type. Par type, on veut dire : si c'est un branchement conditionnel, inconditionnel, un retour de fonction. Les retours de fonction sont donc reconnus à condition qu'ils aient été exécutés auparavant. Il est aussi possible d'avoir un BTB spécialisé dans les retours de fonction, qui est alimenté par les décodeurs. Dès que le décodeur reconnait une instruction de retour de fonction, il ajoute son ''program counter'' dans ce BTB spécialisé. Une dernière possibilité est que le ''return stack predictor'' agit une fois l'instruction partiellement décodée. Le décodeur d'instruction reconnait les instructions de retour de fonction rapidement, en un cycle, et consulte alors le ''return stack predictor''. L'adresse est dépilée, et le décodeur sait où brancher. Il n'a pas besoin de devoir lire l'adresse de retour en mémoire, il la lit depuis le ''return stack predictor'', directement. La prédiction se fait donc assez tard dans le pipeline, quelques instructions ont été chargées à tort. Mais le décodeur peut les annuler, comme il le ferait avec un branchement inconditionnel mal prédit. ==La prédiction de branchement locale== La prédiction de branchement tente de prédire si un branchement sera pris ou non-pris, et décide quelle adresse envoyer au cache en fonction du résultat. Si on prédit qu'un branchement est non pris, on laisse le ''program counter'' faire son travail. À l'inverse si le branchement est prédit comme étant pris, l'adresse de destination est envoyée au cache d'instruction et au ''program counter''. La prédiction de branchement assigne une probabilité qu'un soit branchement soit pris en fonction de ces informations : si la probabilité est de plus de 50%, le branchement est considéré comme pris. La prédiction de branchement se base avant tout sur des statistiques, c’est-à-dire qu'elle détermine la probabilité d’exécution d'un branchement en fonction d'informations assez diverses. La prédiction de branchement n'a d'intérêt que si les prédictions sont suffisamment fiables pour valoir le coup. Rappelons que la pénalité lors d'une mauvaise prédiction est importante, d'autant plus coûteuse que le pipeline est long. Il est généralement admis qu'il faut au minimum un taux de réussite proche de 90%, voire plus sur les processeurs modernes. Heureusement, la plupart des branchements sont des '''branchements biaisés''', c’est-à-dire qu'ils sont presque toujours pris ou presque toujours non-pris, sauf en de rares occasions. De tels branchements font que la prédiction des branchements est facile, bien qu'ils posent paradoxalement quelques problèmes, comme nous le verrons plus bas. Un autre point important est que les unités de prédiction de branchement doivent être très rapides. Idéalement, elles doivent fournir leur prédiction en un seul cycle d'horloge. Un seul cycle d'horloge est un temps très court, surtout sur les ordinateurs avec une fréquence élevée. Les techniques de prédiction dynamique ne peuvent donc pas utiliser des méthodes statistiques extraordinairement complexes. Cela va à l'encontre du fait que le taux de mauvaises prédictions doit être très faible, idéalement inférieur à 1% sur les processeurs modernes. Vous comprenez donc aisément que concevoir une unité de prédiction de branchement est un véritable défi d’ingénierie électronique qui requiert des prouesses technologiques sans précédent. Suivant la nature des informations utilisées, on peut distinguer plusieurs types de prédiction de branchement : locale, globale, dynamique, statique, etc. Nous allons d'abord voir les méthodes statiques avant de voir les méthodes dynamiques. La '''prédiction locale''' prédit chaque branchement indépendamment les uns des autres. Elle sépare bien les statistiques/moyennes pour chaque branchement. Et c'est autant un point fort qu'un point faible. En effet, elle n'exploite pas les corrélations entre branchements, à savoir le fait que l'exécution d'un branchement a tendance à déborder sur les suivants. Dans ce qui suit, nous allons voir les techniques de prédiction locales. ===Les ''branch hints''=== Dans son implémentation la plus explicite, la prédiction est inscrite dans le branchement lui-même. Quelques bits de l'opcode du branchement précisent si le branchement est majoritairement pris ou non pris. Ils permettent d'influencer les règles de prédiction de branchement et de passer outre les réglages par défaut. Ces bits sont appelés des '''suggestions de branchement''' (''branch hint''). Mais tous les processeurs ne gèrent pas cette fonctionnalité. Et de plus, cette solution marche assez mal. La raison est que le programmeur ou le compilateur doit déterminer si le branchement est souvent pris ou non-pris, mais qu'il n'a pas de moyen réellement fiable pour cela. Une solution possible est d’exécuter le programme sur un ensemble de données réalistes et d'analyser le comportement de chaque branchement, afin de déterminer les suggestions de branchement adéquates, mais c'est une solution lourde et peu pratique, qui a de bonnes chances de donner des résultats peu reproductibles d'une exécution à l'autre. ===La prédiction statique de branchement=== La '''prédiction statique''' prédit le résultat du branchement en fonction de caractéristiques fixes du branchement lui-même, comme son adresse, son mode d'adressage, l'adresse de destination, la position du branchement en mémoire, si le branchement est direct ou indirect, etc. Les informations en question sont connues une fois le branchement décodé, il n'y a pas besoin de mémoriser la moindre information supplémentaire. Une première implémentation, assez évidente, fait la différence entre les branchements inconditionnels toujours pris, et les branchements conditionnels au résultat variable. L'idée est de prédire les branchements inconditionnels comme toujours pris alors que les branchements conditionnels sont prédits comme non-pris (ce qui est une approximation). L'implémentation est relativement simple : on utilise un BTB qui ne mémorise que les branchements inconditionnels, et basta ! Les branchements conditionnels sont simplement ignorés, traités comme n'importe quelle autre instruction. Cette méthode est particulièrement inefficace pour les branchements de boucle, où la condition est toujours vraie, sauf en sortie de boucle ! Il a donc fallu affiner légèrement l'algorithme de prédiction statique. Une implémentation plus subtile fait la différence entre les branchements des boucles et les autres. Reste à identifier ceux-ci d'une manière ou d'une autre. Et un bon moyen est de regarder si le branchement revient en arrière ou au contraire saute une portion de code. Un programme s'exécute des adresses basses vers les adresses hautes, les instructions vont naturellement dans le sens croissant des adresses. Un branchement de boucle fait revenir en arrière, histoire de répéter un morceau de code, pas les autres branchements (du moins pas forcément). Et cela nous amène à faire la distinction entre les branchements conditionnels ascendants et descendants. Un '''branchement conditionnel ascendant''' est un branchement qui demande au processeur de reprendre plus loin dans la mémoire : l'adresse de destination est supérieure à l'adresse du branchement. Un branchement '''conditionnel descendant''' a une adresse de destination inférieure à l'adresse du branchement : le branchement demande au processeur de reprendre en arrière dans la mémoire, avant le branchement. Les branchements ascendants sont rarement pris (ils servent dans les conditions de type SI…ALORS), contrairement aux branchements descendants (qui servent à fabriquer des boucles). On peut ainsi modifier l’algorithme de prédiction statique comme suit : * les branchements inconditionnels sont toujours pris ; * les branchements descendants sont toujours pris ; * les branchements ascendants ne sont jamais pris. L'implémentation demande d'élargir le BTB. Le BTB n'a pas seulement à mémoriser l'adresse du branchement et son adresse de destination, mais aussi le type du branchement. En général, il se contente de deux bits, qui indiquent si le branchement est conditionnel, inconditionnel, ou un retour de fonction. Les retours de fonction sont traités à part, histoire de profiter du ''return stack predictor''. {|class="wikitable" |+ BTB avec prédiction de branchement statique évoluée |- ! Adresse du branchement !! Adresse de destination !! Type du branchement |- | 0x4165485 || 0x14657445541 | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Retour de fonction |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... || ... |} ===La prédiction avec des compteurs à saturation=== Les méthodes de prédiction de branchement statique sont intéressantes, mais elles montrent rapidement leurs limites. Pour plus de performances, il faut utiliser la '''prédiction dynamique''', qui extrait des statistiques sur l’exécution des branchements. Pour le reste du chapitre, toutes les techniques utilisent la prédiction dynamique, qu'elles soient locales ou globales. Sa version la plus simple se contente de mémoriser ce qui s'est passé lors de l’exécution précédente du branchement. Si le branchement a été pris, alors on suppose qu'il sera pris la prochaine fois. De même, s'il n'a pas été pris, on suppose qu'il ne le sera pas lors de sa prochaine exécution. Pour cela, chaque entrée du BTB contient un bit qui mémorise la dernière exécution du branchement : 1 s'il a été pris, 0 s'il n'a pas été pris. C'est ce qu'on appelle la '''prédiction de branchements sur 1 bit'''. Au vu de sa simplicité, il ne faut pas s'attendre à des miracles. La méthode fonctionne bien pour les branchements des boucles et les branchements inconditionnels, mais elle n'est pas assez performante. Une forme plus élaborée de prédiction utilise des '''compteurs à saturation'''. Un compteur à saturation est un compteur qui est incrémenté à chaque fois qu'un branchement est pris, décrémenté s'il est non-pris. Le compteur est initialisé à la moitié de sa valeur maximale, ce qui correspond à une probabilité proche de 50%. Les compteurs à saturation sont très utilisés et pas seulement pour de la prédiction locale, les méthodes globales en utilisent aussi. Pour faire la prédiction, l'unité de prédiction regarde le bit de poids fort du compteur. Le branchement est considéré comme pris si ce bit de poids fort est à 1, et non pris s'il vaut 0. En clair : on considère que le branchement est pris si le compteur indique une probabilité supérieure à 50%. [[File:Compteur à saturation.png|centre|vignette|upright=2|Compteur à saturation.]] : Pour la culture générale : le compteur à saturation du Pentium 1 était légèrement bogué, ce qui fait que sa prédiction de branchement était sous-optimale. La plupart des processeurs utilisent un compteur à saturation par branchement, ce qui veut dire un compteur à saturation par entrée dans le BTB. Vu chaque compteur est associé à une entrée du BTB, le phénomène d’''aliasing'' mentionné plus haut peut perturber les prédictions de branchement. Deux branchements peuvent se voir associés à la même entrée du BTB, et agir sur le même compteur à saturation, ce qui entraine l'apparition d''''interférences entre branchements'''. L'interférence peut avoir des effets positifs, neutres ou négatifs, suivant la corrélation entre les deux branchements. L’''aliasing'' n'est pas un problème si les deux branchements sont fortement corrélés, par exemple si les deux sont tout le temps pris ou tout le temps non-pris. Si les deux branchements vont dans le même sens, l'interférence est neutre, voire légèrement positive. Mais si l'un est souvent pris et l’autre souvent non-pris, l'interférence est négative. Les deux branchements vont se marcher dessus sans vergogne, chacun interférant sur les prédictions de l'autre ! Il est rare que l’''aliasing'' ait un impact significatif sur les unités de prédiction des deux paragraphes précédents. Il se manifeste surtout quand la BTB est très petite et la seule solution est d'augmenter la taille du BTB. ===La prédiction avec un historique local=== La prédiction avec des compteurs à saturation est très simple, mais il est possible de créer une implémentation alternative, très proche, avec des avantages et désavantages différents. Un compteur à saturation enregistre l'historique d'un branchement, à savoir ses dernières exécutions. L''''historique''' d'un branchement dit quel a été son résultat dans le passé, pour ses exécutions précédentes. Et il est possible d'enregistrer cet historique et l'utiliser pour faire des prédictions. Par exemple, prenons un branchement sont ses 6 dernières exécutions sont "pris, non-pris, pris, pris, pris, non-pris". Le processeur peut encoder cet historique sur 6 bits. Un branchement pris correspond à un bit à 1, un branchement non-pris correspond à un 0. Le bit de poids fort correspond à l'exécution la plus ancienne, le bit de poids faible à l'exécution la plus récente. Par exemple, si l'historique contient 010, cela veut dire que le branchement a été non pris, puis pris, puis non pris. L'historique d'un branchement est appelé son '''historique local''', mais nous parlerons simplement l'historique. Le processeur peut mémoriser l'historique de chaque branchement. Pour cela, il réserve n bits par branchements, pour mémoriser ses n exécutions précédentes. L'historique d'un branchement est mémorisé dans son entrée du BTB. Et plus précisément dans un champ séparé qui est alimentée comme un registre à décalage. Reste à exploiter cet historique local pour faire des prédictions. Une idée assez simple serait de l'utiliser pour calculer une moyenne des N exécutions précédentes. Si la probabilité d'être pris est supérieure à 50 %, le branchement est considéré comme pris. Dans le cas contraire, il est considéré comme non pris. Le calcul de la moyenne utilise une simple porte à majorité, qui prend en entrée tous les bits de l'historique. Cependant, cette méthode est équivalente à utiliser un compteur à saturation par branchement. La sortie de la porte à majorité est équivalente à ce qu'on aurait dans un compteur à saturation. La seule différence : le cout en circuit et la simplicité de conception. Prenons l'exemple des premiers processeurs PA RISC. Ils mémorisaient, pour chaque branchement, les trois dernières exécutions du branchement, avec trois bits par entrée du BTB. La prédiction était réalisée avec une porte à majorité, dont le résultat était utilisé comme prédiction. Ces processeurs ont préféré utiliser un historique de 3 bits, car il était plus simple à concevoir qu'un compteur à saturation équivalent de deux bits. Il suffisait d'alimenter un registre à décalage avec un bit, pas besoin d'incrémenter ou de décrémenter un compteur, pas besoin de signaux d'incrémentation/décrémentation séparés, etc. Mais pour des historiques plus longs, la donne s'inverse et des compteurs à saturation de deux-trois bits l’emportent. Un défaut de la méthode précédente, qui est partagée avec les compteurs à saturation, est qu'elle marche mal pour les branchements qui suivent un ''pattern''. Je veux dire par là que l'exécution d'un branchement suit un cycle, par exemple pris, pris, non-pris, pris, pris, non-pris, et ainsi de suite en boucle. Notons que l'apparition de tels motifs correspond le plus souvent à la présence de boucles dans le programme. Quand une boucle s’exécute, les branchements qui sont à l'intérieur tendent à exprimer de tels motifs. Avec de tels motifs, les compteurs à saturation feront des prédictions incorrectes, là où les prédicteurs qu'on va voir font des prédictions parfaites. Par exemple, prenons un branchement qui suit le ''pattern'' pris, pris, pris, non-pris. Si l'historique local vaut 1110, le branchement est censé refaire une boucle et être pris. Et certes, on a supposé que l'historique faisait 4 bits. Mais le''pattern'' sera visible tant que l'historique fait plus de 4 bits. Par exemple, le ''pattern'' serait 01110 pour un historique local de 5 bits, 101110 pour un historique local de 6 bits, 1101110 pour un historique local de 7 bits, etc. Un ''pattern'' correspond donc à une valeur bien précise de l'historique local. L'historique local permet de voir ces cycles, reste à les exploiter. Prédire correctement les ''pattern'' demande simplement d'associer un compteur à saturation à chaque valeur possible de l'historique local. Le compteur à saturation dira, quand tel pattern est rencontré, si le branchement sera pris ou non-pris par la suite. Au total, il faut donc un registre d'historique local de n bits couplé à 2^n compteurs à saturation. Et ce pour chaque branchement possible. Les compteurs à saturation sont regroupés dans une '''''Pattern History Table''''', que nous noterons PHT dans ce qui suit. Le résultat est une '''unité de prédiction à deux niveaux locale''', qui contient plusieurs registres d'historique et tout autant de PHT locales. En tout, il y a une PHT et un historique par branchement, donc par entrée du BTB. Quand un branchement est exécuté, son historique local indique quel compteur à saturation choisir dans la PHT locale. En général, l'historique local est mémorisé dans le BTB, reste à gérer la PHT. [[File:Unité de prédiction à deux niveaux purement locale.png|centre|vignette|upright=2.5|Unité de prédiction à deux niveaux avec des historiques et des PHT locales.]] Une unité de prédiction locale à deux niveaux contient plusieurs PHT, reste à savoir quelle PHT correspond à tel branchement. La correspondance se fait idéalement à partir de l'adresse du branchement, mais c'est là une implémentation idéale, qui demande beaucoup de circuits. Généralement, l'unité de prédiction n'utilise que les bits de poids faible de l'adresse du branchement, ce qui permet de faire un choix correct, mais avec une possibilité d'''aliasing''. Il est aussi possible de séparer l'historique local du BTB. Les historiques locaux sont alors mémorisés dans une mémoire RAM dédiée, appelé la '''''Branch History Table''''', ou encore la '''table des historiques locaux''', que nous noterons BHT dans ce qui suivra. Chaque byte de la BHT contient l'historique local associé à un branchement. Pour récupérer l'historique local voulu, l'adresse du branchement est envoyée à la BHT, sur les entrées d'adresse de la BHT, directement. Seuls les bits de poids faible de l'adresse sont utilisés. L'historique local est ensuite envoyé à la PHT adéquate, choisie en fonction des mêmes bits de poids faible du PC. Un bon moyen pour cela est d'accéder à toutes les PHT en parallèle, mais de sélectionner la bonne avec un multiplexeur, ce dernier étant commandé par les bits de poids faible du PC. [[File:Table des historiques locaux.jpg|centre|vignette|upright=2|Table des historiques locaux]] Un défaut des unités de prédiction purement locales de ce type est leur cout en termes de circuits. Utiliser un registre d'historique et une PHT par branchement a un cout en transistor élevé, avec tous les défauts que ca implique en termes de consommation électrique et thermique. Elles ont aussi des temps de calcul un peu plus important que les unités purement globales, mais pas de manière flagrante. Les gains en performance avec un historique local s'observent surtout pour les branchements qui sont dans des boucles ou pour les branchements utilisés pour concevoir des boucles, alors que l'implémentation globale marche bien pour les if..else (surtout quand ils sont imbriqués). Les deux sont donc complémentaires. Dans les faits, elles sont rarement utilisées seules du fait de leurs défauts, au profit d'unités de prédiction hybrides, qui mélangent historiques et PHT locaux et globaux. ==La prédiction de branchement globale/mixte== Utiliser un historique local avec une PHT est très puissant. Mais le cout en circuit est très important. Cependant, on peut adapter l'idée pour faire de la prédiction globale. La '''prédiction globale''' fait des prédictions holistiques, qui tiennent compte de tous les branchements, sans les traiter séparément. Elle ne sépare pas les branchements, tous sont mis dans le même panier. La prédiction globale fait donc le compromis inverse : elle exploite les corrélations entre branchements, mais perd les informations statistiques précise pour chaque branchement. L'idée est qu'au lieu d'utiliser un historique par branchement, on utilise un '''historique global''' qui mémorise les exécutions de tous les branchements conditionnels. L'historique enregistre les N exécutions de branchements conditionnels. L'historique global est mémorisé dans un registre unique, appelé le '''registre d'historique global'''. C'est un registre à décalage mis à jour à chaque fois que le processeur exécute un branchement : on fait rentrer un 1 si le branchement est pris, et un 0 sinon. Une unité de prédiction globale utilise cet historique pour faire sa prédiction. L'unité de prédiction globale utilise un compteur à saturation pour chaque valeur possible du registre d'historique. En clair, pour un registre de N bits, elle utilise 2^n compteurs à saturation. Là encore, les compteurs à saturation sont regroupés dans une ''Pattern History Table''. Nous parlerons de '''PHT globale''' pour désigner une PHT commandée par un historique global, de ''PHT locale'' si elle est adressée par un historique local. [[File:Prédiction dynamique à deux niveaux.png|centre|vignette|upright=2|Prédiction dynamique à deux niveaux.]] Il est admis que l'historique global donne de meilleures prédictions que l'historique local. Mais les deux sont complémentaires, ce qui fait qu'il existe des approches hybrides. Les approches hybrides exploitent à la fois les statistiques pour chaque branchement et les corrélations entre branchements. Les prédicteurs hybrides ont de meilleures performances que les prédicteurs purement globaux ou locaux. Avec un historique global, la prédiction tient compte d'éventuelles corrélations entre branchements consécutifs/proches pour faire les prédictions. C'est un avantage pour les applications qui enchaînent des if...else ou qui contiennent beaucoup de if...else imbriqués les uns dans les autres, qui s’exécutent plusieurs fois de suite. Le résultat de chaque if...else dépend généralement des précédents, ce qui rend l'utilisation d'un historique global intéressant. Par contre, ils sont assez mauvais pour le reste des branchements. Mais cette qualité devient un défaut quand elle détecte des corrélations fortuites ou parasites, inutiles pour la prédiction (corrélation n'est pas causalité, même pour prédire des branchements). Du fait de ce défaut, la prédiction globale a besoin de registres d'historiques globaux très larges, de plusieurs centaines de bits au mieux. [[File:2 level branch predictor.svg|centre|vignette|upright=2|Unités de prédiction de branchement à deux niveaux avec une seule PHT.]] Le circuit précédent a cependant un problème : si deux branchements différents s'exécutent et que l'historique est le même, le processeur n'y verra que du feu. Il y a alors une interférence, à savoir que les deux branchements vont agir sur le même compteur à saturation. On se retrouve alors dans une situation conceptuellement identique à l’''aliasing'' dans le BTB ou avec des compteurs à saturation. Sauf que les interférences sont alors beaucoup plus nombreuses et qu'elles sont clairement un problème pour les performances ! Mitiger les interférences devient alors bien plus important qu'avec les unités de prédiction précédentes. Heureusement, si augmenter la taille de la PHT peut mitiger le problème, d'autres solutions sont possibles sur les unités de prédiction globales. Elles sont au nombre de deux : mitiger les interférences liées aux branchements biaisés, ajouter des informations qui discriminent les branchements. Voyons ces solutions dans le détail. ===La mitigation des interférences par usage de l'adresse de branchement=== La première solution pour réduire l'impact de l’''aliasing'' est d'augmenter la taille de la PHT et de l'historique. Plus l'historique et la PHT sont grands, moins l’''aliasing'' est fréquent. Mais avoir des historiques et des PHT de très grande taille n'est pas une mince affaire et le cout en termes de performances et de portes logiques est souvent trop lourd. Une autre solution consiste à utiliser, en plus de l'historique, des informations sur le branchement lui-même pour choisir le compteur à saturation à utiliser. Typiquement, on peut utiliser l'adresse du branchement en plus de l'historique, pour choisir le compteur à saturation adéquat. Pour être précis, on n'utilise que rarement l'adresse complète du branchement, mais seulement les bits de poids faible. La raison est que cela fait moins de bits à utiliser, donc moins de circuits, mais l’''aliasing'' est juste atténué, pas éliminé. Des branchements différents ont beau avoir des adresses différentes, les bits de poids faible de leurs adresses peuvent être identiques. Des confusions sont donc possibles, mais sont assez rares pour que cela suffise en pratique. Une unité de prédiction de ce type est illustrée ci-dessous. Il y a plusieurs PHT séparées, donc plusieurs ensembles de 2^n compteurs à saturation. Le choix de la PHT est réalisé par l'adresse du branchement, alors que le choix du compteur à saturation est réalisé par l'historique. Les compteurs à saturation d'un branchement sont mis à jour seulement quand le branchement s'est chargé pendant que l'historique associé était dans le registre d'historique. Cette méthode a cependant le défaut de gâcher énormément de transistors. [[File:Unité de prédiction de branchement basée sur des compteurs à saturation sélectionnés par l'historique global.jpg|centre|vignette|upright=2|Unité de prédiction de branchement basée sur des compteurs à saturation sélectionnés par l'historique global]] D'autres unités de prédiction fonctionnent sur le même principe que l'unité précédente, sauf que les différentes PHTs sont fusionnées en une seule grosse PHT. C'est ce que font les unités de prédiction '''''gshare''''' et '''''gselect''''', ainsi que leurs dérivées. Avec les unités de prédiction '''''gselect''''', on concatène les bits de poids faible de l'adresse du branchement et l'historique pour obtenir le numéro du compteur à saturation à utiliser. [[File:Prédiction « gselect ».png|centre|vignette|upright=2|Prédiction « gselect ».]] Avec les unités de prédiction '''''gshare''''', on fait un XOR entre le registre d'historique et les bits de poids faible de l'adresse du branchement. Le résultat de ce XOR donne le numéro du compteur à utiliser. Avec cette technique, on utilise une PHT globale et un historique global, mais le registre d'historique est manipulé de manière à limiter l'''aliasing''. [[File:Prédiction « gshare ».png|centre|vignette|upright=2|Prédiction « gshare ».]] Intuitivement, on pourrait croire que les unités de prédiction gselect ont de meilleures performances. C'est vrai que les unités gshare combinent l'adresse du branchement et l'historique d'une manière qui fait perdre un peu d'information (impossible de retrouver l'adresse du branchement et l'historique à partir du résultat du XOR), alors que les unités gselect conservent ces informations. Mais il faut prendre en compte le fait que les deux unités doivent se comparer à PHT identique, de même taille. Prenons par exemple une PHT de 256 compteurs, adressée par 8 bits. Avec une unité gselect, les 8 bits sont utilisés à la fois pour l'historique et pour les bits de poids faible de l'adresse du branchement. Alors qu'avec une unité gshare, on peut utiliser un historique de 8 bits et les 8 bits de poids faible de l'adresse du branchement. L'augmentation de la taille de l'historique et du nombre de bits utilisés fait que l’''aliasing'' est réduit, et cela compense le fait que l'on fait un XOR au lieu d'une concaténation. Les unités précédentes réduisent l'''aliasing'' en segmentant la PHT : telles entrées sont allouées à un branchement, telles entrées à tel autre branchement, etc. Cependant, l'historique local n'est pas utilisé. Mais quitte à allouer des entrées à un branchement, autant en profiter pour leur associer son historique local. Il s'agit alors d'approches hybrides. Par exemple, on peut citer la '''prédiction mixte''' (''alloyed predictor''). Avec celle-ci, la table des compteurs à saturation est adressée avec la concaténation : de bits de l'adresse du branchement, du registre d'historique global et du registre d'historique local adressé par l'adresse du branchement. [[File:Prédiction mixte.jpg|centre|vignette|upright=2|Prédiction mixte.]] Une version optimisée de ce circuit permet d’accéder à la PHT et à la BHT en parallèle. Avec elle les bits de poids faible de l'adresse du branchement sont concaténés avec l'historique global. Le résultat est ensuite envoyé à plusieurs PHT locales distinctes, en parallèle. Les différences PHT envoient leurs résultats à un multiplexeur, commandé par l'historique local, qui sélectionne le résultat adéquat. L'inconvénient de cette mise en œuvre est que le circuit est assez gros, notamment en raison de la présence de plusieurs PHT séparées. [[File:Alloyed predictor optimisé.jpg|centre|vignette|upright=2|''Alloyed predictor'' optimisé]] ===La mitigation des interférences par la structuration en caches des PHTs=== Les techniques précédentes utilisent les bits de poids faible de l'adresse pour sélectionner la PHT adéquate, ou pour sélectionner le compteur dans la PHT. Mais le reste de l'adresse n'est pas utilisé. Cependant, il est théoriquement possible de conserver les bits de poids fort, afin d'identifier le branchement associé à un compteur. Pour cela, chaque compteur à saturation se voit associé à un ''tag'', qui mémorise le reste de l'adresse du branchement associé au compteur, comme pour les mémoires caches. Lorsque l'unité de prédiction démarre une prédiction, elle récupère les bits de poids fort de l'adresse du branchement et compare ceux-ci avec le ''tag''. S’il y a un ''match'', c'est signe que le compteur est bien associé au branchement à prédire. Dans le cas contraire, c'est signe qu'on fait face à une situation d’''aliasing'' et le compteur n'est pas utilisé pour la prédiction. Le résultat transforme la PHT en une sorte de cache assez particulier, qui associe un compteur à saturation à chaque couple adresse-historique. La technique du paragraphe précédent peut être encore améliorée afin de réduire l’''aliasing''. L’''aliasing'' sur une unité de prédiction de branchement est similaire aux conflits d'accès au cache, où deux données/adresses atterrissent dans la même ligne de cache. Une PHT peut formellement être vue comme un cache directement adressé. Une solution pour limiter ces conflits d'accès à de tels, est de les transformer en un cache associatif à n voies. On peut faire la même chose avec les unités de prédiction de branchement, ce qui revient à dupliquer les PHT, de manière à ce que les bits de poids faible de l'adresse se voient attribuer à plusieurs compteurs à saturation, un par branchement possible. Ce faisant, on réduit les interférences entre branchements. Mais cette technique augmente la quantité de circuits utilisés et la consommation d'énergie, ainsi que le temps d'accès, sans compter qu'elle requiert d'utilisation de ''tags'' pour fonctionner à la perfection. Pour les unités de prédiction de branchement, les mesures à ce sujet ne semblent pas montrer un impact réellement important sur les performances, comparé à une organisation de type directement adressé. Il est possible de pousser la logique encore plus loin en ajoutant des caches de victime et d'autres mécanismes courant sur les caches usuels. ===La mitigation des interférences liées aux branchements biaisés=== L’''aliasing'' a un impact négatif quand deux branchements aliasés (qui sont attribués au même compteur à saturation) vont dans des sens différents : si l'un est non-pris, l'autre a de bonnes chances d'être pris (ou inversement). Or, la plupart des branchements sont biaisés, à savoir très fortement orientés dans une direction qu'une autre (presque toujours pris ou presque toujours non-pris, à plus de 90/99% de chances). Plus un branchement a une probabilité élevée d' être pris ou non-pris, plus sa capacité à interférer avec les autres est forte. Le pire des cas est quand deux branchements sont biaisés mais dans des directions inverses : l'interférence est maximale. En conséquence, les branchements biaisés ou quasi biaisés sont les plus problématiques pour l’''aliasing''. Or, il est possible de concevoir des prédicteurs de branchements qui atténuent fortement les interférences des branchements biaisés ou quasi biaisés. C’est le cas de l’''agree predictor'' et de l'unité de prédiction bimodale, que nous allons voir dans ce qui suit. Les branchements très souvent pris vont interférer très fortement avec les branchements très souvent non-pris, et inversement. Par contre, les branchements très souvent pris n'interféreront pas entre eux, de même que les branchements non-pris iront bien ensemble. D'où l'idée de séparer ces deux types de branchements dans des PHT séparées, pour éviter le mauvais ''aliasing''. L''''unité de prédiction bimodale''' part d'une unité gshare, et sépare la PHT globale en deux : une PHT dédiée aux branchements qui sont très souvent pris et une autre pour les branchements très peu pris. Les deux PHT fournissent chacune une prédiction. La bonne prédiction est choisie par un multiplexeur commandé par une unité de sélection, qui se charge de déduire quelle unité a raison. Cette unité de sélection est une unité de prédiction basée sur des compteurs à saturation de 2 bits. On peut encore améliorer l'unité de sélection de prédiction en n'utilisant non pas des compteurs à saturation, mais une unité de prédiction dynamique à deux niveaux, ou toute autre unité vue auparavant. [[File:Prédiction bimodale.jpg|centre|vignette|upright=2|Prédiction bimodale.]] La '''prédiction par consensus''' (''agree predictor'') combine une unité de prédiction à historique global (idéalement de type gshare ou gselect, mais une unité normale marche aussi) et des compteurs à saturation. Il s'agit techniquement d'une technique de prédiction mixte, à la fois globale et locale. Le BTB intègre un compteur à saturation de un bit, qui indique la direction privilégiée du branchement. L'unité à historique global a un fonctionnement changé : elle calcule non pas la probabilité que le branchement soit pris ou non-pris, mais la probabilité que le résultat du branchement soit compatible avec le résultat de l'unité de prédiction de 1 bit. La prédiction finale vérifie que ces deux circuits sont d'accord, en faisant un NXOR entre les résultats des deux unités de prédiction de branchement. [[File:Prédiction par consensus.png|centre|vignette|upright=2|Prédiction par consensus.]] Des calculs simples de probabilités montrent que l'''agree predictor'' a des résultats assez importants. Ses résultats sont d'autant meilleurs que les branchements sont biaisés et penchent fortement vers le côté pris ou non-pris. ==La prédiction de branchement avec des perceptrons== Les méthodes précédentes utilisent au moins un compteur à saturation par historique possible. Sachant qu'il y a 2^n valeurs possibles pour un historique de n bits, le nombre de compteurs à saturation augmente exponentiellement avec la taille de l'historique. Ce qui limite grandement la taille de l'historique, alors qu'on aurait au contraire besoin d'un historique assez large pour faire des prédictions excellentes. Pour contourner le problème, des alternatives basées sur des techniques d'apprentissage artificiel (''machine learning'') existent. Un exemple est celui des unités de prédiction de branchement dites de ''neural branch prediction'', basée sur des perceptrons. Dans ce qui suit, nous les appelerons des '''unités perceptron'''. Les perceptrons sont un algorithme d'apprentissage automatique très simple, ce qui fait que les explications qui vont suivre devraient être simples. En théorie, il est possible d'utiliser des techniques d'apprentissage automatique plus élaborées, mais cela n'est pas possible en pratique. Rappelons qu'une unité de prédiction de branchement doit idéalement fournir sa prédiction en un cycle d'horloge. L'implémentation d'un perceptron est déjà problématique de ce point de vue, car elle demande d'utiliser beaucoup de circuits arithmétiques complexes (multiplication et addition). Autant dire que les autres techniques de ''machine learning'', encore plus gourmandes en calculs, ne sont pas praticables. ===Les perceptrons et leur usage en tant qu'unité de prédiction de branchements=== L'idée derrière les unités perceptron est que certains bits de l'historique sont plus importants que d'autres. En soi, ce genre de situation est réaliste. Il arrive que certains branchements soient pris uniquement si les deux branchements précédents ne le sont pas, ou que si l'avant-dernier branchement est lui aussi pris. Mais les autres résultats de branchement présents dans l'historique ne sont pas utiles. En conséquence, deux historiques semblables, qui ne sont différents que d'un ou deux bits, peuvent donner des prédictions presque identiques. Dans ce cas, on peut faire la prédiction en n'utilisant que les bits identiques entre ces historiques, ou du moins en leur donnant plus d'importance qu'aux autres. On exploite alors des corrélations avec certains bits de l'historique, plutôt qu'avec l'historique entier lui-même. Pour coder ces corrélations, on associe un coefficient à chaque bit de l'historique, qui indique à quel point ce bit est important pour déterminer le résultat final. Ce coefficient est proportionnel à la corrélation entre le bit de l'historique associé, et le résultat du branchement à prédire. Les coefficients sont des nombres entiers qui peuvent être positifs, nuls ou négatifs. Un coefficient positif signifie que si le branchement associé a été pris, alors le branchement à prédire a de bonnes chances de l'être aussi (et inversement). Un coefficient nul signifie que les deux branchements sont indépendants et que le bit de l'historique associé n'est pas à prendre en compte. Pour un coefficient négatif, cela signifie que si le branchement associé a été pris, alors le branchement à prédire a de bonnes chances d'être non-pris (et inversement). Les coefficients en question sont généralement compris entre -1 et 1. Une fois qu'on connait ces coefficients de corrélations, on peut calculer la probabilité que le branchement à prédire soit pris, avec des calculs arithmétiques simples. Dans les calculs qui vont suivre, un branchement pris est codé par un 1, alors qu'un branchement non-pris est codé par un -1 ! Ce détail permet de simplifier grandement les calculs. Dans sa version la plus simple, le perceptron calcule un nombre Z qui est positif ou nul si le branchement est pris, négatif s'il ne l'est pas. Tous les coefficients <math>p_i</math> sont alors des nombres relatifs, pouvant être positifs, nuls ou négatifs. Ils sont généralement compris entre -1 et 1. La formule devient donc celle-ci : : <math>Z = w_0 + \sum_i (h_i \times w_i)</math> [[File:ArtificialNeuronModel francais.png|centre|vignette|upright=2|Perceptron.]] : Sur le plan mathématique, le perceptron effectue un produit scalaire entre deux vecteurs : l'historique et le vecteur des poids. Choisir les coefficients adéquats est le but de l'algorithme du perceptron. L'unité de prédiction part de poids par défaut, qui sont mis à jour à chaque bonne ou mauvaise prédiction. Toute la magie de cet algorithme tient dans la manière dont sont mis à jour les poids. L'idée est de prendre le vecteur des poids, puis d'ajouter ou soustraire l'historique suivant le résultat du branchement. Les poids évoluent ainsi à chaque branchement, améliorant leurs prédictions d'une exécution à l'autre. La règle de mise à jour des poids est donc la suivante : : <math>w_i \leftarrow w_i + t \times h_i</math>, avec t = 1 pour un branchement pris, -1 pour un branchement non-pris. ===L'implémentation matérielle des perceptrons et ses optimisations=== Les calculs réalisés par un perceptrons sont simples, juste des multiplications et des additions et cela se sent dans la conception du circuit. L'unité perceptron utilise un perceptron par entrée du BTB, un par branchement pour simplifier. Pour cela, on utilise une mémoire SRAM qui mémorise les poids de chaque perceptron. La SRAM est adressée par les bits de poids faible du ''program counter'', de l'adresse du branchement. Une fois les poids récupérés, ils sont envoyés à un circuit de calcul de la prédiction, composé de circuits multiplieurs suivis par un additionneur multi-opérande. Le circuit de mise à jour des poids récupère la prédiction, les poids du perceptron, mais aussi le résultat du branchement. La mise à jour étant une simple addition, le circuit est composé d'additionneurs/soustracteurs, couplés à des registres pour mémoriser la prédiction et les poids du perceptron en attendant que le résultat du branchement soit disponible. Vu qu'il y a un délai de quelques cycles avant que le résultat du branchement soit disponible et que les prédictions doivent continuer pendant ce temps, les poids du perceptron et la prédiction sont stockés dans des FIFOs lues/écrites à chaque cycle. [[File:Unité de prédiction de branchement basée sur des perceptrons.png|centre|vignette|upright=2|Unité de prédiction de branchement basée sur des perceptrons]] Rien de bien compliqué sur le principe, mais rappelons que le perceptron doit fournir un résultat en moins d'un cycle d'horloge, et cela tient compte de l'accès au BTB. C'est un temps très court, surtout pour un circuit qui implique des multiplications. Les multiplications prennent facilement deux à trois cycles d'horloge. Autant dire que si on ajoute le temps d'accès à une petite SRAM, pour récupérer le perceptron, c'est presque impossible. Mais quelques optimisations simples permettent de rendre le calcul plus rapide. Premièrement, la taille de la SRAM est réduite grâce à quelques économies assez simples. Notamment, le perceptron utilise des poids codés sur quelques bits, généralement un octet, parfois 7 ou 5 bits, guère plus. Les poids sont encodés en complément à 1 ou en complément à 2, là où les perceptrons normaux utilisent des nombres flottants. Rien que ces deux choix simplifient les circuits et les rendent plus rapides. Le désavantage d'utiliser peu de bits par poids est une perte mineure en termes de taux de prédiction, qui est plus que compensée par l'économie en portes logiques, qui permet d'augmenter la taille de la SRAM. D'autres optimisations portent sur le circuit de calcul. Par exemple, la multiplication <math>h_i \times w_i</math> est facultative. Vu que l'historique contient les valeurs 1 et -1, il suffit d'additionner le poids associé si la valeur est 1 et de le soustraire s'il vaut -1. On peut même simplifier la soustraction et se limiter à une complémentation à un (une inversion des bits du poids). En faisant cela, les circuits multiplieurs disparaissent et le circuit de prédiction se résume à un simple additionneur multi-opérande couplé à quelques inverseurs commandables. Certains perceptrons prennent en compte à la fois l'historique global et l'historique local pour faire leur prédiction. Les deux historiques sont simplement concaténés et envoyés en entrée du perceptron. Cela marche assez bien, car les perceptrons peuvent utiliser des historiques de grande taille sans problèmes. Par contre, il y a besoin d'ajouter une ''branch history table'' au circuit, ce qui demande des portes logiques en plus, mais aussi un temps de calcul supplémentaire (l'accès à cette table n'est pas gratuit). ===Les avantages et inconvénients des perceptrons pour la prédiction de branchements=== L'avantage des unités perceptron est qu'elles utilisent un nombre de portes logiques qui est proportionnel à la taille de l'historique, et non pas exponentiel. Cela permet d'utiliser un historique de grande taille, et donc d'obtenir un taux de bonnes prédictions très élevé. Leur désavantage est que leurs performances de prédiction sont moins bonnes à historique équivalent. Les unités perceptrons ont des taux de prédiction élevés, mais seulement quand certaines conditions mathématiques sont respectées par les historiques. La condition en question est que les historiques correspondants à un branchement pris et les historiques où ce même branchement est non-pris doivent être linéairement séparables. Heureusement, les branchements des programmes informatiques sont une majorité à respecter la condition de séparation linéaire, ce qui rend les perceptrons parfaitement adaptés à la prédiction de branchements. La notion de linéairement séparable est un peu difficile à comprendre. Pour l'expliquer, imaginez que les N bits de l'historique forme un espace à N-dimensions discrets. Chaque historique est un point dans cet espace N-dimensionnel et chaque bit sert de coordonnée pour se repérer dans cet espace. Une fonction est linéairement séparable si l'espace N-dimensionnel peut être coupé en deux par un hyperplan : d'un côté de ce plan se trouvent les historiques des branchements pris, de l'autre se trouvent les historiques des branchements non-pris. Cet hyperplan est défini par l'ensemble des points qui respecte l'équation suivante : : <math>w_0 + \sum_i (h_i \times w_i) = 0</math> Un exemple où cette condition est respectée est quand le résultat d'une prédiction se calcule avec un ET entre plusieurs branchements de l'historique. Si un branchement est pris quand plusieurs branchements de l’historique sont tous pris ou tous non-pris, la condition est respectée et le perceptron donnera des prédictions correctes. Un exemple où cette condition n'est pas respectée est un banal XOR. Prenons l'exemple d'un historique global de 2 bits. Imaginons que le branchement à prédire soit pris : soit quand le premier branchement de l'historique est pris et le second non-pris, soit quand le premier est non-pris et le second pris. Concrètement, la prédiction exacte se calcule en faisant un XOR entre les deux bits de l'historique. Dans ce cas, la condition "linéairement séparable" n'est pas respectée et le perceptron n'aura pas des performances optimales. Les unités perceptron utilisent idéalement un perceptron par branchement. Cela demande d'associer, l'adresse du branchement pour chaque perceptron, généralement en donnant un perceptron par entrée du BTB. Mais une telle organisation n'est pas possible en pratique, car elle demanderait d'ajouter un ''tag'' à chaque perceptron/entrée du BTB, ce qui demanderait beaucoup de circuits et de ressources matérielles. Dans les faits l'association entre un branchement et un perceptron se fait en utilisant les bits de poids faible de l'adresse de branchement. Mais faire cela entrainera l'apparition de phénomènes d'''aliasing'', comme pour les unités de prédiction à deux niveaux. Les techniques vues précédemment peuvent en théorie être adaptées sur les unités à perceptrons, dans une certaine mesure. Notons que les perceptrons sont des algorithmes ou des circuits qui permettent de classer des données d'entrée en deux types. Ici, la donnée d'entrée est l'historique global et la sortie du perceptron indique si le branchement prédit est pris ou non-pris. Ils ne sont donc pas adaptés à d'autres tâches de prédiction, comme pour le préchargement des lignes de cache ou la prédiction de l'adresse de destination d'un branchement, ou toute autre tâche d'exécution spéculative un tant soit peu complexe. Leur utilisation reste cantonnée à la prédiction de branchement proprement dit, guère plus. Par contre, les autres techniques vues plus haut peuvent être utilisées pour le préchargement des lignes de cache, ou d'autres mécanismes de prédiction plus complexes, que nous aborderons dans la suite du cours. ==La prédiction avec plusieurs unités de branchement distinctes== Il est possible de combiner plusieurs unités de prédiction de branchement différentes et de combiner leurs résultats en une seule prédiction. On peut par exemple combiner une unité de prédiction spécialisée dans les boucles, une unité de prédiction 2 bits basée sur des compteurs à saturation, une unité de prédiction statique et une unité ''gshare''. Tout le problème est de décider quelle unité a fait la bonne prédiction, comment combiner les résultats des différentes unités de prédiction. Soit on choisit un résultat qui parait plus fiable que les autres, soit on combine les résultats pour faire une sorte de moyenne des prédictions. La méthode la plus simple de choisir le résultat d'une unité de prédiction parmi toutes les autres. Pour implémenter le tout, il suffit d'un multiplexeur qui effectue le choix de la prédiction. Reste à commander le multiplexeur, ce qui demande un '''circuit méta-prédicteur''' qui sélectionne la bonne unité de prédiction. Il arrive que le circuit méta-prédicteur soit conçu avec les mêmes techniques que les unités de prédiction de branchement elle-mêmes. Dans le cas le plus simple, il s'agit d'un simple compteur à saturation, mis à jour suivant la concordance des prédictions avec le résultat effectif du branchement. Mais c'est loin d'être systématique. Une autre solution combine le résultat des différentes unités de prédiction avec une fonction mathématique adéquate. Avec un nombre impair d'unités de prédiction, une méthode assez efficace est de simplement prendre le résultat majoritaire. Si une majorité d'unités pense que le branchement est pris, alors on le considère comme pris, et comme non pris dans le cas contraire. Par contre, avec un nombre pair d'unités, cette méthode simple ne marche pas, il y a un risque d'égalité. L'unité de prédiction de branchement « '''''e-gskew''''' » utilise trois unités gshare et combine leurs résultats avec un vote à majorité. Les trois unités gshare utilisent des XOR légèrement différents, dans le sens où les bits de l'adresse du branchement choisi ne sont pas les mêmes dans les trois unités, sans compter que le résultat du XOR peut subir une modification qui dépend de l'unité de prédiction choisie. Autrement dit, la fonction de hachage qui associe une entrée à un branchement dépend de l'unité. [[File:Unité de prédiction « e-gskew ».png|centre|vignette|upright=2|Unité de prédiction « e-gskew ».]] Enfin, une dernière solution est que certaines unités de prédiction ont la priorité sur toutes les autres. Par exemple, prenons un processeur avec un ''return stack predictor'', une unité qui détecte les boucles et une unité de prédiction avec un historique. Si le ''return stack predictor'' détecte une instruction de retour de fonction, sa prédiction passera avant toutes les autres unités de prédiction. Et dans ce qui va suivre, c'est cette solution qui sera utilisé pour les unités de prédiction qu'on va étudier. ===La prédiction des branchements des boucles=== Les boucles sont à part, pour ce qui est de la prédiction de branchement. Rappelons qu'une boucle FOR/WHILE répète une série d'instruction N fois, le nombre N étant appelé le '''compteur de boucle'''. Ce qui fait que les branchements d'une boucle FOR sont pris n − 1 fois, la dernière exécution étant non prise. La conséquence est que les unités de prédiction usuelles sont assez mauvaises pour prédire les branchements des boucles. Par contre, la prédiction statique marche particulièrement bien comparé au reste. La prédiction statique prédit que les branchements des boucles sont toujours pris, ce qui donne des prédictions qui sont d'autant meilleures que la boucle est répétée un grand nombre de fois. Reste à gérer les branchements des boucles au mieux, en sachant cela. L'idée est d'utiliser plusieurs unités de prédiction séparées : une spécialisée dans les boucles et d'autres unités de prédiction. Uen unité de méta-prédiction se charge d'activer l'unité de prédiction des boucles dès qu'une boucle est détéctée. Les unités de prédiction modernes essayent de détecter les branchements des boucles afin de les mettre à part. En soi, ce n'est pas compliqué les branchements des boucles sont presque toujours des branchements descendants et réciproquement. De plus, ces branchements sont pris à chaque répétition de la boucle, et ne sont non-pris que quand on quitte la boucle. Le compteur de boucle analyse l'exécution des branchements, pour détecter les branchements des boucles. Un branchement est considéré comme un branchement de boucle si : il est pris N fois, avant de ne pas être pris une fois. Au moment où le branchement n'est plus pris, le ''Loop Predictor'' lui attribue une entrée dédié. L'entrée mémorise mémorise le nombre de fois que ce branchement a été pris, à savoir N, et est associée à un compteur. Le compteur est incrémenté/décrémenté à chaque exécution du branchement, et sa valeur est comparée à la valeur N. Quand le compteur atteint N+1, il est réinitialisé. Il s'agit d'une implémentation simple, mais est visiblement proche de celle utilisée sur le Pentium M, si j'en crois l'article "The Intel Pentium M Processor : Microarchitecture and Performance". Il faut noter que la boucle doit s'exécuter au moins une fois pour être détectée, et seules les exécutions suivantes sont prédites correctement. La boucle doit donc s'exécuter plusieurs fois, ce qui implique qu'elle est elle-même dans une boucle plus grande qu'elle. Une telle unité est donc capable de prédire spécifiquement les branchements de boucles FOR imbriquées, à savoir où une boucle FOR est dans une autre boucle FOR. Concrètement, cela permet de répéter la première boucle FOR plusieurs fois de suite, souvent un grand nombre de fois. Avec les boucles imbriquées, le compteur de boucle peut changer d'une répétition à l'autre, mais ce n'est que rarement le cas. Dans de nombreux cas, le compteur de boucle reste le même d'une répétition à l'autre. Ce qui fait qu'une fois la boucle exécutée une première fois, on peut prédire à la perfection les exécutions suivantes. Pour cela, il suffit de déterminer le compteur de boucle après la première exécution de la boucle, puis de le mémoriser dans un compteur. Lors des exécutions suivantes de la boucle, on compte le nombre de fois que le branchement de la boucle s'exécute : il est prédit comme pris tant que le compteur est différent du compteur de boucle, mais non-pris en cas d'égalité. ===Le prédicteur L-TAGE=== Il est possible d'utiliser plusieurs unités de prédiction, chacune utilisant des historiques de taille différente. Pour donner un exemple simple, on pourrait avoir une unité avec un historique de 2 bits, une autre avec un historique de 3 bits, une autre de 4 bits, etc. Une méta-unité de branchement prédit alors quelle taille d'historique donne les meilleurs résultats. Il est fréquent que les tailles d'historiques utilisées suivent une formule mathématique précise, appelée suite géométrique, ce qui est un mot bien barbare pour dire que l'on passe d'un historique au suivant en multipliant sa taille par une constante bien précise. Par exemple, on pourrait avoir des historiques de taille 2, 4, 8, 16, 32. C'est d'ailleurs le principe derrière les '''unités de branchement TAGE (''TAgged GEometric length predictor'')'''. Elles utilisent plusieurs registres d'historiques, chacun avec sa propre PHT, dont la taille suit une suite géométrique. Avec cette méthode, les branchements qui ont un motif répétitif sont alors parfaitement détectés, peu importe sa taille. Par exemple, un branchement avec un historique dont le cycle est pris, non-pris, non-pris, sera parfaitement prédit avec un historique de 3 bits, 6 bits, ou de 3*n bits, mais pas forcément avec un historique de 4 ou 8 bits. De ce fait, utiliser plusieurs unités de branchement avec plusieurs tailles d'historique fonctionne plutôt bien. Le prédicteur TAGE a été raffiné avec le temps, sur plusieurs points, que je ne vais pas détailler dans ce qui suit. Précisons cependant que les PHT sont structurées en caches, afin d'éliminer les phénomènes d'''aliasing''. L'ajout d'une unité de prédiction pour les boucles, a donné le '''prédicteur L-TAGE''', qui a lui-même été amélioré par la suite. Les unités de branchement TAGE sont les plus utilisées au moment où j'écris ces lignes (2026), et la majorité des processeurs x86 commerciaux utilisent une variante de ce prédicteur, à quelques exceptions près. Le '''prédicteur ITTAGE''' peut être adapté pour prédire des branchements indirects. La seule différence est que chaque table d'historique est associée non seulement à une prédiction pris/non-pris, mais aussi à une adresse de destination. L'idée derrière l'ITTAGE est que l'adresse de destination dépend de l'historique. L'adresse de destination dépend de ce qui se passe avant qu'on exécute le branchement indirect, donc de l'historique global. Il est donc fréquent que des historiques différents donne un résultat différent, que ce soit pour la prédiction pris/non-pris, mais aussi pour l'adresse de destination. L'ITTAGE associe donc une adresse de destination par ''pattern'' prédit. ==Les optimisations de la prédiction de branchements== La prédiction de branchement est complexe et toute économie est bonne à prendre. Pour améliorer les performances, ou simplement améliorer l'utilisation du BTB et des PHT, diverses techniques ont été inventées. Ces techniques marchent quel que soit l'unité de prédiction prise en compte. Elles peuvent s'appliquer aussi bien aux unités basées sur des perceptrons, que sur des unités à deux niveaux ou des unités de prédiction dynamique en général. Ces techniques sont assez variés : certaines profitent du fait que de nombreux branchements sont biaisés, d'autres tentent de réduire les interférences entre branchements sans utiliser l'historique global/local, etc. ===Le filtrage de branchements=== Une première optimisation permet d'économiser l'usage des différentes ressources matérielles, comme la BTB ou les PHT, en les réservant aux branchements non-biaisés. Idéalement, les branchements biaisés peuvent être prédits en utilisant des techniques très simples. D'où l'idée d'utiliser deux unités de prédiction spécialisées : une pour les branchements biaisés et une autre plus complexe. L'unité de prédiction pour les branchements biaisés est une unité de prédiction à 1 bit qui indique si le branchement est pris ou non, ce qui suffit largement pour de tels branchements. Cette technique s'appelle le '''filtrage de branchement''' et son nom est assez parlant. On filtre les branchements biaisés pour éviter qu'ils utilisent des PHT et d'autres ressources qui gagneraient à être utilisées pour des branchements plus difficiles à prédire. Appliquer cette idée demande de reconnaître les branchements fortement biaisés d'une manière ou d'une autre, ce qui est plus facile à dire qu'à faire. Une solution similaire enregistre quels branchements sont biaisés dans le BTB, et utilise cette information pour faire des prédictions. ===L'usage de fonctions de hashage pour indexer les diverses SRAM/tables=== Plus haut, nous avons vu que les unités de prédiction de branchement contiennent des structures qui sont adressées par l'adresse de branchement. Tel est le cas du ''Branch Target Buffer'', de certaines PHT globales ou locales, de la ''Branch History Table'' qui stocke les historiques locaux, ou encore de la SRAM des poids des unités à base de perceptrons. En pratique, ces structures sont adressées non pas par l'adresse de branchement complète, mais pas les bits de poids faible de cette adresse. Cela a pour conséquence l'apparition d'un ''aliasing'' lié au fait que ces structures vont confondre deux branchements pour lesquels les bits de poids faible de l'adresse sont identiques. Il est possible d'utiliser autre chose que les bits de poids faible de l'adresse du branchement, afin de limiter les interférences. Et les possibilités sont multiples. Les possibilités en question s'inspirent des traitements effectués sur les adresses des banques dans les mémoires évoluées. Une première solution serait de faire un XOR entre les bits de poids faible de l'adresse du branchement, et d'autres bits de cette même adresse. Ainsi, deux branchements éloignés en mémoire donneraient des résultats différents, même si leurs bits de poids faible sont identiques. Une autre possibilité serait de diviser l'adresse du branchement par un nombre et de garder le reste. Ce calcul de modulo n'est en soi pas très différent du fait de conserver seulement les bits de poids faible. Conserver les N bits de poids faible consiste en effet à prendre le modulo par 2^N de l'adresse. Ici, l'idée serait de faire un modulo par un nombre P, qui serait un nombre premier proche de 2^N. Le fait que le nombre soit premier limite les cas où deux adresses différentes donneraient le même reste, ce qui réduit l'''aliasing''. Le fait de prendre un nombre proche de 2^N pour une entrée de N bits est que le résultat reste assez proche de ce qu'on obtiendrait en gardant les bits de poids faible, cette dernière étant une solution pas trop mauvaise. D'autres possibilités similaires se basent sur des réductions polynomiales ou d'autres astuces impliquant des nombres premiers. L'efficacité de ces méthodes dépend grandement de la taille de la SRAM/table considérée, des adresses des branchements et de beaucoup d'autres paramètres. La réduction des interférences par telle ou telle méthode dépend aussi de l'unité de prédiction considérée. Les résultats ne sont pas les mêmes selon que l'on parle d'une unité à perceptrons ou d'une unité à deux niveaux ou d'unités à base de compteurs à saturation, ou que l'on parle du BTB. Par contre, toutes les méthodes ne sont pas équivalentes en termes de temps de calcul ou de portes logiques. Autant la première solution avec un XOR ajoute un temps de calcul négligeable et quelques portes logiques, autant les autres méthodes requièrent l'ajout d'un diviseur très lent et gourmand en portes logiques pour calculer des modulos. Elles ne sont généralement pas praticables pour ces raisons, le temps de calcul serait trop élevé. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Le pipeline | prevText=Le pipeline | next=Les optimisations du chargement des instructions | nextText=Les optimisations du chargement des instructions }} </noinclude> jedvk6nw1py6hkhwusnsrwv33kewdlm 774415 774414 2026-10-07T22:38:26Z Mewtow 31375 /* La prédiction avec plusieurs unités de branchement distinctes */ 774415 wikitext text/x-wiki Les branchements sont pris en charge par plusieurs étages du pipeline. Exécuter un branchement demande de savoir deux choses : s'il est pris ou non, quelle est l'adresse de destination. L'adresse de destination est localisée grâce au mode d'adressage du branchement. Avec le mode d'adressage direct, elle est extraite de l'instruction par le séquenceur. Avec l'adressage indirect, elle est lue dans un registre. Avec les branchements relatifs, elle est calculée via un additionneur, qui additionne un décalage au ''program counter''. L'additionneur peut être soit dans l'étage de ''PC Update'', soit dans l'unité de calcul de l'étage d'exécution. Passons maintenant au résultat du branchement, à savoir s'il est pris ou non. Pour savoir si le branchement est pris, il faut distinguer les branchements conditionnels et inconditionnels. Pour rappel, les branchements inconditionnels regroupent les appels de fonction, les simples instructions de saut, et quelques autres. Ils sont toujours pris, ils s'exécutent toujours. À partir du moment où le décodeur a décodé un branchement inconditionnel, il sait que le branchement est pris. Pour les branchements conditionnels, ils sont pris seulement si une condition précise est respectée. On sait s'ils sont exécutés une fois que la condition est calculée par l'unité de calcul, ou par une unité de branchement spécialisée. [[File:Gestion des branchements sur un pipeline.png|centre|vignette|upright=2.0|Gestion des branchements conditionnels sur un pipeline.]] Sur le schéma précédent, les branchements inconditionnels ne sont pas montrés. Pour cela, il faudrait rajouter une connexion du décodeur vers le multiplexeur de branchement. De plus, il faudrait aussi rajouter de quoi retarder l'exécution du branchement seulement une fois que l'adresse est connue. Par exemple, elle est connue un cycle d'horloge plus si l'adresse est lue depuis les registres, comparé à un branchement direct. ==Les branchements et les exceptions imprécises/précises== Un branchement est censé altérer le ''program counter'' immédiatement, en un cycle d'horloge. Mais sur un pipeline, Le branchement fait bien reprendre le processeur au bon endroit, mais avec du retard. La raison est qu'un branchement met plusieurs cycles pour s'exécuter. Il faut plusieurs cycles pour déterminer quelle est l'adresse de destination, temps qui dépend de là où est lue l'adresse. Et on sait si le branchement est pris seulement au niveau du décodeur, voire au niveau de l'unité de calcul. Le branchement doit traverser plusieurs étages avant qu'on connaisse son résultat. Il y a bien 3 à 4 cycles avant cela. Et pendant ce temps de trajet, des instructions seront chargées dans le pipeline, potentiellement à tort. Si le branchement n'est pas pris, ce n'est pas grave : les instructions n'ont pas été chargées à tort. Mais dans le cas contraire, les instructions n'auraient pas dû rentrer dans le pipeline. Le problème est similaire à celui observé avec les exceptions précises/imprécises. La différence est que les branchements sont des instructions machines, pas les exceptions matérielles. Mais le problème des instructions chargées à tort est le même dans les grandes lignes. Une différence est que les branchements sont résolus plus ou moins au même étage, alors que les exceptions peuvent survenir dans tout le pipeline. Une autre différence, bien plus importante, est que les branchements non-pris ne chargent pas d'instructions à tort et qu'il faut faire la différence entre branchement pris et non-pris. Dans la suite, nous parlerons d'instructions fautives pour parler des instructions chargées à tort. Pour corriger ce problème, il existe une solution logicielle. Le compilateur fait suivre les branchements par des instructions qui ne font rien, des NOP (No OPeration) : c'est ce qu'on appelle un '''délai de branchement'''. L'idée est que toutes les instructions chargées à tort sont des NOPs, qui ne font rien. Mais le nombre de NOP à ajouter dépend du pipeline du processeur, ce qui peut poser des problèmes de compatibilité. Mais d'autres solutions, matérielles cette fois, sont utilisées de nos jours. ===L'annulation des instructions au décodage=== Les branchements inconditionnels sont dans un cas plus favorable : ils sont identifiés dans l'unité de décodage et sont toujours pris. D'autres branchements sont dans ce cas : les appels de fonctions, les interruptions logicielles. Avec eux, le décodeur annule les instructions fautives, il les remplace par des instructions NOP. L'étage de décodage sait que les instructions suivant un branchement inconditionnel ont été chargées à tort. Précisément N instructions, avec N le nombre d'étages entre le chargement et le décodage (inclus). Les N instructions suivantes sont tout simplement annulés, elles sont remplacées par des NOPs. L'implémentation demande juste un simple compteur et quelques circuits annexes. Cependant, la technique ne marche pas du tout pour les branchements conditionnels. En effet, on ne sait pas s'ils sont pris ou non à l'étage de décodage. Du moins pas avec un pipeline normal. Mais certains processeurs, basés sur le pipeline RISC classique, calculaient les branchements pendant l'étape de décodage, dans une ALU spécialisée qui travaille en parallèle de l'unité de décodage. Le défaut de cette technique est que l'ALU en question doit lire les registres. Pour rappel, sur ce pipeline, l'étape de décodage effectue la lecture des opérandes dans les registres, en parallèle de l'unité de décodage. Mais ce qu'on n'a pas dit dans le chapitre précédent, c'est qu'elle effectue aussi le calcul des branchements dans une mini-ALU séparée ! Ce faisant, le résultat du branchement est connu dès la fin de l'étape de décodage. ===L'usage du système d'exceptions précises pour les branchements conditionnels=== La solution gère le problème en empêchant les instructions chargées à tort d'enregistrer leurs résultats, afin qu'elles n'aient pas le moindre effet sur l'état du processeur. Le hardware pour les exceptions précises, décrit quelques paragraphes avant, est réutilisé pour cela. Les branchements conditionnels peuvent être pris ou non-pris et on doit tenir compte de ces deux cas. Si le branchement n'est pas pris, les instructions n'ont pas été chargées à tort, elles peuvent enregistrer leur résultat dans les registres. Par contre, ce n'est pas le cas pour les branchements pris, les mécanismes d'exceptions précise doivent alors d'activer. Pour cela, la meilleure solution est que l'unité de calcul génère un indicateur d'exception si le branchement n'est pas pris. L'ALU effectue la comparaison et génère ou non l'indicateur d'exception adéquat suivant le résultat de celle-ci. L'idée est d'utiliser un indicateur d'exception particulier, qui précise qu'un branchement a été pris, signe que des instructions ont été chargées à tort. L'indicateur est propagé dans le pipeline, jusqu'à atteindre l'étage d'enregistrement dans les registres. Cet étage décide s'il faut autoriser ou non les écritures qu'il recoit, pour annuler les instructions chargées à tort. Le ''program counter'' est corrigé immédiatement, simplement en exécutant le branchement. Reste à savoir qui génère cet indicateur d'exception de branchement pris. Diverses optimisations visent à éliminer les instructions chargées à tort, à faire en sorte que des instructions utiles soient chargées. Le chapitre suivant va détailler ces optimisations dans le détail, qui sont regroupées sous le terme de prédiction de branchement. Elles ne fonctionnent pas pour les exceptions précises, seulement les branchements. ===La prédiction de branchement=== Les techniques précédentes ont pour défaut que de nombreux cycles d'horloge sont gâchés à cause des instructions fautives. Pour éviter ce gâchis, les concepteurs de processeurs ont inventé l'''exécution spéculative de branchement'', qui consiste à deviner l'adresse de destination du branchement et charger les bonnes instructions dès le départ. Le but est de ne pas charger d'instruction à tort, en prédisant quelles seront les bonnes instructions à charger. Et cela demande de résoudre trois problèmes : * reconnaître les branchements ; * savoir si un branchement sera exécuté ou non : c'est la '''prédiction de branchement''' ; * dans le cas où un branchement serait exécuté, il faut aussi savoir quelle est l'adresse de destination : c'est la '''prédiction de l'adresse de destination''' d'un branchement, aussi appelée ''branch target prediction''. Pour résoudre le second problème, le processeur contient un circuit qui déterminer si le branchement est pris (on doit brancher vers l'adresse de destination) ou non pris (on poursuit l’exécution du programme immédiatement après le branchement) : c'est l''''unité de prédiction de branchement'''. La prédiction de direction de branchement est elle aussi déléguée à un circuit spécialisé : l’'''unité de prédiction de direction de branchement'''. C'est l'unité de prédiction de branchement qui autorise l'unité de prédiction de destination de branchement à modifier le ''program counter''. Dans certains processeurs, les deux unités sont regroupées dans le même circuit. [[File:Relation entre prédiction de branchement et de direction de branchement.png|centre|vignette|upright=2|Relation entre prédiction de branchement et de direction de branchement.]] : Au lieu de parler d'unité de prédiction de branchement, j'utiliserais parfois le terme '''prédicteur''', sous-entendu ''prédicteur de branchement''. Avant de poursuivre, faisons une remarque très importante. J'ai parlé d'unité de prédiction de branchement, comme s'il n'y en avait qu'une seule. Mais en réalité, n'en avoir qu'une seule est l'exception. La règle est d'avoir plusieurs unités de branchements spécialisées. Un processeur moderne utilise plusieurs prédicteurs séparés, chacun prédisant un type de branchement bien précis. Il y a pas exemple un prédicteur spécialisé pour les boucles, un autre pour les instructions de retour de fonctions, un autre pour les branchements directs, un autre pour les branchements indirects. Les processeurs modernes utilisent une prédiction de branchement à deux niveaux. Un premier prédicteur fournit des résultats rapides et peu précis, un second a des résultats plus précis après quelques cycles d'horloge. Le prédicteur rapide envoit l'adresse prédite au cache d'instruction, pour charger les instructions prédites et mettre à jour le ''program counter''. Le prédicteur lent envoient leurs résultats au décodeur d'instruction, histoire d'annuler des branchements considérés à tort comme pris/non-pris. Il y a donc des prédicteurs rapides, dont le résultat est corrigé par des prédicteurs plus lents. La seconde unité a le dernier mot, elle corrige ou annule les prédictions de la première. Ce qui lui vaut le nom, en anglais, d'''overiding predictor''. La correction se fait simplement en corrigeant le ''program counter'' et en forçant le décodeur à émettre des NOPs pendant quelques cycles, le temps d'éliminer les instructions chargées à tort. Ce détail aura rapidement son importance. ==Les erreurs de prédiction de branchement== Le processeur peut parfaitement se tromper en faisant ses prédictions, ce qui charge des instructions par erreur dans le pipeline. Dans ce cas, on parle d''''erreur de prédiction'''. Les erreurs de prédiction sont corrigées par le système d'exception précises, ou un équivalent. En clair, les instructions chargées à tort se propagent jusqu'à la sortie du pipeline, où leurs résultats sont alors annulés. N'importe quelle technique de gestion des exceptions précise fait le travail, mais les processeurs modernes utilisent un tampon de réordonnancement (''reorder buffer'') qu'on détaillera dans quelques chapitres. Détecter les erreurs de prédiction demande d'ajouter un circuit dans le processeur : l’''unité de vérification de branchement'' (''branch verification unit''). Elle génère un indicateur d'exception, qui est géré par le système d'exception précise. Pour générer l'indicateur, elle compare l'adresse de destination prédite et celle calculée en exécutant le branchement. Pour cela, l'adresse prédite doit être propagée dans le pipeline jusqu’à ce que l'adresse de destination du branchement soit calculée. [[File:Unité de vérification de branchement.png|centre|vignette|upright=2|Unité de vérification de branchement]] ===La pénalité de mauvaise prédiction=== Le temps pour vider le pipeline des instructions fautives dépend du pipeline. La dernière instruction à être chargée dans le pipeline le sera en même temps qu'on détecte l'erreur de prédiction. Il faudra attendre que cette instruction quitte le pipeline, et donc qu'elle traverse tous les étages. Il y a donc un temps d'attente pour collecter les résultats de ces instructions et la décision de ne pas enregistrer leurs résultats dans les registres Le nombre d'instructions chargées à tort dépend de où est détectée l'erreur de prédiction. Plus le résultat du branchement est connu tard, plus le nombre d'instructions chargées à tort sera grand, et plus le nombre de cycles d'horloge gâchés le sera aussi. Le nombre de cycles gâchés s'appelle la '''pénalité de mauvaise prédiction'''. Elle correspond précisément au nombre de cycles/étages entre le premier étage du pipeline, et l'étage où le branchement est résolu (on connait l'adresse à laquelle brancher). Plus celui-ci est éloigné du début du pipeline, plus la pénalité est importante. [[File:MIT 6.823 Computer System Architecture, Fall 2005 - P-3 vs. P-4 Pipelines - 4473425702.jpg|centre|vignette|upright=2.5|Comparaison de la pénalité de mauvaise prédiction entre le Pentium 3 et le Pentium 4 d'Intel.]] Elle est généralement d'autant plus longue que le pipeline lui-même est long. Les concepteurs de processeurs font donc face à un compromis : un pipeline long permet d'exécuter plus d'instructions en parallèle, mais cela augmente aussi la pénalité de mauvaise prédiction. Un pipeline de 10-15 étapes est un bon compromis. Les pipelines plus longs ont besoin d'une prédiction de branchement élaborée pour fonctionner correctement. Plus la prédiction de branchement est performante, plus le pipeline peut être long. Les pénalités de mauvaise prédiction seront plus importantes, mais les mauvaises prédictions seront plus rares, ce qui compense complétement. ===La ''minimal control dependency''=== En utilisant une technique du nom de ''minimal control dependency'', seules les instructions qui dépendent du résultat du branchement sont supprimées du pipeline en cas de mauvaise prédiction, les autres n'étant pas annulées. Sans ''minimal control dependency'', toutes les instructions qui suivent un branchement dans notre pipeline sont annulées. Pourtant, certaines d'entre elles pourraient être utiles. Prenons un exemple : supposons que l'on dispose d'un processeur de 31 étages (un Pentium 4 par exemple). Supposons que l'adresse du branchement est connue au 9éme étage. On fait face à un branchement qui envoie le processeur seulement 6 instructions plus loin. Si toutes les instructions qui suivent le branchement sont supprimées, les instructions en rouge sont celles qui sont chargées incorrectement. On remarque pourtant que certaines instructions chargées sont potentiellement correctes : celles qui suivent le point d'arrivée du branchement. Elles ne le sont pas forcément : il se peut qu'elles aient des dépendances avec les instructions supprimées. Mais si elles n'en ont pas, alors ces instructions auraient dûes être exécutées. Il serait donc plus efficace de les laisser enregistrer leurs résultats au lieu de les ré-exécuter à l’identique un peu plus tard. Ce genre de choses est possible sur les processeurs qui implémentent une technique du nom de ''Minimal Control Dependancy''. En gros, cette technique fait en sorte que seules les instructions qui dépendent du résultat du branchement soient supprimées du pipeline en cas de mauvaise prédiction. [[File:Minimal cntrol dependency.png|centre|vignette|upright=2|Minimal control dependency]] ==La reconnaissance des branchements== Pour prédire des branchements, le processeur doit faire la différence entre branchements et autres instructions, dès l'étage de chargement. Or, un processeur « normal », sans prédiction de branchement, ne peut faire cette différence qu'à l'étage de décodage, et pas avant. Les chercheurs ont donc dû trouver une solution. La première solution se base sur les techniques de prédécodage vues dans le chapitre sur le cache. Pour rappel, ce prédécodage consiste à décoder partiellement les instructions lors de leur chargement dans le cache d'instructions et à mémoriser des informations utiles dans la ligne de cache. Dans le cas des branchements, les circuits de prédécodage peuvent identifier les branchements et mémoriser cette information dans la ligne de cache. Une autre solution mémorise les branchements déjà rencontrés, dans un cache dédié. Ce cache mémorise l'adresse (le ''program counter'') des branchements déjà rencontrés : il s'appelle le '''tampon d’adresse de branchement''' (''branch adress buffer''). Il est intégré à l'unité de prédiction de branchement, mais est alimenté par le décodeur d'instruction. Reconnaitre un branchement demande l'implication du décodeur d'instruction, qui sait si l'instruction qu'il vient de décoder est un branchement ou non. Il peut même savoir si le branchement est conditionnel, inconditionnel, un appel de retour de fonction, etc. Lorsque le décodeur d'instruction reconnait un branchement, il ajoute ce branchement dans le cache Le tapon d'adresse de branchement contient une adresse mémoire par branchement, et éventuellement d'autres informations. Et pour comprendre quelle adresse est mémorisée, il faut faire attention à un détail. Les explications qui vont suivre vont faire intervenir deux adresses. * La première est l''''adresse de destination''' du branchement, à savoir l'adresse à laquelle le processeur doit reprendre son exécution si le branchement est pris. * L'autre adresse est l''''adresse du branchement''' lui-même. Elle correspond à sa position en mémoire RAM. L'adresse du branchement est la valeur du ''program counter'' quand le branchement est chargé dans le cache, alors que l'adresse de destination est celle du ''program Counter'' une fois le branchement exécuté. Il faudra faire bien attention à ne pas confondre les deux adresses dans ce qui suit. L'adresse du branchement est celle qui est utilisée pour la reconnaissance du branchement. Le tampon d'adresse de branchement mémorise, pour chaque branchement, son adresse de branchement (d'où son nom). À chaque cycle d'horloge, l'unité de chargement envoie le ''program counter'' en entrée du tampon d’adresse de branchement. Un succès dans ce cache indique qu'un branchement déjà rencontré est ré-executé, on peut utiliser la prédiction de branchement. Dans le cas contraire, la prédiction de branchement n'est pas utilisée, et l'instruction est chargée normalement. ==La prédiction de l'adresse de destination== La '''prédiction de l'adresse de destination d'un branchement''' détermine l'adresse de destination d'un branchement. Rappelons qu'il existe plusieurs modes d'adressage pour les branchements et que chacun d'entre eux précise l'adresse de destination à sa manière. Suivant le mode d'adressage, l'adresse de destination est soit dans l'instruction elle-même (adressage direct), soit calculée à l’exécution en additionnant un ''offset'' au ''program counter'' (adressage relatif), soit dans un registre du processeur (branchement indirect), soit précisée de manière implicite (retour de fonction, adresse au sommet de la pile). : Histoire de simplifier la terminologie, je parlerais parfois de '''prédiction de destination''' pour parler de la ''prédiction de l'adresse de destination d'un branchement''. Pour rappel, les branchements se classent en trois types : * les branchements directs pour lequel l'adresse de destination est toujours la même ; * les branchements indirects où l'adresse de destination est dans un registre et peut changer ; * les instructions de retour de fonction. Les branchements directs sont facilement prévisibles, vu que l'adresse vers laquelle il faut brancher est toujours la même. La prédiction des branchements directs et relatifs se fait globalement de la même manière, ce qui fait que nous ferons la confusion dans ce qui suit. Pour les instructions de retour de fonction, une prédiction parfaite est possible. Et c'est ce qui explique qu'elles sont traitées à part, par une unité de branchement dédiée. Pour les branchements indirects, vu que cette adresse change, la prédire celle-ci est particulièrement compliqué (quand c'est possible). Les branchements indirects sont eux aussi traités par une unité de prédiction dédiée rien que pour eux. ===La prédiction de destination pour les branchements directs=== Lorsqu'un branchement est exécuté, on mémoriser l'adresse de destination et la réutiliser lors d'exécutions ultérieures du branchement. Cette technique marche à la perfection pour les branchements directs, pour lesquels cette adresse est toujours la même, mais donne des résultats mitigés pour les branchements indirects. Pour mémoriser l'adresse de destination, on utilise un cache qui mémorise les correspondances entre l'adresse du branchement et l'adresse de destination. Ce cache est appelé le '''''branch target buffer''''' en anglais, qui sera abrévié en BTB dans ce qui suit. Le BTB est une amélioration du tampon d'adresse de branchement vu plus haut, auquel on aurait ajouté les adresses de destination des branchements. Le BTB est utilisé comme suit : on envoie en entrée l'adresse du branchement, le BTB répond au cycle suivant en précisant : s’il reconnaît l'adresse d'entrée, et quelle est l'adresse de destination si c'est le cas. Le BTB peut être un cache totalement associatif, associatif par voie, ou directement adressé, mais ces derniers sont les plus fréquents. Les BTB sont généralement des caches de type directement adressés, où seuls les bits de poids faible de l'adresse du branchement adressent le cache, le reste de l'adresse est tout simplement ignoré. Il existe aussi des BTB qui sont construits comme les caches associatifs à plusieurs voies, mais ceux-ci impliquent généralement la présence de ''tags'', ce qui fait qu'ils sont un peu moins fréquents. [[File:Branch target buffer directement adressé.png|centre|vignette|upright=2|Branch target buffer directement adressé.]] Comme pour tous les caches, un accès au BTB peut entraîner un défaut de cache, c’est-à-dire qu'un branchement censé être dans le BTB n'y est pas. Comme pour les autres caches, les défauts de cache peuvent se classer en trois types : les défauts de cache à froid (''cold miss'') causés par la première exécution d'un branchement, les défauts liés à la capacité du BTB/cache, et les défauts par conflit d'accès au cache. Les défauts liés à la capacité du BTB sont les plus simples à comprendre. La capacité limitée du BTB fait que d'anciens branchements sont éliminées pour laisser la place à de nouveaux. En conséquence, ces branchements peuvent donner des erreurs de prédiction si leur correspondance a été éliminée du cache. On peut réduire des défauts en augmentant la taille du BTB. Les défauts liés aux conflits d'accès au BTB sont eux beaucoup plus intéressants, car la conception du BTB est assez spéciale dans la manière dont sont gérés ce genre de défauts. Le BTB est un cache partiellement associatif, ce qui fait que des défauts de cache par conflit d'accès peuvent avoir lieu. Pour rappel, les défauts par conflit d'accès ont lieu quand deux adresses mémoires se voient attribuer la même ligne de cache. Pour un BTB, cela correspond au cas où deux branchements se voient attribuer la même entrée dans le BTB, ce qui porte le nom d’''aliasing''. Ne pas détecter ce genre de situation sur un cache normal entraînerait des problèmes : la donnée lue/écrite ne serait pas forcément la bonne. Les caches normaux utilisent un système de tags pour éviter ce problème et on s'attendrait à ce que les BTB fassent de même. Mais pour un BTB, l'absence de ''tags'' n'est pas un problème : la seule conséquence est une augmentation du taux de mauvaises prédictions, dont les conséquences en termes de performance peuvent être facilement compensées. Les BTB peuvent donc se passer de tags, ce qui a de nombreux avantages : le circuit est plus rapide, prend moins de portes logiques, consomme moins d'énergie, etc. Ces économies de portes logiques et de performances peuvent être utilisées pour augmenter la taille du BTB, ce qui compense, voire surcompense les mauvaises prédictions induites par l’''aliasing''. Mais ce n'est pas systématique : d'autres BTBs utilisent des ''tags'' pour améliorer le taux de prédiction. Nous ferons donc la différence entre '''BTB tagués''' et '''BTB non-tagués'''. Des processeurs se passent de BTB. À la place, ils mémorisent les correspondances entre branchement et adresse de destination dans les bits de contrôle du cache d'instructions. Mais nous verrons cela en détail dans le chapitre suivant. Plus haut, j'ai dit qu'un processeur moderne utilisait à la fois un prédicteurs rapide et imprécis, avec un prédicteur lent mais précis. Le prédicteur lent a le dernier mot, il corrige ou annule les prédictions du prédicteur rapide. Ce qui lui vaut le nom, en anglais, d'''overiding predictor''. La correction se fait simplement en corrigeant le ''program counter'' et en forçant le décodeur à émettre des NOPs pendant quelques cycles, le temps d'éliminer les instructions chargées à tort. [[File:Double BTB sur les CPU modernes.png|centre|vignette|upright=2|Double BTB sur les CPU modernes.]] Les deux unités de prédiction utilisent des BTB séparés, de taille différente, au moins deux. Vu que les BTB sont des caches, on les appelle des '''BTB de niveau L1 et L2'''. Par exemple, les processeurs Bulldozer d'AMD disposent de deux BTBs : un BTB L1 de 512 entrées, et un BTB L2 de 5120 entrées. Le BTB de niveau L2 est souvent un cache de victime, qui mémorise les branchements retirés de la BTB de niveau L1. La BTB pour les branchements indirects vient en plus de ces deux BTBs, elle complète souvent la BTB de niveau L2. Le ''return stack predictor'' vient en plus des BTB L1 et L2, mais sa petite taille et sa rapidité font qu'il complète l'unité/BTB de niveau L1. Quelques processeurs ont trois niveaux de BTB, avec un niveau L0, L1 et L2. La BTB L0 aliment le cache d'instruction, alors que la L1 et la L2 préviennent les décodeurs d'instruction. La différence entre la L1 et la L2 est que la L2 met plus de temps à prévenir les décodeurs. Les branchements peuvent donc être annulés/forcés à deux étages de décodage différents. Pour donner quelques exemples, les processeurs Zen d'AMD disposent de plusieurs BTBs, avec des niveaux L0, L1 et L2. Le tableau ci-dessous vous donne la taille et la latence de chaque niveau de BTB. La BTB de niveau L0 alimente le cache d'instruction, la L1 permet d'annuler les instructions au premier cycle de décodage, la BTB L2 annule les instructions vers le milieu/fin du décodage. {|class="wikitable" |- ! rowspan="3" | Zen 2 | 16 || 0 |- | 512 || 1 |- | 7168 || 4 |- ! rowspan="3" | Zen 3 | 16 || 0 |- | 1024 || 1 |- | 6656 + 1536 (branchements indirects)|| 4 |} ===La prédiction de destination pour les branchements indirects=== Un BTB classique peut fonctionner pour les branchements indirects. Son seul défaut est qu'il mémorisera la dernière adresse de destination prise par le branchement. Et cela peut fonctionner pour les branchements indirects fortement biaisés, qui ont presque toujours la même adresse de destination. Mais la majorité des branchements indirects change d'adresse de destination régulièrement. Tous les processeurs commerciaux datant d'avant le Pentium M utilisaient un BTB normal pour prédire les branchements indirects. Les processeurs qui les ont suivi sont capables de prédire l'adresse de destination d'un branchement indirect. , grâce à une '''unité de prédiction des branchements indirects''', séparée de l'unité de prédiction de branchement normale. Elle utilise un BTB amélioré, qui mémorise plusieurs adresses de destination pour un seul branchement, en compagnie d'informations pour prédire laquelle est la bonne. Il est possible d'utiliser un BTB unique, pour tous types de branchements, appelé un '''BTB unifié'''. Un BTB unifié a plusieurs adresse de destination par entrée du BTB. Mais une seule est utilisée pour les branchements directs. Vu que les branchements directs sont majoritaires, cela fait beaucoup d'entrées inutilisées. En conséquence, elle est tout sauf optimale, et n'est presque jamais utilisée en pratique. {|class="wikitable" |+ Branch Target Buffer unifié |- ! Adresse du branchement !! Informations de prédiction !! Adresse de destination !! Seconde adresse de destination |- | 0x54462146 || 11101011 || 0x52495674 || 0x24987461 |- | 0x54142446 || 11101101 || 0x15646956 || x0x4158746 |- | 0x25456542 || 10011101 || 0x52495674 || 0x24157461 |- | 0x15455855 || 11010101 || 0x52485754 || 0x24024511 |- | ... || ... || ... || ... |- | ... || ... || ... || ... |- | 0x54462146 || 1001101 || 0x52495674 || 0x24987461 |} Une seconde solution utilise un BTB séparé, distinct du BTB pour les branchements directs. En séparant les deux, on a un BTB simple qui ne mémorise qu'une adresse de destination par branchement, et un BTB pour branchements indirects avec plusieurs adresses de destination par branchement. [[File:BTB avec prédiction de branchements indirects.png|centre|vignette|upright=2.5|BTB avec prédiction de branchements indirects.]] L'idée est que l'on économise des circuits à nombre d'entrées égales. Par exemple, comparons ce que cela donne avec 64 entrées. D'un côté, on a un BTB à 64 entrées, capable de gérer deux adresse de destination par branchement. En pratique, la majorité des branchements sont des branchements directs, qui n'utiliseraient qu'une seule case mémoire. Les circuits du BTB seraient donc sous-utilisés. De l'autre, on a un BTB à 48 entrées pour branchements directs, et un second BTB avec 16 entrées, capable de mémoriser deux adresses par branchements. Le premier BTB utilise moins de circuits, et est utilisé à pleine capacité, il ne gaspille pas de circuits pour mémoriser des adresses de destination vides. Par contre, on perd un peu en flexibilité. L'organisation précédente marche bien si 1/4 des branchements rencontrés sont des branchements indirects. Mais si le ratio est différent, la performance est légèrement sous-optimale. Attention, cela ne veut pas dire qu'il y aura des entrées vides dans un BTB, non. Simplement, le premier BTB risque de manquer de place pour les branchements directs récents, alors que les branchements indirects seront eux bien prédits, même les anciens. À l'opposé, un BTB unique permet de mémoriser les 64 derniers branchements, peu importe qu'ils soient indirects ou non. En clair : un BTB unique est plus flexible, ce qui donne un gain de performance dans certaines situations. Cependant, cet avantage théorique ne tient pas compte de l'économie en circuits, qui peut être utilisée pour faire grossir le BTB pour branchements directs. Par exemple, à budget à transistor équivalent, on a le choix entre BTB à 64 entrées uniques, et un BTB pour branchements directs de 80 entrées avec un BTB indirect de 16 entrées. Le fait qu'un BTB soit plus gros permet des gains de performances qui compensent la perte de flexibilité. On peut ainsi avoir le meilleur des deux monde, à savoir une économie de circuits et les gains en performance qui en découlent, malgré une petite perte de flexibilité. Une troisième solution utilise un BTB normal, mais réserve plusieurs entrées pour un branchement indirect. Par exemple, un branchement indirect qui a trois adresses de destination prendra trois entrées : une par adresse de destination. Mais quelques difficultés surviennent alors. Comment placer trois copies d'un même branchement, alors que ces trois copies ont le même ''program counter'' ? Et que faire pour savoir quelle copie sera prise ? Que faire si l'unité de prédiction de branchement prédit les trois copies comme prises ? La technique du '''''Program Counter'' virtuel''' (''Virtual Program Counter'') résoud ces problèmes d'une manière assez élégante. L'idée est la même que précédemment, à savoir qu'il y a une copie du branchement par adresse de destination, dans le BTB. Mais ces copies n'ont pas exactement le même ''Program counter''. Chaque copie se voit attribuer un ''Program Counter'' virtuel. Et comprendre pourquoi demande de comprendre l'idée sous-jacente. La technique a été inspirée par une optimisation des compilateurs, qui transforme un branchement indirects en une séquence de branchements conditionnels. Par exemple, un branchement indirects qui peut brancher vers les adresses A, B et C est transformé en trois branchements conditionnels : un qui branche vers A, un autre qui branche vers B et un autre vers C. Elle ne marche pas pour tous les branchements indirects, elle demande des circonstances particulières pour fonctionner. Mais l'idée est adaptée pour le ''design'' de l'unité de prédiction de branchement. L'idée est qu'un branchement indirect est déroulé en plusieurs branchements dans le BTB. Les branchements sont traités en séquence : l'unité de prédiction prédit si le premier branchement est pris ou non. S'il est pris, son adresse de destination est envoyée au cache d'instruction. S'il n'est pas pris, l'unité de prédiction tester la seconde copie du branchement, et rebelote. Il teste toutes les copies, une par une, et s’arrête quand il tombe sur un branchement pris. Si aucune copie n'est prise, alors le branchement est prédit comme non-pris. Première remarque : il s'agit d'un processus itératif, où on test un branchement à la fois. Et oui, c'est une prédiction par cycle d'horloge. Prédire un branchement indirect peut donc demander plusieurs cycles d'horloge. Mais ce n'est pas un problème, car le prédicteur peut servir d'''overriding predictor'', d'unité de prédiction lente mais précise. La prédiction peut être utilisée par l'unité de décodage, pour annuler les instructions chargées à tord. Deuxième remarque : comment passer d'un branchement à l'autre ? La première copie a un ''program counter'' normal, qui correspond à l'adresse du branchement. La seconde a un ''program counter'' qui est calculé à partir de cette première adresse, la troisième copie a une adresse calculée à partir de la seconde, etc. Concrètement, n'importe quelle fonction un tant soit peu simple suffit, tant qu'elle est déterministe. L'article qui a proposé cette techniques (''Virtual Program Counter (VPC) Prediction : Very Low Cost Indirect Branch Prediction Using Conditional Branch Prediction Hardware'') se contenait de faire un XOR entre l'adresse précédente et une constante, avec une constante différente par itération. Troisième remarque : l'algorithme demande de faire une parenthèse dans le fonctionnement normal du BTB. Quand un branchement indirect est détecté, il lance une succession de prédiction pour prédire un branchement. L'incrémentation du ''program counter'' normale cesse pendant ce processus. Et cela ne doit être fait que quand un branchement indirect est détecté. Pour cela, le BTB mémorise le type de chaque branchement, sans l'entrée du BTBT : est-ce qu'il est conditionnel, inconditionnel, mais aussi direct ou indirect. Et le type est accesible en sortie du BTB, sur une sortie de quelques bit. Quand l'unité de prédiction remarque qu'un branchement indirect vient de sortir du BTB, alors elle lance le processus. [[File:Virtual program counter.png|centre|vignette|upright=2.5|Virtual program counter]] L'avantage de cette technique est qu'elle s’implémente facilement, son cout en transistors est faible. Elle demande juste d'ajouter quelques circuits autour d'un BTB classique. Elle utilise l'unité de prédiction classique, pas besoin d'une unité de prédiction séparée. Vu qu'il y a un seul BTB, on n'a pas les désavantages d'un BTB séparé pour les branchements indirects. Les entrées du BTB sont réparties entre branchements directs et indirects de manière optimale. Niveau performance, elle est un peu moins bonne que les unités de prédiction ITTAGE qu'on détaillera dans la suite du chapitre. Mais elle est suffisante pour être utilisée sur certains processeurs commerciaux. ===La prédiction de l'adresse de retour d'une fonction=== Presque tous les processeurs conçus après les années 2000 peuvent prévoir l'adresse à laquelle il faudra reprendre lorsqu'une fonction/sous-programme a fini de s’exécuter. Ils font de la '''prédiction de retour de fonction'''. Pour cela, ils possèdent pour une unité de prédiction de branchement spécialisée, appelée le ''return stack predictor'', ou '''''pile de prédiction de retour de fonction'''''. Le terme ''return stack predictor'' étant plus court, nous l'utiliserons dans la suite du cours. Le ''return stack predictor'' est capable de reconnaitre les instructions de retour de fonction, et de prédire l'adresse de retour. Pour comprendre comment elle fait, rappelez-vous le chapitre sur la pile d'appel. Dans ce chapitre, j'avais introduit le concept de ''pile d'adresse de retour'', à savoir une pile d'appel très simplifiée, qui ne contient que les adresses de retour des fonctions en cours d'exécution. Le ''return stack predictor'' est essentiellement composé d'une telle pile d'adresse de retour, avec des circuits pour empiler et dépiler les adresses de retour. Le ''return stack predictor'' doit reconstruire une pile d'adresses de retour, à partir du programme exécuté. La pile d'adresse est mémorisée dans le ''return stack predictor'', elle est totalement séparée de la pile d'appel normale. Le programme exécuté continue d'utiliser la même pile d'appel qu'avant, ce qui fait que les adresses de retour sont dispersées dans la pile d'appel, parfois dans des registres spéciaux du processeur. Mais cela ne pose pas de problèmes pour le processeur, qui reconstruit la pile d'adresse de retour avec l'aide du décodeur. La reconstruction se fait à partir des instructions d'appel et de retour de fonction. Une instruction d'appel de fonction empile une adresse de retour, une instruction de retour de fonction la dépile. Lorsque le décodeur d'instruction reconnait une instruction d'appel de fonction, il extrait l'adresse de retour et l'envoie au ''return stack predictor''. L'adresse de retour est alors empilée, ce qui fait qu'elle se retrouve au sommet de la pile d'adresse de retour. Dépiler une adresse de retour revient à prédire un branchement. Lorsque le ''return stack predictor'' détecte une instruction de retour de fonction, il dépile l'adresse au sommet de la pile. L'adresse est alors envoyée du cache d'instruction, pour l'ire l'instruction adéquate, et le ''program counter'' est mis à jour avec cette adresse. Toute la difficulté revient à détecter les instructions de retour de fonction, sans quoi on ne sait pas quand dépiler l'adresse. La documentation là-dessus n'est pas très fournie, il est difficile de savoir ce qui est fait dans les processeurs modernes. Mais on peut faire quelques suppositions. La première possibilité est l'usage du ''prédécodage'', une technique qui décode partiellement les instructions lors de leur chargement dans le cache d'instruction. Elle sera vue dans le chapitre suivant, mais parlons-en rapidement. Le prédécodage ajoute quelques bits au début de chaque instruction pour préciser si c'est un branchement conditionnel, inconditionnel, un retour de fonction. On peut ainsi savoir rapidement quand dépiler : dès qu'une instruction est lue dans le cache d'instruction, le ''return stack predictor'' vérifie les bits ajoutés et agit en fonction. La seconde possibilité est que le BTB reconnait l'instruction de retour de fonction. Pour chaque branchement, il mémorise non seulement son adresse de retour, mais aussi son type. Par type, on veut dire : si c'est un branchement conditionnel, inconditionnel, un retour de fonction. Les retours de fonction sont donc reconnus à condition qu'ils aient été exécutés auparavant. Il est aussi possible d'avoir un BTB spécialisé dans les retours de fonction, qui est alimenté par les décodeurs. Dès que le décodeur reconnait une instruction de retour de fonction, il ajoute son ''program counter'' dans ce BTB spécialisé. Une dernière possibilité est que le ''return stack predictor'' agit une fois l'instruction partiellement décodée. Le décodeur d'instruction reconnait les instructions de retour de fonction rapidement, en un cycle, et consulte alors le ''return stack predictor''. L'adresse est dépilée, et le décodeur sait où brancher. Il n'a pas besoin de devoir lire l'adresse de retour en mémoire, il la lit depuis le ''return stack predictor'', directement. La prédiction se fait donc assez tard dans le pipeline, quelques instructions ont été chargées à tort. Mais le décodeur peut les annuler, comme il le ferait avec un branchement inconditionnel mal prédit. ==La prédiction de branchement locale== La prédiction de branchement tente de prédire si un branchement sera pris ou non-pris, et décide quelle adresse envoyer au cache en fonction du résultat. Si on prédit qu'un branchement est non pris, on laisse le ''program counter'' faire son travail. À l'inverse si le branchement est prédit comme étant pris, l'adresse de destination est envoyée au cache d'instruction et au ''program counter''. La prédiction de branchement assigne une probabilité qu'un soit branchement soit pris en fonction de ces informations : si la probabilité est de plus de 50%, le branchement est considéré comme pris. La prédiction de branchement se base avant tout sur des statistiques, c’est-à-dire qu'elle détermine la probabilité d’exécution d'un branchement en fonction d'informations assez diverses. La prédiction de branchement n'a d'intérêt que si les prédictions sont suffisamment fiables pour valoir le coup. Rappelons que la pénalité lors d'une mauvaise prédiction est importante, d'autant plus coûteuse que le pipeline est long. Il est généralement admis qu'il faut au minimum un taux de réussite proche de 90%, voire plus sur les processeurs modernes. Heureusement, la plupart des branchements sont des '''branchements biaisés''', c’est-à-dire qu'ils sont presque toujours pris ou presque toujours non-pris, sauf en de rares occasions. De tels branchements font que la prédiction des branchements est facile, bien qu'ils posent paradoxalement quelques problèmes, comme nous le verrons plus bas. Un autre point important est que les unités de prédiction de branchement doivent être très rapides. Idéalement, elles doivent fournir leur prédiction en un seul cycle d'horloge. Un seul cycle d'horloge est un temps très court, surtout sur les ordinateurs avec une fréquence élevée. Les techniques de prédiction dynamique ne peuvent donc pas utiliser des méthodes statistiques extraordinairement complexes. Cela va à l'encontre du fait que le taux de mauvaises prédictions doit être très faible, idéalement inférieur à 1% sur les processeurs modernes. Vous comprenez donc aisément que concevoir une unité de prédiction de branchement est un véritable défi d’ingénierie électronique qui requiert des prouesses technologiques sans précédent. Suivant la nature des informations utilisées, on peut distinguer plusieurs types de prédiction de branchement : locale, globale, dynamique, statique, etc. Nous allons d'abord voir les méthodes statiques avant de voir les méthodes dynamiques. La '''prédiction locale''' prédit chaque branchement indépendamment les uns des autres. Elle sépare bien les statistiques/moyennes pour chaque branchement. Et c'est autant un point fort qu'un point faible. En effet, elle n'exploite pas les corrélations entre branchements, à savoir le fait que l'exécution d'un branchement a tendance à déborder sur les suivants. Dans ce qui suit, nous allons voir les techniques de prédiction locales. ===Les ''branch hints''=== Dans son implémentation la plus explicite, la prédiction est inscrite dans le branchement lui-même. Quelques bits de l'opcode du branchement précisent si le branchement est majoritairement pris ou non pris. Ils permettent d'influencer les règles de prédiction de branchement et de passer outre les réglages par défaut. Ces bits sont appelés des '''suggestions de branchement''' (''branch hint''). Mais tous les processeurs ne gèrent pas cette fonctionnalité. Et de plus, cette solution marche assez mal. La raison est que le programmeur ou le compilateur doit déterminer si le branchement est souvent pris ou non-pris, mais qu'il n'a pas de moyen réellement fiable pour cela. Une solution possible est d’exécuter le programme sur un ensemble de données réalistes et d'analyser le comportement de chaque branchement, afin de déterminer les suggestions de branchement adéquates, mais c'est une solution lourde et peu pratique, qui a de bonnes chances de donner des résultats peu reproductibles d'une exécution à l'autre. ===La prédiction statique de branchement=== La '''prédiction statique''' prédit le résultat du branchement en fonction de caractéristiques fixes du branchement lui-même, comme son adresse, son mode d'adressage, l'adresse de destination, la position du branchement en mémoire, si le branchement est direct ou indirect, etc. Les informations en question sont connues une fois le branchement décodé, il n'y a pas besoin de mémoriser la moindre information supplémentaire. Une première implémentation, assez évidente, fait la différence entre les branchements inconditionnels toujours pris, et les branchements conditionnels au résultat variable. L'idée est de prédire les branchements inconditionnels comme toujours pris alors que les branchements conditionnels sont prédits comme non-pris (ce qui est une approximation). L'implémentation est relativement simple : on utilise un BTB qui ne mémorise que les branchements inconditionnels, et basta ! Les branchements conditionnels sont simplement ignorés, traités comme n'importe quelle autre instruction. Cette méthode est particulièrement inefficace pour les branchements de boucle, où la condition est toujours vraie, sauf en sortie de boucle ! Il a donc fallu affiner légèrement l'algorithme de prédiction statique. Une implémentation plus subtile fait la différence entre les branchements des boucles et les autres. Reste à identifier ceux-ci d'une manière ou d'une autre. Et un bon moyen est de regarder si le branchement revient en arrière ou au contraire saute une portion de code. Un programme s'exécute des adresses basses vers les adresses hautes, les instructions vont naturellement dans le sens croissant des adresses. Un branchement de boucle fait revenir en arrière, histoire de répéter un morceau de code, pas les autres branchements (du moins pas forcément). Et cela nous amène à faire la distinction entre les branchements conditionnels ascendants et descendants. Un '''branchement conditionnel ascendant''' est un branchement qui demande au processeur de reprendre plus loin dans la mémoire : l'adresse de destination est supérieure à l'adresse du branchement. Un branchement '''conditionnel descendant''' a une adresse de destination inférieure à l'adresse du branchement : le branchement demande au processeur de reprendre en arrière dans la mémoire, avant le branchement. Les branchements ascendants sont rarement pris (ils servent dans les conditions de type SI…ALORS), contrairement aux branchements descendants (qui servent à fabriquer des boucles). On peut ainsi modifier l’algorithme de prédiction statique comme suit : * les branchements inconditionnels sont toujours pris ; * les branchements descendants sont toujours pris ; * les branchements ascendants ne sont jamais pris. L'implémentation demande d'élargir le BTB. Le BTB n'a pas seulement à mémoriser l'adresse du branchement et son adresse de destination, mais aussi le type du branchement. En général, il se contente de deux bits, qui indiquent si le branchement est conditionnel, inconditionnel, ou un retour de fonction. Les retours de fonction sont traités à part, histoire de profiter du ''return stack predictor''. {|class="wikitable" |+ BTB avec prédiction de branchement statique évoluée |- ! Adresse du branchement !! Adresse de destination !! Type du branchement |- | 0x4165485 || 0x14657445541 | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Retour de fonction |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... || ... |} ===La prédiction avec des compteurs à saturation=== Les méthodes de prédiction de branchement statique sont intéressantes, mais elles montrent rapidement leurs limites. Pour plus de performances, il faut utiliser la '''prédiction dynamique''', qui extrait des statistiques sur l’exécution des branchements. Pour le reste du chapitre, toutes les techniques utilisent la prédiction dynamique, qu'elles soient locales ou globales. Sa version la plus simple se contente de mémoriser ce qui s'est passé lors de l’exécution précédente du branchement. Si le branchement a été pris, alors on suppose qu'il sera pris la prochaine fois. De même, s'il n'a pas été pris, on suppose qu'il ne le sera pas lors de sa prochaine exécution. Pour cela, chaque entrée du BTB contient un bit qui mémorise la dernière exécution du branchement : 1 s'il a été pris, 0 s'il n'a pas été pris. C'est ce qu'on appelle la '''prédiction de branchements sur 1 bit'''. Au vu de sa simplicité, il ne faut pas s'attendre à des miracles. La méthode fonctionne bien pour les branchements des boucles et les branchements inconditionnels, mais elle n'est pas assez performante. Une forme plus élaborée de prédiction utilise des '''compteurs à saturation'''. Un compteur à saturation est un compteur qui est incrémenté à chaque fois qu'un branchement est pris, décrémenté s'il est non-pris. Le compteur est initialisé à la moitié de sa valeur maximale, ce qui correspond à une probabilité proche de 50%. Les compteurs à saturation sont très utilisés et pas seulement pour de la prédiction locale, les méthodes globales en utilisent aussi. Pour faire la prédiction, l'unité de prédiction regarde le bit de poids fort du compteur. Le branchement est considéré comme pris si ce bit de poids fort est à 1, et non pris s'il vaut 0. En clair : on considère que le branchement est pris si le compteur indique une probabilité supérieure à 50%. [[File:Compteur à saturation.png|centre|vignette|upright=2|Compteur à saturation.]] : Pour la culture générale : le compteur à saturation du Pentium 1 était légèrement bogué, ce qui fait que sa prédiction de branchement était sous-optimale. La plupart des processeurs utilisent un compteur à saturation par branchement, ce qui veut dire un compteur à saturation par entrée dans le BTB. Vu chaque compteur est associé à une entrée du BTB, le phénomène d’''aliasing'' mentionné plus haut peut perturber les prédictions de branchement. Deux branchements peuvent se voir associés à la même entrée du BTB, et agir sur le même compteur à saturation, ce qui entraine l'apparition d''''interférences entre branchements'''. L'interférence peut avoir des effets positifs, neutres ou négatifs, suivant la corrélation entre les deux branchements. L’''aliasing'' n'est pas un problème si les deux branchements sont fortement corrélés, par exemple si les deux sont tout le temps pris ou tout le temps non-pris. Si les deux branchements vont dans le même sens, l'interférence est neutre, voire légèrement positive. Mais si l'un est souvent pris et l’autre souvent non-pris, l'interférence est négative. Les deux branchements vont se marcher dessus sans vergogne, chacun interférant sur les prédictions de l'autre ! Il est rare que l’''aliasing'' ait un impact significatif sur les unités de prédiction des deux paragraphes précédents. Il se manifeste surtout quand la BTB est très petite et la seule solution est d'augmenter la taille du BTB. ===La prédiction avec un historique local=== La prédiction avec des compteurs à saturation est très simple, mais il est possible de créer une implémentation alternative, très proche, avec des avantages et désavantages différents. Un compteur à saturation enregistre l'historique d'un branchement, à savoir ses dernières exécutions. L''''historique''' d'un branchement dit quel a été son résultat dans le passé, pour ses exécutions précédentes. Et il est possible d'enregistrer cet historique et l'utiliser pour faire des prédictions. Par exemple, prenons un branchement sont ses 6 dernières exécutions sont "pris, non-pris, pris, pris, pris, non-pris". Le processeur peut encoder cet historique sur 6 bits. Un branchement pris correspond à un bit à 1, un branchement non-pris correspond à un 0. Le bit de poids fort correspond à l'exécution la plus ancienne, le bit de poids faible à l'exécution la plus récente. Par exemple, si l'historique contient 010, cela veut dire que le branchement a été non pris, puis pris, puis non pris. L'historique d'un branchement est appelé son '''historique local''', mais nous parlerons simplement l'historique. Le processeur peut mémoriser l'historique de chaque branchement. Pour cela, il réserve n bits par branchements, pour mémoriser ses n exécutions précédentes. L'historique d'un branchement est mémorisé dans son entrée du BTB. Et plus précisément dans un champ séparé qui est alimentée comme un registre à décalage. Reste à exploiter cet historique local pour faire des prédictions. Une idée assez simple serait de l'utiliser pour calculer une moyenne des N exécutions précédentes. Si la probabilité d'être pris est supérieure à 50 %, le branchement est considéré comme pris. Dans le cas contraire, il est considéré comme non pris. Le calcul de la moyenne utilise une simple porte à majorité, qui prend en entrée tous les bits de l'historique. Cependant, cette méthode est équivalente à utiliser un compteur à saturation par branchement. La sortie de la porte à majorité est équivalente à ce qu'on aurait dans un compteur à saturation. La seule différence : le cout en circuit et la simplicité de conception. Prenons l'exemple des premiers processeurs PA RISC. Ils mémorisaient, pour chaque branchement, les trois dernières exécutions du branchement, avec trois bits par entrée du BTB. La prédiction était réalisée avec une porte à majorité, dont le résultat était utilisé comme prédiction. Ces processeurs ont préféré utiliser un historique de 3 bits, car il était plus simple à concevoir qu'un compteur à saturation équivalent de deux bits. Il suffisait d'alimenter un registre à décalage avec un bit, pas besoin d'incrémenter ou de décrémenter un compteur, pas besoin de signaux d'incrémentation/décrémentation séparés, etc. Mais pour des historiques plus longs, la donne s'inverse et des compteurs à saturation de deux-trois bits l’emportent. Un défaut de la méthode précédente, qui est partagée avec les compteurs à saturation, est qu'elle marche mal pour les branchements qui suivent un ''pattern''. Je veux dire par là que l'exécution d'un branchement suit un cycle, par exemple pris, pris, non-pris, pris, pris, non-pris, et ainsi de suite en boucle. Notons que l'apparition de tels motifs correspond le plus souvent à la présence de boucles dans le programme. Quand une boucle s’exécute, les branchements qui sont à l'intérieur tendent à exprimer de tels motifs. Avec de tels motifs, les compteurs à saturation feront des prédictions incorrectes, là où les prédicteurs qu'on va voir font des prédictions parfaites. Par exemple, prenons un branchement qui suit le ''pattern'' pris, pris, pris, non-pris. Si l'historique local vaut 1110, le branchement est censé refaire une boucle et être pris. Et certes, on a supposé que l'historique faisait 4 bits. Mais le''pattern'' sera visible tant que l'historique fait plus de 4 bits. Par exemple, le ''pattern'' serait 01110 pour un historique local de 5 bits, 101110 pour un historique local de 6 bits, 1101110 pour un historique local de 7 bits, etc. Un ''pattern'' correspond donc à une valeur bien précise de l'historique local. L'historique local permet de voir ces cycles, reste à les exploiter. Prédire correctement les ''pattern'' demande simplement d'associer un compteur à saturation à chaque valeur possible de l'historique local. Le compteur à saturation dira, quand tel pattern est rencontré, si le branchement sera pris ou non-pris par la suite. Au total, il faut donc un registre d'historique local de n bits couplé à 2^n compteurs à saturation. Et ce pour chaque branchement possible. Les compteurs à saturation sont regroupés dans une '''''Pattern History Table''''', que nous noterons PHT dans ce qui suit. Le résultat est une '''unité de prédiction à deux niveaux locale''', qui contient plusieurs registres d'historique et tout autant de PHT locales. En tout, il y a une PHT et un historique par branchement, donc par entrée du BTB. Quand un branchement est exécuté, son historique local indique quel compteur à saturation choisir dans la PHT locale. En général, l'historique local est mémorisé dans le BTB, reste à gérer la PHT. [[File:Unité de prédiction à deux niveaux purement locale.png|centre|vignette|upright=2.5|Unité de prédiction à deux niveaux avec des historiques et des PHT locales.]] Une unité de prédiction locale à deux niveaux contient plusieurs PHT, reste à savoir quelle PHT correspond à tel branchement. La correspondance se fait idéalement à partir de l'adresse du branchement, mais c'est là une implémentation idéale, qui demande beaucoup de circuits. Généralement, l'unité de prédiction n'utilise que les bits de poids faible de l'adresse du branchement, ce qui permet de faire un choix correct, mais avec une possibilité d'''aliasing''. Il est aussi possible de séparer l'historique local du BTB. Les historiques locaux sont alors mémorisés dans une mémoire RAM dédiée, appelé la '''''Branch History Table''''', ou encore la '''table des historiques locaux''', que nous noterons BHT dans ce qui suivra. Chaque byte de la BHT contient l'historique local associé à un branchement. Pour récupérer l'historique local voulu, l'adresse du branchement est envoyée à la BHT, sur les entrées d'adresse de la BHT, directement. Seuls les bits de poids faible de l'adresse sont utilisés. L'historique local est ensuite envoyé à la PHT adéquate, choisie en fonction des mêmes bits de poids faible du PC. Un bon moyen pour cela est d'accéder à toutes les PHT en parallèle, mais de sélectionner la bonne avec un multiplexeur, ce dernier étant commandé par les bits de poids faible du PC. [[File:Table des historiques locaux.jpg|centre|vignette|upright=2|Table des historiques locaux]] Un défaut des unités de prédiction purement locales de ce type est leur cout en termes de circuits. Utiliser un registre d'historique et une PHT par branchement a un cout en transistor élevé, avec tous les défauts que ca implique en termes de consommation électrique et thermique. Elles ont aussi des temps de calcul un peu plus important que les unités purement globales, mais pas de manière flagrante. Les gains en performance avec un historique local s'observent surtout pour les branchements qui sont dans des boucles ou pour les branchements utilisés pour concevoir des boucles, alors que l'implémentation globale marche bien pour les if..else (surtout quand ils sont imbriqués). Les deux sont donc complémentaires. Dans les faits, elles sont rarement utilisées seules du fait de leurs défauts, au profit d'unités de prédiction hybrides, qui mélangent historiques et PHT locaux et globaux. ==La prédiction de branchement globale/mixte== Utiliser un historique local avec une PHT est très puissant. Mais le cout en circuit est très important. Cependant, on peut adapter l'idée pour faire de la prédiction globale. La '''prédiction globale''' fait des prédictions holistiques, qui tiennent compte de tous les branchements, sans les traiter séparément. Elle ne sépare pas les branchements, tous sont mis dans le même panier. La prédiction globale fait donc le compromis inverse : elle exploite les corrélations entre branchements, mais perd les informations statistiques précise pour chaque branchement. L'idée est qu'au lieu d'utiliser un historique par branchement, on utilise un '''historique global''' qui mémorise les exécutions de tous les branchements conditionnels. L'historique enregistre les N exécutions de branchements conditionnels. L'historique global est mémorisé dans un registre unique, appelé le '''registre d'historique global'''. C'est un registre à décalage mis à jour à chaque fois que le processeur exécute un branchement : on fait rentrer un 1 si le branchement est pris, et un 0 sinon. Une unité de prédiction globale utilise cet historique pour faire sa prédiction. L'unité de prédiction globale utilise un compteur à saturation pour chaque valeur possible du registre d'historique. En clair, pour un registre de N bits, elle utilise 2^n compteurs à saturation. Là encore, les compteurs à saturation sont regroupés dans une ''Pattern History Table''. Nous parlerons de '''PHT globale''' pour désigner une PHT commandée par un historique global, de ''PHT locale'' si elle est adressée par un historique local. [[File:Prédiction dynamique à deux niveaux.png|centre|vignette|upright=2|Prédiction dynamique à deux niveaux.]] Il est admis que l'historique global donne de meilleures prédictions que l'historique local. Mais les deux sont complémentaires, ce qui fait qu'il existe des approches hybrides. Les approches hybrides exploitent à la fois les statistiques pour chaque branchement et les corrélations entre branchements. Les prédicteurs hybrides ont de meilleures performances que les prédicteurs purement globaux ou locaux. Avec un historique global, la prédiction tient compte d'éventuelles corrélations entre branchements consécutifs/proches pour faire les prédictions. C'est un avantage pour les applications qui enchaînent des if...else ou qui contiennent beaucoup de if...else imbriqués les uns dans les autres, qui s’exécutent plusieurs fois de suite. Le résultat de chaque if...else dépend généralement des précédents, ce qui rend l'utilisation d'un historique global intéressant. Par contre, ils sont assez mauvais pour le reste des branchements. Mais cette qualité devient un défaut quand elle détecte des corrélations fortuites ou parasites, inutiles pour la prédiction (corrélation n'est pas causalité, même pour prédire des branchements). Du fait de ce défaut, la prédiction globale a besoin de registres d'historiques globaux très larges, de plusieurs centaines de bits au mieux. [[File:2 level branch predictor.svg|centre|vignette|upright=2|Unités de prédiction de branchement à deux niveaux avec une seule PHT.]] Le circuit précédent a cependant un problème : si deux branchements différents s'exécutent et que l'historique est le même, le processeur n'y verra que du feu. Il y a alors une interférence, à savoir que les deux branchements vont agir sur le même compteur à saturation. On se retrouve alors dans une situation conceptuellement identique à l’''aliasing'' dans le BTB ou avec des compteurs à saturation. Sauf que les interférences sont alors beaucoup plus nombreuses et qu'elles sont clairement un problème pour les performances ! Mitiger les interférences devient alors bien plus important qu'avec les unités de prédiction précédentes. Heureusement, si augmenter la taille de la PHT peut mitiger le problème, d'autres solutions sont possibles sur les unités de prédiction globales. Elles sont au nombre de deux : mitiger les interférences liées aux branchements biaisés, ajouter des informations qui discriminent les branchements. Voyons ces solutions dans le détail. ===La mitigation des interférences par usage de l'adresse de branchement=== La première solution pour réduire l'impact de l’''aliasing'' est d'augmenter la taille de la PHT et de l'historique. Plus l'historique et la PHT sont grands, moins l’''aliasing'' est fréquent. Mais avoir des historiques et des PHT de très grande taille n'est pas une mince affaire et le cout en termes de performances et de portes logiques est souvent trop lourd. Une autre solution consiste à utiliser, en plus de l'historique, des informations sur le branchement lui-même pour choisir le compteur à saturation à utiliser. Typiquement, on peut utiliser l'adresse du branchement en plus de l'historique, pour choisir le compteur à saturation adéquat. Pour être précis, on n'utilise que rarement l'adresse complète du branchement, mais seulement les bits de poids faible. La raison est que cela fait moins de bits à utiliser, donc moins de circuits, mais l’''aliasing'' est juste atténué, pas éliminé. Des branchements différents ont beau avoir des adresses différentes, les bits de poids faible de leurs adresses peuvent être identiques. Des confusions sont donc possibles, mais sont assez rares pour que cela suffise en pratique. Une unité de prédiction de ce type est illustrée ci-dessous. Il y a plusieurs PHT séparées, donc plusieurs ensembles de 2^n compteurs à saturation. Le choix de la PHT est réalisé par l'adresse du branchement, alors que le choix du compteur à saturation est réalisé par l'historique. Les compteurs à saturation d'un branchement sont mis à jour seulement quand le branchement s'est chargé pendant que l'historique associé était dans le registre d'historique. Cette méthode a cependant le défaut de gâcher énormément de transistors. [[File:Unité de prédiction de branchement basée sur des compteurs à saturation sélectionnés par l'historique global.jpg|centre|vignette|upright=2|Unité de prédiction de branchement basée sur des compteurs à saturation sélectionnés par l'historique global]] D'autres unités de prédiction fonctionnent sur le même principe que l'unité précédente, sauf que les différentes PHTs sont fusionnées en une seule grosse PHT. C'est ce que font les unités de prédiction '''''gshare''''' et '''''gselect''''', ainsi que leurs dérivées. Avec les unités de prédiction '''''gselect''''', on concatène les bits de poids faible de l'adresse du branchement et l'historique pour obtenir le numéro du compteur à saturation à utiliser. [[File:Prédiction « gselect ».png|centre|vignette|upright=2|Prédiction « gselect ».]] Avec les unités de prédiction '''''gshare''''', on fait un XOR entre le registre d'historique et les bits de poids faible de l'adresse du branchement. Le résultat de ce XOR donne le numéro du compteur à utiliser. Avec cette technique, on utilise une PHT globale et un historique global, mais le registre d'historique est manipulé de manière à limiter l'''aliasing''. [[File:Prédiction « gshare ».png|centre|vignette|upright=2|Prédiction « gshare ».]] Intuitivement, on pourrait croire que les unités de prédiction gselect ont de meilleures performances. C'est vrai que les unités gshare combinent l'adresse du branchement et l'historique d'une manière qui fait perdre un peu d'information (impossible de retrouver l'adresse du branchement et l'historique à partir du résultat du XOR), alors que les unités gselect conservent ces informations. Mais il faut prendre en compte le fait que les deux unités doivent se comparer à PHT identique, de même taille. Prenons par exemple une PHT de 256 compteurs, adressée par 8 bits. Avec une unité gselect, les 8 bits sont utilisés à la fois pour l'historique et pour les bits de poids faible de l'adresse du branchement. Alors qu'avec une unité gshare, on peut utiliser un historique de 8 bits et les 8 bits de poids faible de l'adresse du branchement. L'augmentation de la taille de l'historique et du nombre de bits utilisés fait que l’''aliasing'' est réduit, et cela compense le fait que l'on fait un XOR au lieu d'une concaténation. Les unités précédentes réduisent l'''aliasing'' en segmentant la PHT : telles entrées sont allouées à un branchement, telles entrées à tel autre branchement, etc. Cependant, l'historique local n'est pas utilisé. Mais quitte à allouer des entrées à un branchement, autant en profiter pour leur associer son historique local. Il s'agit alors d'approches hybrides. Par exemple, on peut citer la '''prédiction mixte''' (''alloyed predictor''). Avec celle-ci, la table des compteurs à saturation est adressée avec la concaténation : de bits de l'adresse du branchement, du registre d'historique global et du registre d'historique local adressé par l'adresse du branchement. [[File:Prédiction mixte.jpg|centre|vignette|upright=2|Prédiction mixte.]] Une version optimisée de ce circuit permet d’accéder à la PHT et à la BHT en parallèle. Avec elle les bits de poids faible de l'adresse du branchement sont concaténés avec l'historique global. Le résultat est ensuite envoyé à plusieurs PHT locales distinctes, en parallèle. Les différences PHT envoient leurs résultats à un multiplexeur, commandé par l'historique local, qui sélectionne le résultat adéquat. L'inconvénient de cette mise en œuvre est que le circuit est assez gros, notamment en raison de la présence de plusieurs PHT séparées. [[File:Alloyed predictor optimisé.jpg|centre|vignette|upright=2|''Alloyed predictor'' optimisé]] ===La mitigation des interférences par la structuration en caches des PHTs=== Les techniques précédentes utilisent les bits de poids faible de l'adresse pour sélectionner la PHT adéquate, ou pour sélectionner le compteur dans la PHT. Mais le reste de l'adresse n'est pas utilisé. Cependant, il est théoriquement possible de conserver les bits de poids fort, afin d'identifier le branchement associé à un compteur. Pour cela, chaque compteur à saturation se voit associé à un ''tag'', qui mémorise le reste de l'adresse du branchement associé au compteur, comme pour les mémoires caches. Lorsque l'unité de prédiction démarre une prédiction, elle récupère les bits de poids fort de l'adresse du branchement et compare ceux-ci avec le ''tag''. S’il y a un ''match'', c'est signe que le compteur est bien associé au branchement à prédire. Dans le cas contraire, c'est signe qu'on fait face à une situation d’''aliasing'' et le compteur n'est pas utilisé pour la prédiction. Le résultat transforme la PHT en une sorte de cache assez particulier, qui associe un compteur à saturation à chaque couple adresse-historique. La technique du paragraphe précédent peut être encore améliorée afin de réduire l’''aliasing''. L’''aliasing'' sur une unité de prédiction de branchement est similaire aux conflits d'accès au cache, où deux données/adresses atterrissent dans la même ligne de cache. Une PHT peut formellement être vue comme un cache directement adressé. Une solution pour limiter ces conflits d'accès à de tels, est de les transformer en un cache associatif à n voies. On peut faire la même chose avec les unités de prédiction de branchement, ce qui revient à dupliquer les PHT, de manière à ce que les bits de poids faible de l'adresse se voient attribuer à plusieurs compteurs à saturation, un par branchement possible. Ce faisant, on réduit les interférences entre branchements. Mais cette technique augmente la quantité de circuits utilisés et la consommation d'énergie, ainsi que le temps d'accès, sans compter qu'elle requiert d'utilisation de ''tags'' pour fonctionner à la perfection. Pour les unités de prédiction de branchement, les mesures à ce sujet ne semblent pas montrer un impact réellement important sur les performances, comparé à une organisation de type directement adressé. Il est possible de pousser la logique encore plus loin en ajoutant des caches de victime et d'autres mécanismes courant sur les caches usuels. ===La mitigation des interférences liées aux branchements biaisés=== L’''aliasing'' a un impact négatif quand deux branchements aliasés (qui sont attribués au même compteur à saturation) vont dans des sens différents : si l'un est non-pris, l'autre a de bonnes chances d'être pris (ou inversement). Or, la plupart des branchements sont biaisés, à savoir très fortement orientés dans une direction qu'une autre (presque toujours pris ou presque toujours non-pris, à plus de 90/99% de chances). Plus un branchement a une probabilité élevée d' être pris ou non-pris, plus sa capacité à interférer avec les autres est forte. Le pire des cas est quand deux branchements sont biaisés mais dans des directions inverses : l'interférence est maximale. En conséquence, les branchements biaisés ou quasi biaisés sont les plus problématiques pour l’''aliasing''. Or, il est possible de concevoir des prédicteurs de branchements qui atténuent fortement les interférences des branchements biaisés ou quasi biaisés. C’est le cas de l’''agree predictor'' et de l'unité de prédiction bimodale, que nous allons voir dans ce qui suit. Les branchements très souvent pris vont interférer très fortement avec les branchements très souvent non-pris, et inversement. Par contre, les branchements très souvent pris n'interféreront pas entre eux, de même que les branchements non-pris iront bien ensemble. D'où l'idée de séparer ces deux types de branchements dans des PHT séparées, pour éviter le mauvais ''aliasing''. L''''unité de prédiction bimodale''' part d'une unité gshare, et sépare la PHT globale en deux : une PHT dédiée aux branchements qui sont très souvent pris et une autre pour les branchements très peu pris. Les deux PHT fournissent chacune une prédiction. La bonne prédiction est choisie par un multiplexeur commandé par une unité de sélection, qui se charge de déduire quelle unité a raison. Cette unité de sélection est une unité de prédiction basée sur des compteurs à saturation de 2 bits. On peut encore améliorer l'unité de sélection de prédiction en n'utilisant non pas des compteurs à saturation, mais une unité de prédiction dynamique à deux niveaux, ou toute autre unité vue auparavant. [[File:Prédiction bimodale.jpg|centre|vignette|upright=2|Prédiction bimodale.]] La '''prédiction par consensus''' (''agree predictor'') combine une unité de prédiction à historique global (idéalement de type gshare ou gselect, mais une unité normale marche aussi) et des compteurs à saturation. Il s'agit techniquement d'une technique de prédiction mixte, à la fois globale et locale. Le BTB intègre un compteur à saturation de un bit, qui indique la direction privilégiée du branchement. L'unité à historique global a un fonctionnement changé : elle calcule non pas la probabilité que le branchement soit pris ou non-pris, mais la probabilité que le résultat du branchement soit compatible avec le résultat de l'unité de prédiction de 1 bit. La prédiction finale vérifie que ces deux circuits sont d'accord, en faisant un NXOR entre les résultats des deux unités de prédiction de branchement. [[File:Prédiction par consensus.png|centre|vignette|upright=2|Prédiction par consensus.]] Des calculs simples de probabilités montrent que l'''agree predictor'' a des résultats assez importants. Ses résultats sont d'autant meilleurs que les branchements sont biaisés et penchent fortement vers le côté pris ou non-pris. ==La prédiction de branchement avec des perceptrons== Les méthodes précédentes utilisent au moins un compteur à saturation par historique possible. Sachant qu'il y a 2^n valeurs possibles pour un historique de n bits, le nombre de compteurs à saturation augmente exponentiellement avec la taille de l'historique. Ce qui limite grandement la taille de l'historique, alors qu'on aurait au contraire besoin d'un historique assez large pour faire des prédictions excellentes. Pour contourner le problème, des alternatives basées sur des techniques d'apprentissage artificiel (''machine learning'') existent. Un exemple est celui des unités de prédiction de branchement dites de ''neural branch prediction'', basée sur des perceptrons. Dans ce qui suit, nous les appelerons des '''unités perceptron'''. Les perceptrons sont un algorithme d'apprentissage automatique très simple, ce qui fait que les explications qui vont suivre devraient être simples. En théorie, il est possible d'utiliser des techniques d'apprentissage automatique plus élaborées, mais cela n'est pas possible en pratique. Rappelons qu'une unité de prédiction de branchement doit idéalement fournir sa prédiction en un cycle d'horloge. L'implémentation d'un perceptron est déjà problématique de ce point de vue, car elle demande d'utiliser beaucoup de circuits arithmétiques complexes (multiplication et addition). Autant dire que les autres techniques de ''machine learning'', encore plus gourmandes en calculs, ne sont pas praticables. ===Les perceptrons et leur usage en tant qu'unité de prédiction de branchements=== L'idée derrière les unités perceptron est que certains bits de l'historique sont plus importants que d'autres. En soi, ce genre de situation est réaliste. Il arrive que certains branchements soient pris uniquement si les deux branchements précédents ne le sont pas, ou que si l'avant-dernier branchement est lui aussi pris. Mais les autres résultats de branchement présents dans l'historique ne sont pas utiles. En conséquence, deux historiques semblables, qui ne sont différents que d'un ou deux bits, peuvent donner des prédictions presque identiques. Dans ce cas, on peut faire la prédiction en n'utilisant que les bits identiques entre ces historiques, ou du moins en leur donnant plus d'importance qu'aux autres. On exploite alors des corrélations avec certains bits de l'historique, plutôt qu'avec l'historique entier lui-même. Pour coder ces corrélations, on associe un coefficient à chaque bit de l'historique, qui indique à quel point ce bit est important pour déterminer le résultat final. Ce coefficient est proportionnel à la corrélation entre le bit de l'historique associé, et le résultat du branchement à prédire. Les coefficients sont des nombres entiers qui peuvent être positifs, nuls ou négatifs. Un coefficient positif signifie que si le branchement associé a été pris, alors le branchement à prédire a de bonnes chances de l'être aussi (et inversement). Un coefficient nul signifie que les deux branchements sont indépendants et que le bit de l'historique associé n'est pas à prendre en compte. Pour un coefficient négatif, cela signifie que si le branchement associé a été pris, alors le branchement à prédire a de bonnes chances d'être non-pris (et inversement). Les coefficients en question sont généralement compris entre -1 et 1. Une fois qu'on connait ces coefficients de corrélations, on peut calculer la probabilité que le branchement à prédire soit pris, avec des calculs arithmétiques simples. Dans les calculs qui vont suivre, un branchement pris est codé par un 1, alors qu'un branchement non-pris est codé par un -1 ! Ce détail permet de simplifier grandement les calculs. Dans sa version la plus simple, le perceptron calcule un nombre Z qui est positif ou nul si le branchement est pris, négatif s'il ne l'est pas. Tous les coefficients <math>p_i</math> sont alors des nombres relatifs, pouvant être positifs, nuls ou négatifs. Ils sont généralement compris entre -1 et 1. La formule devient donc celle-ci : : <math>Z = w_0 + \sum_i (h_i \times w_i)</math> [[File:ArtificialNeuronModel francais.png|centre|vignette|upright=2|Perceptron.]] : Sur le plan mathématique, le perceptron effectue un produit scalaire entre deux vecteurs : l'historique et le vecteur des poids. Choisir les coefficients adéquats est le but de l'algorithme du perceptron. L'unité de prédiction part de poids par défaut, qui sont mis à jour à chaque bonne ou mauvaise prédiction. Toute la magie de cet algorithme tient dans la manière dont sont mis à jour les poids. L'idée est de prendre le vecteur des poids, puis d'ajouter ou soustraire l'historique suivant le résultat du branchement. Les poids évoluent ainsi à chaque branchement, améliorant leurs prédictions d'une exécution à l'autre. La règle de mise à jour des poids est donc la suivante : : <math>w_i \leftarrow w_i + t \times h_i</math>, avec t = 1 pour un branchement pris, -1 pour un branchement non-pris. ===L'implémentation matérielle des perceptrons et ses optimisations=== Les calculs réalisés par un perceptrons sont simples, juste des multiplications et des additions et cela se sent dans la conception du circuit. L'unité perceptron utilise un perceptron par entrée du BTB, un par branchement pour simplifier. Pour cela, on utilise une mémoire SRAM qui mémorise les poids de chaque perceptron. La SRAM est adressée par les bits de poids faible du ''program counter'', de l'adresse du branchement. Une fois les poids récupérés, ils sont envoyés à un circuit de calcul de la prédiction, composé de circuits multiplieurs suivis par un additionneur multi-opérande. Le circuit de mise à jour des poids récupère la prédiction, les poids du perceptron, mais aussi le résultat du branchement. La mise à jour étant une simple addition, le circuit est composé d'additionneurs/soustracteurs, couplés à des registres pour mémoriser la prédiction et les poids du perceptron en attendant que le résultat du branchement soit disponible. Vu qu'il y a un délai de quelques cycles avant que le résultat du branchement soit disponible et que les prédictions doivent continuer pendant ce temps, les poids du perceptron et la prédiction sont stockés dans des FIFOs lues/écrites à chaque cycle. [[File:Unité de prédiction de branchement basée sur des perceptrons.png|centre|vignette|upright=2|Unité de prédiction de branchement basée sur des perceptrons]] Rien de bien compliqué sur le principe, mais rappelons que le perceptron doit fournir un résultat en moins d'un cycle d'horloge, et cela tient compte de l'accès au BTB. C'est un temps très court, surtout pour un circuit qui implique des multiplications. Les multiplications prennent facilement deux à trois cycles d'horloge. Autant dire que si on ajoute le temps d'accès à une petite SRAM, pour récupérer le perceptron, c'est presque impossible. Mais quelques optimisations simples permettent de rendre le calcul plus rapide. Premièrement, la taille de la SRAM est réduite grâce à quelques économies assez simples. Notamment, le perceptron utilise des poids codés sur quelques bits, généralement un octet, parfois 7 ou 5 bits, guère plus. Les poids sont encodés en complément à 1 ou en complément à 2, là où les perceptrons normaux utilisent des nombres flottants. Rien que ces deux choix simplifient les circuits et les rendent plus rapides. Le désavantage d'utiliser peu de bits par poids est une perte mineure en termes de taux de prédiction, qui est plus que compensée par l'économie en portes logiques, qui permet d'augmenter la taille de la SRAM. D'autres optimisations portent sur le circuit de calcul. Par exemple, la multiplication <math>h_i \times w_i</math> est facultative. Vu que l'historique contient les valeurs 1 et -1, il suffit d'additionner le poids associé si la valeur est 1 et de le soustraire s'il vaut -1. On peut même simplifier la soustraction et se limiter à une complémentation à un (une inversion des bits du poids). En faisant cela, les circuits multiplieurs disparaissent et le circuit de prédiction se résume à un simple additionneur multi-opérande couplé à quelques inverseurs commandables. Certains perceptrons prennent en compte à la fois l'historique global et l'historique local pour faire leur prédiction. Les deux historiques sont simplement concaténés et envoyés en entrée du perceptron. Cela marche assez bien, car les perceptrons peuvent utiliser des historiques de grande taille sans problèmes. Par contre, il y a besoin d'ajouter une ''branch history table'' au circuit, ce qui demande des portes logiques en plus, mais aussi un temps de calcul supplémentaire (l'accès à cette table n'est pas gratuit). ===Les avantages et inconvénients des perceptrons pour la prédiction de branchements=== L'avantage des unités perceptron est qu'elles utilisent un nombre de portes logiques qui est proportionnel à la taille de l'historique, et non pas exponentiel. Cela permet d'utiliser un historique de grande taille, et donc d'obtenir un taux de bonnes prédictions très élevé. Leur désavantage est que leurs performances de prédiction sont moins bonnes à historique équivalent. Les unités perceptrons ont des taux de prédiction élevés, mais seulement quand certaines conditions mathématiques sont respectées par les historiques. La condition en question est que les historiques correspondants à un branchement pris et les historiques où ce même branchement est non-pris doivent être linéairement séparables. Heureusement, les branchements des programmes informatiques sont une majorité à respecter la condition de séparation linéaire, ce qui rend les perceptrons parfaitement adaptés à la prédiction de branchements. La notion de linéairement séparable est un peu difficile à comprendre. Pour l'expliquer, imaginez que les N bits de l'historique forme un espace à N-dimensions discrets. Chaque historique est un point dans cet espace N-dimensionnel et chaque bit sert de coordonnée pour se repérer dans cet espace. Une fonction est linéairement séparable si l'espace N-dimensionnel peut être coupé en deux par un hyperplan : d'un côté de ce plan se trouvent les historiques des branchements pris, de l'autre se trouvent les historiques des branchements non-pris. Cet hyperplan est défini par l'ensemble des points qui respecte l'équation suivante : : <math>w_0 + \sum_i (h_i \times w_i) = 0</math> Un exemple où cette condition est respectée est quand le résultat d'une prédiction se calcule avec un ET entre plusieurs branchements de l'historique. Si un branchement est pris quand plusieurs branchements de l’historique sont tous pris ou tous non-pris, la condition est respectée et le perceptron donnera des prédictions correctes. Un exemple où cette condition n'est pas respectée est un banal XOR. Prenons l'exemple d'un historique global de 2 bits. Imaginons que le branchement à prédire soit pris : soit quand le premier branchement de l'historique est pris et le second non-pris, soit quand le premier est non-pris et le second pris. Concrètement, la prédiction exacte se calcule en faisant un XOR entre les deux bits de l'historique. Dans ce cas, la condition "linéairement séparable" n'est pas respectée et le perceptron n'aura pas des performances optimales. Les unités perceptron utilisent idéalement un perceptron par branchement. Cela demande d'associer, l'adresse du branchement pour chaque perceptron, généralement en donnant un perceptron par entrée du BTB. Mais une telle organisation n'est pas possible en pratique, car elle demanderait d'ajouter un ''tag'' à chaque perceptron/entrée du BTB, ce qui demanderait beaucoup de circuits et de ressources matérielles. Dans les faits l'association entre un branchement et un perceptron se fait en utilisant les bits de poids faible de l'adresse de branchement. Mais faire cela entrainera l'apparition de phénomènes d'''aliasing'', comme pour les unités de prédiction à deux niveaux. Les techniques vues précédemment peuvent en théorie être adaptées sur les unités à perceptrons, dans une certaine mesure. Notons que les perceptrons sont des algorithmes ou des circuits qui permettent de classer des données d'entrée en deux types. Ici, la donnée d'entrée est l'historique global et la sortie du perceptron indique si le branchement prédit est pris ou non-pris. Ils ne sont donc pas adaptés à d'autres tâches de prédiction, comme pour le préchargement des lignes de cache ou la prédiction de l'adresse de destination d'un branchement, ou toute autre tâche d'exécution spéculative un tant soit peu complexe. Leur utilisation reste cantonnée à la prédiction de branchement proprement dit, guère plus. Par contre, les autres techniques vues plus haut peuvent être utilisées pour le préchargement des lignes de cache, ou d'autres mécanismes de prédiction plus complexes, que nous aborderons dans la suite du cours. ==La prédiction des branchements des boucles== Les boucles sont à part, pour ce qui est de la prédiction de branchement. Rappelons qu'une boucle FOR/WHILE répète une série d'instruction N fois, le nombre N étant appelé le '''compteur de boucle'''. Ce qui fait que les branchements d'une boucle FOR sont pris n − 1 fois, la dernière exécution étant non prise. La conséquence est que les unités de prédiction usuelles sont assez mauvaises pour prédire les branchements des boucles. Par contre, la prédiction statique marche particulièrement bien comparé au reste. La prédiction statique prédit que les branchements des boucles sont toujours pris, ce qui donne des prédictions qui sont d'autant meilleures que la boucle est répétée un grand nombre de fois. Reste à gérer les branchements des boucles au mieux, en sachant cela. L'idée est d'utiliser plusieurs unités de prédiction séparées : une spécialisée dans les boucles et d'autres unités de prédiction. Uen unité de méta-prédiction se charge d'activer l'unité de prédiction des boucles dès qu'une boucle est détéctée. Les unités de prédiction modernes essayent de détecter les branchements des boucles afin de les mettre à part. En soi, ce n'est pas compliqué les branchements des boucles sont presque toujours des branchements descendants et réciproquement. De plus, ces branchements sont pris à chaque répétition de la boucle, et ne sont non-pris que quand on quitte la boucle. Le compteur de boucle analyse l'exécution des branchements, pour détecter les branchements des boucles. Un branchement est considéré comme un branchement de boucle si : il est pris N fois, avant de ne pas être pris une fois. Au moment où le branchement n'est plus pris, le ''Loop Predictor'' lui attribue une entrée dédié. L'entrée mémorise mémorise le nombre de fois que ce branchement a été pris, à savoir N, et est associée à un compteur. Le compteur est incrémenté/décrémenté à chaque exécution du branchement, et sa valeur est comparée à la valeur N. Quand le compteur atteint N+1, il est réinitialisé. Il s'agit d'une implémentation simple, mais est visiblement proche de celle utilisée sur le Pentium M, si j'en crois l'article "The Intel Pentium M Processor : Microarchitecture and Performance". Il faut noter que la boucle doit s'exécuter au moins une fois pour être détectée, et seules les exécutions suivantes sont prédites correctement. La boucle doit donc s'exécuter plusieurs fois, ce qui implique qu'elle est elle-même dans une boucle plus grande qu'elle. Une telle unité est donc capable de prédire spécifiquement les branchements de boucles FOR imbriquées, à savoir où une boucle FOR est dans une autre boucle FOR. Concrètement, cela permet de répéter la première boucle FOR plusieurs fois de suite, souvent un grand nombre de fois. Avec les boucles imbriquées, le compteur de boucle peut changer d'une répétition à l'autre, mais ce n'est que rarement le cas. Dans de nombreux cas, le compteur de boucle reste le même d'une répétition à l'autre. Ce qui fait qu'une fois la boucle exécutée une première fois, on peut prédire à la perfection les exécutions suivantes. Pour cela, il suffit de déterminer le compteur de boucle après la première exécution de la boucle, puis de le mémoriser dans un compteur. Lors des exécutions suivantes de la boucle, on compte le nombre de fois que le branchement de la boucle s'exécute : il est prédit comme pris tant que le compteur est différent du compteur de boucle, mais non-pris en cas d'égalité. ==La prédiction avec plusieurs unités de branchement distinctes== Il est possible de combiner plusieurs unités de prédiction de branchement différentes et de combiner leurs résultats en une seule prédiction. On peut par exemple combiner une unité de prédiction spécialisée dans les boucles, une unité de prédiction 2 bits basée sur des compteurs à saturation, une unité de prédiction statique et une unité ''gshare''. Tout le problème est de décider quelle unité a fait la bonne prédiction, comment combiner les résultats des différentes unités de prédiction. Soit on choisit un résultat qui parait plus fiable que les autres, soit on combine les résultats pour faire une sorte de moyenne des prédictions. La méthode la plus simple de choisir le résultat d'une unité de prédiction parmi toutes les autres. Pour implémenter le tout, il suffit d'un multiplexeur qui effectue le choix de la prédiction. Reste à commander le multiplexeur, ce qui demande un '''circuit méta-prédicteur''' qui sélectionne la bonne unité de prédiction. Il arrive que le circuit méta-prédicteur soit conçu avec les mêmes techniques que les unités de prédiction de branchement elle-mêmes. Dans le cas le plus simple, il s'agit d'un simple compteur à saturation, mis à jour suivant la concordance des prédictions avec le résultat effectif du branchement. Mais c'est loin d'être systématique. Une autre solution combine le résultat des différentes unités de prédiction avec une fonction mathématique adéquate. Avec un nombre impair d'unités de prédiction, une méthode assez efficace est de simplement prendre le résultat majoritaire. Si une majorité d'unités pense que le branchement est pris, alors on le considère comme pris, et comme non pris dans le cas contraire. Par contre, avec un nombre pair d'unités, cette méthode simple ne marche pas, il y a un risque d'égalité. L'unité de prédiction de branchement « '''''e-gskew''''' » utilise trois unités gshare et combine leurs résultats avec un vote à majorité. Les trois unités gshare utilisent des XOR légèrement différents, dans le sens où les bits de l'adresse du branchement choisi ne sont pas les mêmes dans les trois unités, sans compter que le résultat du XOR peut subir une modification qui dépend de l'unité de prédiction choisie. Autrement dit, la fonction de hachage qui associe une entrée à un branchement dépend de l'unité. [[File:Unité de prédiction « e-gskew ».png|centre|vignette|upright=2|Unité de prédiction « e-gskew ».]] Enfin, une dernière solution est que certaines unités de prédiction ont la priorité sur toutes les autres. Par exemple, prenons un processeur avec un ''return stack predictor'', une unité qui détecte les boucles et une unité de prédiction avec un historique. Si le ''return stack predictor'' détecte une instruction de retour de fonction, sa prédiction passera avant toutes les autres unités de prédiction. Et dans ce qui va suivre, c'est cette solution qui sera utilisé pour les unités de prédiction qu'on va étudier. ===Le prédicteur L-TAGE=== Il est possible d'utiliser plusieurs unités de prédiction, chacune utilisant des historiques de taille différente. Pour donner un exemple simple, on pourrait avoir une unité avec un historique de 2 bits, une autre avec un historique de 3 bits, une autre de 4 bits, etc. Une méta-unité de branchement prédit alors quelle taille d'historique donne les meilleurs résultats. Il est fréquent que les tailles d'historiques utilisées suivent une formule mathématique précise, appelée suite géométrique, ce qui est un mot bien barbare pour dire que l'on passe d'un historique au suivant en multipliant sa taille par une constante bien précise. Par exemple, on pourrait avoir des historiques de taille 2, 4, 8, 16, 32. C'est d'ailleurs le principe derrière les '''unités de branchement TAGE (''TAgged GEometric length predictor'')'''. Elles utilisent plusieurs registres d'historiques, chacun avec sa propre PHT, dont la taille suit une suite géométrique. Avec cette méthode, les branchements qui ont un motif répétitif sont alors parfaitement détectés, peu importe sa taille. Par exemple, un branchement avec un historique dont le cycle est pris, non-pris, non-pris, sera parfaitement prédit avec un historique de 3 bits, 6 bits, ou de 3*n bits, mais pas forcément avec un historique de 4 ou 8 bits. De ce fait, utiliser plusieurs unités de branchement avec plusieurs tailles d'historique fonctionne plutôt bien. Le prédicteur TAGE a été raffiné avec le temps, sur plusieurs points, que je ne vais pas détailler dans ce qui suit. Précisons cependant que les PHT sont structurées en caches, afin d'éliminer les phénomènes d'''aliasing''. L'ajout d'une unité de prédiction pour les boucles, a donné le '''prédicteur L-TAGE''', qui a lui-même été amélioré par la suite. Les unités de branchement TAGE sont les plus utilisées au moment où j'écris ces lignes (2026), et la majorité des processeurs x86 commerciaux utilisent une variante de ce prédicteur, à quelques exceptions près. Le '''prédicteur ITTAGE''' peut être adapté pour prédire des branchements indirects. La seule différence est que chaque table d'historique est associée non seulement à une prédiction pris/non-pris, mais aussi à une adresse de destination. L'idée derrière l'ITTAGE est que l'adresse de destination dépend de l'historique. L'adresse de destination dépend de ce qui se passe avant qu'on exécute le branchement indirect, donc de l'historique global. Il est donc fréquent que des historiques différents donne un résultat différent, que ce soit pour la prédiction pris/non-pris, mais aussi pour l'adresse de destination. L'ITTAGE associe donc une adresse de destination par ''pattern'' prédit. ==Les optimisations de la prédiction de branchements== La prédiction de branchement est complexe et toute économie est bonne à prendre. Pour améliorer les performances, ou simplement améliorer l'utilisation du BTB et des PHT, diverses techniques ont été inventées. Ces techniques marchent quel que soit l'unité de prédiction prise en compte. Elles peuvent s'appliquer aussi bien aux unités basées sur des perceptrons, que sur des unités à deux niveaux ou des unités de prédiction dynamique en général. Ces techniques sont assez variés : certaines profitent du fait que de nombreux branchements sont biaisés, d'autres tentent de réduire les interférences entre branchements sans utiliser l'historique global/local, etc. ===Le filtrage de branchements=== Une première optimisation permet d'économiser l'usage des différentes ressources matérielles, comme la BTB ou les PHT, en les réservant aux branchements non-biaisés. Idéalement, les branchements biaisés peuvent être prédits en utilisant des techniques très simples. D'où l'idée d'utiliser deux unités de prédiction spécialisées : une pour les branchements biaisés et une autre plus complexe. L'unité de prédiction pour les branchements biaisés est une unité de prédiction à 1 bit qui indique si le branchement est pris ou non, ce qui suffit largement pour de tels branchements. Cette technique s'appelle le '''filtrage de branchement''' et son nom est assez parlant. On filtre les branchements biaisés pour éviter qu'ils utilisent des PHT et d'autres ressources qui gagneraient à être utilisées pour des branchements plus difficiles à prédire. Appliquer cette idée demande de reconnaître les branchements fortement biaisés d'une manière ou d'une autre, ce qui est plus facile à dire qu'à faire. Une solution similaire enregistre quels branchements sont biaisés dans le BTB, et utilise cette information pour faire des prédictions. ===L'usage de fonctions de hashage pour indexer les diverses SRAM/tables=== Plus haut, nous avons vu que les unités de prédiction de branchement contiennent des structures qui sont adressées par l'adresse de branchement. Tel est le cas du ''Branch Target Buffer'', de certaines PHT globales ou locales, de la ''Branch History Table'' qui stocke les historiques locaux, ou encore de la SRAM des poids des unités à base de perceptrons. En pratique, ces structures sont adressées non pas par l'adresse de branchement complète, mais pas les bits de poids faible de cette adresse. Cela a pour conséquence l'apparition d'un ''aliasing'' lié au fait que ces structures vont confondre deux branchements pour lesquels les bits de poids faible de l'adresse sont identiques. Il est possible d'utiliser autre chose que les bits de poids faible de l'adresse du branchement, afin de limiter les interférences. Et les possibilités sont multiples. Les possibilités en question s'inspirent des traitements effectués sur les adresses des banques dans les mémoires évoluées. Une première solution serait de faire un XOR entre les bits de poids faible de l'adresse du branchement, et d'autres bits de cette même adresse. Ainsi, deux branchements éloignés en mémoire donneraient des résultats différents, même si leurs bits de poids faible sont identiques. Une autre possibilité serait de diviser l'adresse du branchement par un nombre et de garder le reste. Ce calcul de modulo n'est en soi pas très différent du fait de conserver seulement les bits de poids faible. Conserver les N bits de poids faible consiste en effet à prendre le modulo par 2^N de l'adresse. Ici, l'idée serait de faire un modulo par un nombre P, qui serait un nombre premier proche de 2^N. Le fait que le nombre soit premier limite les cas où deux adresses différentes donneraient le même reste, ce qui réduit l'''aliasing''. Le fait de prendre un nombre proche de 2^N pour une entrée de N bits est que le résultat reste assez proche de ce qu'on obtiendrait en gardant les bits de poids faible, cette dernière étant une solution pas trop mauvaise. D'autres possibilités similaires se basent sur des réductions polynomiales ou d'autres astuces impliquant des nombres premiers. L'efficacité de ces méthodes dépend grandement de la taille de la SRAM/table considérée, des adresses des branchements et de beaucoup d'autres paramètres. La réduction des interférences par telle ou telle méthode dépend aussi de l'unité de prédiction considérée. Les résultats ne sont pas les mêmes selon que l'on parle d'une unité à perceptrons ou d'une unité à deux niveaux ou d'unités à base de compteurs à saturation, ou que l'on parle du BTB. Par contre, toutes les méthodes ne sont pas équivalentes en termes de temps de calcul ou de portes logiques. Autant la première solution avec un XOR ajoute un temps de calcul négligeable et quelques portes logiques, autant les autres méthodes requièrent l'ajout d'un diviseur très lent et gourmand en portes logiques pour calculer des modulos. Elles ne sont généralement pas praticables pour ces raisons, le temps de calcul serait trop élevé. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Le pipeline | prevText=Le pipeline | next=Les optimisations du chargement des instructions | nextText=Les optimisations du chargement des instructions }} </noinclude> i0kp4viu5afxfj4zzwtbsjmldkxmmab 774416 774415 2026-10-07T22:41:34Z Mewtow 31375 774416 wikitext text/x-wiki Les branchements sont pris en charge par plusieurs étages du pipeline. Exécuter un branchement demande de savoir deux choses : s'il est pris ou non, quelle est l'adresse de destination. L'adresse de destination est localisée grâce au mode d'adressage du branchement. Avec le mode d'adressage direct, elle est extraite de l'instruction par le séquenceur. Avec l'adressage indirect, elle est lue dans un registre. Avec les branchements relatifs, elle est calculée via un additionneur, qui additionne un décalage au ''program counter''. L'additionneur peut être soit dans l'étage de ''PC Update'', soit dans l'unité de calcul de l'étage d'exécution. Passons maintenant au résultat du branchement, à savoir s'il est pris ou non. Pour savoir si le branchement est pris, il faut distinguer les branchements conditionnels et inconditionnels. Pour rappel, les branchements inconditionnels regroupent les appels de fonction, les simples instructions de saut, et quelques autres. Ils sont toujours pris, ils s'exécutent toujours. À partir du moment où le décodeur a décodé un branchement inconditionnel, il sait que le branchement est pris. Pour les branchements conditionnels, ils sont pris seulement si une condition précise est respectée. On sait s'ils sont exécutés une fois que la condition est calculée par l'unité de calcul, ou par une unité de branchement spécialisée. [[File:Gestion des branchements sur un pipeline.png|centre|vignette|upright=2.0|Gestion des branchements conditionnels sur un pipeline.]] Sur le schéma précédent, les branchements inconditionnels ne sont pas montrés. Pour cela, il faudrait rajouter une connexion du décodeur vers le multiplexeur de branchement. De plus, il faudrait aussi rajouter de quoi retarder l'exécution du branchement seulement une fois que l'adresse est connue. Par exemple, elle est connue un cycle d'horloge plus si l'adresse est lue depuis les registres, comparé à un branchement direct. ==Les branchements et les exceptions imprécises/précises== Un branchement est censé altérer le ''program counter'' immédiatement, en un cycle d'horloge. Mais sur un pipeline, Le branchement fait bien reprendre le processeur au bon endroit, mais avec du retard. La raison est qu'un branchement met plusieurs cycles pour s'exécuter. Il faut plusieurs cycles pour déterminer quelle est l'adresse de destination, temps qui dépend de là où est lue l'adresse. Et on sait si le branchement est pris seulement au niveau du décodeur, voire au niveau de l'unité de calcul. Le branchement doit traverser plusieurs étages avant qu'on connaisse son résultat. Il y a bien 3 à 4 cycles avant cela. Et pendant ce temps de trajet, des instructions seront chargées dans le pipeline, potentiellement à tort. Si le branchement n'est pas pris, ce n'est pas grave : les instructions n'ont pas été chargées à tort. Mais dans le cas contraire, les instructions n'auraient pas dû rentrer dans le pipeline. Le problème est similaire à celui observé avec les exceptions précises/imprécises. La différence est que les branchements sont des instructions machines, pas les exceptions matérielles. Mais le problème des instructions chargées à tort est le même dans les grandes lignes. Une différence est que les branchements sont résolus plus ou moins au même étage, alors que les exceptions peuvent survenir dans tout le pipeline. Une autre différence, bien plus importante, est que les branchements non-pris ne chargent pas d'instructions à tort et qu'il faut faire la différence entre branchement pris et non-pris. Dans la suite, nous parlerons d'instructions fautives pour parler des instructions chargées à tort. Pour corriger ce problème, il existe une solution logicielle. Le compilateur fait suivre les branchements par des instructions qui ne font rien, des NOP (No OPeration) : c'est ce qu'on appelle un '''délai de branchement'''. L'idée est que toutes les instructions chargées à tort sont des NOPs, qui ne font rien. Mais le nombre de NOP à ajouter dépend du pipeline du processeur, ce qui peut poser des problèmes de compatibilité. Mais d'autres solutions, matérielles cette fois, sont utilisées de nos jours. ===L'annulation des instructions au décodage=== Les branchements inconditionnels sont dans un cas plus favorable : ils sont identifiés dans l'unité de décodage et sont toujours pris. D'autres branchements sont dans ce cas : les appels de fonctions, les interruptions logicielles. Avec eux, le décodeur annule les instructions fautives, il les remplace par des instructions NOP. L'étage de décodage sait que les instructions suivant un branchement inconditionnel ont été chargées à tort. Précisément N instructions, avec N le nombre d'étages entre le chargement et le décodage (inclus). Les N instructions suivantes sont tout simplement annulés, elles sont remplacées par des NOPs. L'implémentation demande juste un simple compteur et quelques circuits annexes. Cependant, la technique ne marche pas du tout pour les branchements conditionnels. En effet, on ne sait pas s'ils sont pris ou non à l'étage de décodage. Du moins pas avec un pipeline normal. Mais certains processeurs, basés sur le pipeline RISC classique, calculaient les branchements pendant l'étape de décodage, dans une ALU spécialisée qui travaille en parallèle de l'unité de décodage. Le défaut de cette technique est que l'ALU en question doit lire les registres. Pour rappel, sur ce pipeline, l'étape de décodage effectue la lecture des opérandes dans les registres, en parallèle de l'unité de décodage. Mais ce qu'on n'a pas dit dans le chapitre précédent, c'est qu'elle effectue aussi le calcul des branchements dans une mini-ALU séparée ! Ce faisant, le résultat du branchement est connu dès la fin de l'étape de décodage. ===L'usage du système d'exceptions précises pour les branchements conditionnels=== La solution gère le problème en empêchant les instructions chargées à tort d'enregistrer leurs résultats, afin qu'elles n'aient pas le moindre effet sur l'état du processeur. Le hardware pour les exceptions précises, décrit quelques paragraphes avant, est réutilisé pour cela. Les branchements conditionnels peuvent être pris ou non-pris et on doit tenir compte de ces deux cas. Si le branchement n'est pas pris, les instructions n'ont pas été chargées à tort, elles peuvent enregistrer leur résultat dans les registres. Par contre, ce n'est pas le cas pour les branchements pris, les mécanismes d'exceptions précise doivent alors d'activer. Pour cela, la meilleure solution est que l'unité de calcul génère un indicateur d'exception si le branchement n'est pas pris. L'ALU effectue la comparaison et génère ou non l'indicateur d'exception adéquat suivant le résultat de celle-ci. L'idée est d'utiliser un indicateur d'exception particulier, qui précise qu'un branchement a été pris, signe que des instructions ont été chargées à tort. L'indicateur est propagé dans le pipeline, jusqu'à atteindre l'étage d'enregistrement dans les registres. Cet étage décide s'il faut autoriser ou non les écritures qu'il recoit, pour annuler les instructions chargées à tort. Le ''program counter'' est corrigé immédiatement, simplement en exécutant le branchement. Reste à savoir qui génère cet indicateur d'exception de branchement pris. Diverses optimisations visent à éliminer les instructions chargées à tort, à faire en sorte que des instructions utiles soient chargées. Le chapitre suivant va détailler ces optimisations dans le détail, qui sont regroupées sous le terme de prédiction de branchement. Elles ne fonctionnent pas pour les exceptions précises, seulement les branchements. ===La prédiction de branchement=== Les techniques précédentes ont pour défaut que de nombreux cycles d'horloge sont gâchés à cause des instructions fautives. Pour éviter ce gâchis, les concepteurs de processeurs ont inventé l'''exécution spéculative de branchement'', qui consiste à deviner l'adresse de destination du branchement et charger les bonnes instructions dès le départ. Le but est de ne pas charger d'instruction à tort, en prédisant quelles seront les bonnes instructions à charger. Et cela demande de résoudre trois problèmes : * reconnaître les branchements ; * savoir si un branchement sera exécuté ou non : c'est la '''prédiction de branchement''' ; * dans le cas où un branchement serait exécuté, il faut aussi savoir quelle est l'adresse de destination : c'est la '''prédiction de l'adresse de destination''' d'un branchement, aussi appelée ''branch target prediction''. Pour résoudre le second problème, le processeur contient un circuit qui déterminer si le branchement est pris (on doit brancher vers l'adresse de destination) ou non pris (on poursuit l’exécution du programme immédiatement après le branchement) : c'est l''''unité de prédiction de branchement'''. La prédiction de direction de branchement est elle aussi déléguée à un circuit spécialisé : l’'''unité de prédiction de direction de branchement'''. C'est l'unité de prédiction de branchement qui autorise l'unité de prédiction de destination de branchement à modifier le ''program counter''. Dans certains processeurs, les deux unités sont regroupées dans le même circuit. [[File:Relation entre prédiction de branchement et de direction de branchement.png|centre|vignette|upright=2|Relation entre prédiction de branchement et de direction de branchement.]] : Au lieu de parler d'unité de prédiction de branchement, j'utiliserais parfois le terme '''prédicteur''', sous-entendu ''prédicteur de branchement''. Avant de poursuivre, faisons une remarque très importante. J'ai parlé d'unité de prédiction de branchement, comme s'il n'y en avait qu'une seule. Mais en réalité, n'en avoir qu'une seule est l'exception. La règle est d'avoir plusieurs unités de branchements spécialisées. Un processeur moderne utilise plusieurs prédicteurs séparés, chacun prédisant un type de branchement bien précis. Il y a pas exemple un prédicteur spécialisé pour les boucles, un autre pour les instructions de retour de fonctions, un autre pour les branchements directs, un autre pour les branchements indirects. Les processeurs modernes utilisent une prédiction de branchement à deux niveaux. Un premier prédicteur fournit des résultats rapides et peu précis, un second a des résultats plus précis après quelques cycles d'horloge. Le prédicteur rapide envoit l'adresse prédite au cache d'instruction, pour charger les instructions prédites et mettre à jour le ''program counter''. Le prédicteur lent envoient leurs résultats au décodeur d'instruction, histoire d'annuler des branchements considérés à tort comme pris/non-pris. Il y a donc des prédicteurs rapides, dont le résultat est corrigé par des prédicteurs plus lents. La seconde unité a le dernier mot, elle corrige ou annule les prédictions de la première. Ce qui lui vaut le nom, en anglais, d'''overiding predictor''. La correction se fait simplement en corrigeant le ''program counter'' et en forçant le décodeur à émettre des NOPs pendant quelques cycles, le temps d'éliminer les instructions chargées à tort. Ce détail aura rapidement son importance. ==Les erreurs de prédiction de branchement== Le processeur peut parfaitement se tromper en faisant ses prédictions, ce qui charge des instructions par erreur dans le pipeline. Dans ce cas, on parle d''''erreur de prédiction'''. Les erreurs de prédiction sont corrigées par le système d'exception précises, ou un équivalent. En clair, les instructions chargées à tort se propagent jusqu'à la sortie du pipeline, où leurs résultats sont alors annulés. N'importe quelle technique de gestion des exceptions précise fait le travail, mais les processeurs modernes utilisent un tampon de réordonnancement (''reorder buffer'') qu'on détaillera dans quelques chapitres. Détecter les erreurs de prédiction demande d'ajouter un circuit dans le processeur : l’''unité de vérification de branchement'' (''branch verification unit''). Elle génère un indicateur d'exception, qui est géré par le système d'exception précise. Pour générer l'indicateur, elle compare l'adresse de destination prédite et celle calculée en exécutant le branchement. Pour cela, l'adresse prédite doit être propagée dans le pipeline jusqu’à ce que l'adresse de destination du branchement soit calculée. [[File:Unité de vérification de branchement.png|centre|vignette|upright=2|Unité de vérification de branchement]] ===La pénalité de mauvaise prédiction=== Le temps pour vider le pipeline des instructions fautives dépend du pipeline. La dernière instruction à être chargée dans le pipeline le sera en même temps qu'on détecte l'erreur de prédiction. Il faudra attendre que cette instruction quitte le pipeline, et donc qu'elle traverse tous les étages. Il y a donc un temps d'attente pour collecter les résultats de ces instructions et la décision de ne pas enregistrer leurs résultats dans les registres Le nombre d'instructions chargées à tort dépend de où est détectée l'erreur de prédiction. Plus le résultat du branchement est connu tard, plus le nombre d'instructions chargées à tort sera grand, et plus le nombre de cycles d'horloge gâchés le sera aussi. Le nombre de cycles gâchés s'appelle la '''pénalité de mauvaise prédiction'''. Elle correspond précisément au nombre de cycles/étages entre le premier étage du pipeline, et l'étage où le branchement est résolu (on connait l'adresse à laquelle brancher). Plus celui-ci est éloigné du début du pipeline, plus la pénalité est importante. [[File:MIT 6.823 Computer System Architecture, Fall 2005 - P-3 vs. P-4 Pipelines - 4473425702.jpg|centre|vignette|upright=2.5|Comparaison de la pénalité de mauvaise prédiction entre le Pentium 3 et le Pentium 4 d'Intel.]] Elle est généralement d'autant plus longue que le pipeline lui-même est long. Les concepteurs de processeurs font donc face à un compromis : un pipeline long permet d'exécuter plus d'instructions en parallèle, mais cela augmente aussi la pénalité de mauvaise prédiction. Un pipeline de 10-15 étapes est un bon compromis. Les pipelines plus longs ont besoin d'une prédiction de branchement élaborée pour fonctionner correctement. Plus la prédiction de branchement est performante, plus le pipeline peut être long. Les pénalités de mauvaise prédiction seront plus importantes, mais les mauvaises prédictions seront plus rares, ce qui compense complétement. ===La ''minimal control dependency''=== En utilisant une technique du nom de ''minimal control dependency'', seules les instructions qui dépendent du résultat du branchement sont supprimées du pipeline en cas de mauvaise prédiction, les autres n'étant pas annulées. Sans ''minimal control dependency'', toutes les instructions qui suivent un branchement dans notre pipeline sont annulées. Pourtant, certaines d'entre elles pourraient être utiles. Prenons un exemple : supposons que l'on dispose d'un processeur de 31 étages (un Pentium 4 par exemple). Supposons que l'adresse du branchement est connue au 9éme étage. On fait face à un branchement qui envoie le processeur seulement 6 instructions plus loin. Si toutes les instructions qui suivent le branchement sont supprimées, les instructions en rouge sont celles qui sont chargées incorrectement. On remarque pourtant que certaines instructions chargées sont potentiellement correctes : celles qui suivent le point d'arrivée du branchement. Elles ne le sont pas forcément : il se peut qu'elles aient des dépendances avec les instructions supprimées. Mais si elles n'en ont pas, alors ces instructions auraient dûes être exécutées. Il serait donc plus efficace de les laisser enregistrer leurs résultats au lieu de les ré-exécuter à l’identique un peu plus tard. Ce genre de choses est possible sur les processeurs qui implémentent une technique du nom de ''Minimal Control Dependancy''. En gros, cette technique fait en sorte que seules les instructions qui dépendent du résultat du branchement soient supprimées du pipeline en cas de mauvaise prédiction. [[File:Minimal cntrol dependency.png|centre|vignette|upright=2|Minimal control dependency]] ==La reconnaissance des branchements== Pour prédire des branchements, le processeur doit faire la différence entre branchements et autres instructions, dès l'étage de chargement. Or, un processeur « normal », sans prédiction de branchement, ne peut faire cette différence qu'à l'étage de décodage, et pas avant. Les chercheurs ont donc dû trouver une solution. La première solution se base sur les techniques de prédécodage vues dans le chapitre sur le cache. Pour rappel, ce prédécodage consiste à décoder partiellement les instructions lors de leur chargement dans le cache d'instructions et à mémoriser des informations utiles dans la ligne de cache. Dans le cas des branchements, les circuits de prédécodage peuvent identifier les branchements et mémoriser cette information dans la ligne de cache. Une autre solution mémorise les branchements déjà rencontrés, dans un cache dédié. Ce cache mémorise l'adresse (le ''program counter'') des branchements déjà rencontrés : il s'appelle le '''tampon d’adresse de branchement''' (''branch adress buffer''). Il est intégré à l'unité de prédiction de branchement, mais est alimenté par le décodeur d'instruction. Reconnaitre un branchement demande l'implication du décodeur d'instruction, qui sait si l'instruction qu'il vient de décoder est un branchement ou non. Il peut même savoir si le branchement est conditionnel, inconditionnel, un appel de retour de fonction, etc. Lorsque le décodeur d'instruction reconnait un branchement, il ajoute ce branchement dans le cache Le tapon d'adresse de branchement contient une adresse mémoire par branchement, et éventuellement d'autres informations. Et pour comprendre quelle adresse est mémorisée, il faut faire attention à un détail. Les explications qui vont suivre vont faire intervenir deux adresses. * La première est l''''adresse de destination''' du branchement, à savoir l'adresse à laquelle le processeur doit reprendre son exécution si le branchement est pris. * L'autre adresse est l''''adresse du branchement''' lui-même. Elle correspond à sa position en mémoire RAM. L'adresse du branchement est la valeur du ''program counter'' quand le branchement est chargé dans le cache, alors que l'adresse de destination est celle du ''program Counter'' une fois le branchement exécuté. Il faudra faire bien attention à ne pas confondre les deux adresses dans ce qui suit. L'adresse du branchement est celle qui est utilisée pour la reconnaissance du branchement. Le tampon d'adresse de branchement mémorise, pour chaque branchement, son adresse de branchement (d'où son nom). À chaque cycle d'horloge, l'unité de chargement envoie le ''program counter'' en entrée du tampon d’adresse de branchement. Un succès dans ce cache indique qu'un branchement déjà rencontré est ré-executé, on peut utiliser la prédiction de branchement. Dans le cas contraire, la prédiction de branchement n'est pas utilisée, et l'instruction est chargée normalement. ==La prédiction de l'adresse de destination== La '''prédiction de l'adresse de destination d'un branchement''' détermine l'adresse de destination d'un branchement. Rappelons qu'il existe plusieurs modes d'adressage pour les branchements et que chacun d'entre eux précise l'adresse de destination à sa manière. Suivant le mode d'adressage, l'adresse de destination est soit dans l'instruction elle-même (adressage direct), soit calculée à l’exécution en additionnant un ''offset'' au ''program counter'' (adressage relatif), soit dans un registre du processeur (branchement indirect), soit précisée de manière implicite (retour de fonction, adresse au sommet de la pile). : Histoire de simplifier la terminologie, je parlerais parfois de '''prédiction de destination''' pour parler de la ''prédiction de l'adresse de destination d'un branchement''. Pour rappel, les branchements se classent en trois types : * les branchements directs pour lequel l'adresse de destination est toujours la même ; * les branchements indirects où l'adresse de destination est dans un registre et peut changer ; * les instructions de retour de fonction. Les branchements directs sont facilement prévisibles, vu que l'adresse vers laquelle il faut brancher est toujours la même. La prédiction des branchements directs et relatifs se fait globalement de la même manière, ce qui fait que nous ferons la confusion dans ce qui suit. Pour les instructions de retour de fonction, une prédiction parfaite est possible. Et c'est ce qui explique qu'elles sont traitées à part, par une unité de branchement dédiée. Pour les branchements indirects, vu que cette adresse change, la prédire celle-ci est particulièrement compliqué (quand c'est possible). Les branchements indirects sont eux aussi traités par une unité de prédiction dédiée rien que pour eux. ===La prédiction de destination pour les branchements directs=== Lorsqu'un branchement est exécuté, on mémoriser l'adresse de destination et la réutiliser lors d'exécutions ultérieures du branchement. Cette technique marche à la perfection pour les branchements directs, pour lesquels cette adresse est toujours la même, mais donne des résultats mitigés pour les branchements indirects. Pour mémoriser l'adresse de destination, on utilise un cache qui mémorise les correspondances entre l'adresse du branchement et l'adresse de destination. Ce cache est appelé le '''''branch target buffer''''' en anglais, qui sera abrévié en BTB dans ce qui suit. Le BTB est une amélioration du tampon d'adresse de branchement vu plus haut, auquel on aurait ajouté les adresses de destination des branchements. Le BTB est utilisé comme suit : on envoie en entrée l'adresse du branchement, le BTB répond au cycle suivant en précisant : s’il reconnaît l'adresse d'entrée, et quelle est l'adresse de destination si c'est le cas. Le BTB peut être un cache totalement associatif, associatif par voie, ou directement adressé, mais ces derniers sont les plus fréquents. Les BTB sont généralement des caches de type directement adressés, où seuls les bits de poids faible de l'adresse du branchement adressent le cache, le reste de l'adresse est tout simplement ignoré. Il existe aussi des BTB qui sont construits comme les caches associatifs à plusieurs voies, mais ceux-ci impliquent généralement la présence de ''tags'', ce qui fait qu'ils sont un peu moins fréquents. [[File:Branch target buffer directement adressé.png|centre|vignette|upright=2|Branch target buffer directement adressé.]] Comme pour tous les caches, un accès au BTB peut entraîner un défaut de cache, c’est-à-dire qu'un branchement censé être dans le BTB n'y est pas. Comme pour les autres caches, les défauts de cache peuvent se classer en trois types : les défauts de cache à froid (''cold miss'') causés par la première exécution d'un branchement, les défauts liés à la capacité du BTB/cache, et les défauts par conflit d'accès au cache. Les défauts liés à la capacité du BTB sont les plus simples à comprendre. La capacité limitée du BTB fait que d'anciens branchements sont éliminées pour laisser la place à de nouveaux. En conséquence, ces branchements peuvent donner des erreurs de prédiction si leur correspondance a été éliminée du cache. On peut réduire des défauts en augmentant la taille du BTB. Les défauts liés aux conflits d'accès au BTB sont eux beaucoup plus intéressants, car la conception du BTB est assez spéciale dans la manière dont sont gérés ce genre de défauts. Le BTB est un cache partiellement associatif, ce qui fait que des défauts de cache par conflit d'accès peuvent avoir lieu. Pour rappel, les défauts par conflit d'accès ont lieu quand deux adresses mémoires se voient attribuer la même ligne de cache. Pour un BTB, cela correspond au cas où deux branchements se voient attribuer la même entrée dans le BTB, ce qui porte le nom d’''aliasing''. Ne pas détecter ce genre de situation sur un cache normal entraînerait des problèmes : la donnée lue/écrite ne serait pas forcément la bonne. Les caches normaux utilisent un système de tags pour éviter ce problème et on s'attendrait à ce que les BTB fassent de même. Mais pour un BTB, l'absence de ''tags'' n'est pas un problème : la seule conséquence est une augmentation du taux de mauvaises prédictions, dont les conséquences en termes de performance peuvent être facilement compensées. Les BTB peuvent donc se passer de tags, ce qui a de nombreux avantages : le circuit est plus rapide, prend moins de portes logiques, consomme moins d'énergie, etc. Ces économies de portes logiques et de performances peuvent être utilisées pour augmenter la taille du BTB, ce qui compense, voire surcompense les mauvaises prédictions induites par l’''aliasing''. Mais ce n'est pas systématique : d'autres BTBs utilisent des ''tags'' pour améliorer le taux de prédiction. Nous ferons donc la différence entre '''BTB tagués''' et '''BTB non-tagués'''. Des processeurs se passent de BTB. À la place, ils mémorisent les correspondances entre branchement et adresse de destination dans les bits de contrôle du cache d'instructions. Mais nous verrons cela en détail dans le chapitre suivant. Plus haut, j'ai dit qu'un processeur moderne utilisait à la fois un prédicteurs rapide et imprécis, avec un prédicteur lent mais précis. Le prédicteur lent a le dernier mot, il corrige ou annule les prédictions du prédicteur rapide. Ce qui lui vaut le nom, en anglais, d'''overiding predictor''. La correction se fait simplement en corrigeant le ''program counter'' et en forçant le décodeur à émettre des NOPs pendant quelques cycles, le temps d'éliminer les instructions chargées à tort. [[File:Double BTB sur les CPU modernes.png|centre|vignette|upright=2|Double BTB sur les CPU modernes.]] Les deux unités de prédiction utilisent des BTB séparés, de taille différente, au moins deux. Vu que les BTB sont des caches, on les appelle des '''BTB de niveau L1 et L2'''. Par exemple, les processeurs Bulldozer d'AMD disposent de deux BTBs : un BTB L1 de 512 entrées, et un BTB L2 de 5120 entrées. Le BTB de niveau L2 est souvent un cache de victime, qui mémorise les branchements retirés de la BTB de niveau L1. La BTB pour les branchements indirects vient en plus de ces deux BTBs, elle complète souvent la BTB de niveau L2. Le ''return stack predictor'' vient en plus des BTB L1 et L2, mais sa petite taille et sa rapidité font qu'il complète l'unité/BTB de niveau L1. Quelques processeurs ont trois niveaux de BTB, avec un niveau L0, L1 et L2. La BTB L0 aliment le cache d'instruction, alors que la L1 et la L2 préviennent les décodeurs d'instruction. La différence entre la L1 et la L2 est que la L2 met plus de temps à prévenir les décodeurs. Les branchements peuvent donc être annulés/forcés à deux étages de décodage différents. Pour donner quelques exemples, les processeurs Zen d'AMD disposent de plusieurs BTBs, avec des niveaux L0, L1 et L2. Le tableau ci-dessous vous donne la taille et la latence de chaque niveau de BTB. La BTB de niveau L0 alimente le cache d'instruction, la L1 permet d'annuler les instructions au premier cycle de décodage, la BTB L2 annule les instructions vers le milieu/fin du décodage. {|class="wikitable" |- ! rowspan="3" | Zen 2 | 16 || 0 |- | 512 || 1 |- | 7168 || 4 |- ! rowspan="3" | Zen 3 | 16 || 0 |- | 1024 || 1 |- | 6656 + 1536 (branchements indirects)|| 4 |} ===La prédiction de destination pour les branchements indirects=== Un BTB classique peut fonctionner pour les branchements indirects. Son seul défaut est qu'il mémorisera la dernière adresse de destination prise par le branchement. Et cela peut fonctionner pour les branchements indirects fortement biaisés, qui ont presque toujours la même adresse de destination. Mais la majorité des branchements indirects change d'adresse de destination régulièrement. Tous les processeurs commerciaux datant d'avant le Pentium M utilisaient un BTB normal pour prédire les branchements indirects. Les processeurs qui les ont suivi sont capables de prédire l'adresse de destination d'un branchement indirect. , grâce à une '''unité de prédiction des branchements indirects''', séparée de l'unité de prédiction de branchement normale. Elle utilise un BTB amélioré, qui mémorise plusieurs adresses de destination pour un seul branchement, en compagnie d'informations pour prédire laquelle est la bonne. Il est possible d'utiliser un BTB unique, pour tous types de branchements, appelé un '''BTB unifié'''. Un BTB unifié a plusieurs adresse de destination par entrée du BTB. Mais une seule est utilisée pour les branchements directs. Vu que les branchements directs sont majoritaires, cela fait beaucoup d'entrées inutilisées. En conséquence, elle est tout sauf optimale, et n'est presque jamais utilisée en pratique. {|class="wikitable" |+ Branch Target Buffer unifié |- ! Adresse du branchement !! Informations de prédiction !! Adresse de destination !! Seconde adresse de destination |- | 0x54462146 || 11101011 || 0x52495674 || 0x24987461 |- | 0x54142446 || 11101101 || 0x15646956 || x0x4158746 |- | 0x25456542 || 10011101 || 0x52495674 || 0x24157461 |- | 0x15455855 || 11010101 || 0x52485754 || 0x24024511 |- | ... || ... || ... || ... |- | ... || ... || ... || ... |- | 0x54462146 || 1001101 || 0x52495674 || 0x24987461 |} Une seconde solution utilise un BTB séparé, distinct du BTB pour les branchements directs. En séparant les deux, on a un BTB simple qui ne mémorise qu'une adresse de destination par branchement, et un BTB pour branchements indirects avec plusieurs adresses de destination par branchement. [[File:BTB avec prédiction de branchements indirects.png|centre|vignette|upright=2.5|BTB avec prédiction de branchements indirects.]] L'idée est que l'on économise des circuits à nombre d'entrées égales. Par exemple, comparons ce que cela donne avec 64 entrées. D'un côté, on a un BTB à 64 entrées, capable de gérer deux adresse de destination par branchement. En pratique, la majorité des branchements sont des branchements directs, qui n'utiliseraient qu'une seule case mémoire. Les circuits du BTB seraient donc sous-utilisés. De l'autre, on a un BTB à 48 entrées pour branchements directs, et un second BTB avec 16 entrées, capable de mémoriser deux adresses par branchements. Le premier BTB utilise moins de circuits, et est utilisé à pleine capacité, il ne gaspille pas de circuits pour mémoriser des adresses de destination vides. Par contre, on perd un peu en flexibilité. L'organisation précédente marche bien si 1/4 des branchements rencontrés sont des branchements indirects. Mais si le ratio est différent, la performance est légèrement sous-optimale. Attention, cela ne veut pas dire qu'il y aura des entrées vides dans un BTB, non. Simplement, le premier BTB risque de manquer de place pour les branchements directs récents, alors que les branchements indirects seront eux bien prédits, même les anciens. À l'opposé, un BTB unique permet de mémoriser les 64 derniers branchements, peu importe qu'ils soient indirects ou non. En clair : un BTB unique est plus flexible, ce qui donne un gain de performance dans certaines situations. Cependant, cet avantage théorique ne tient pas compte de l'économie en circuits, qui peut être utilisée pour faire grossir le BTB pour branchements directs. Par exemple, à budget à transistor équivalent, on a le choix entre BTB à 64 entrées uniques, et un BTB pour branchements directs de 80 entrées avec un BTB indirect de 16 entrées. Le fait qu'un BTB soit plus gros permet des gains de performances qui compensent la perte de flexibilité. On peut ainsi avoir le meilleur des deux monde, à savoir une économie de circuits et les gains en performance qui en découlent, malgré une petite perte de flexibilité. Une troisième solution utilise un BTB normal, mais réserve plusieurs entrées pour un branchement indirect. Par exemple, un branchement indirect qui a trois adresses de destination prendra trois entrées : une par adresse de destination. Mais quelques difficultés surviennent alors. Comment placer trois copies d'un même branchement, alors que ces trois copies ont le même ''program counter'' ? Et que faire pour savoir quelle copie sera prise ? Que faire si l'unité de prédiction de branchement prédit les trois copies comme prises ? La technique du '''''Program Counter'' virtuel''' (''Virtual Program Counter'') résoud ces problèmes d'une manière assez élégante. L'idée est la même que précédemment, à savoir qu'il y a une copie du branchement par adresse de destination, dans le BTB. Mais ces copies n'ont pas exactement le même ''Program counter''. Chaque copie se voit attribuer un ''Program Counter'' virtuel. Et comprendre pourquoi demande de comprendre l'idée sous-jacente. La technique a été inspirée par une optimisation des compilateurs, qui transforme un branchement indirects en une séquence de branchements conditionnels. Par exemple, un branchement indirects qui peut brancher vers les adresses A, B et C est transformé en trois branchements conditionnels : un qui branche vers A, un autre qui branche vers B et un autre vers C. Elle ne marche pas pour tous les branchements indirects, elle demande des circonstances particulières pour fonctionner. Mais l'idée est adaptée pour le ''design'' de l'unité de prédiction de branchement. L'idée est qu'un branchement indirect est déroulé en plusieurs branchements dans le BTB. Les branchements sont traités en séquence : l'unité de prédiction prédit si le premier branchement est pris ou non. S'il est pris, son adresse de destination est envoyée au cache d'instruction. S'il n'est pas pris, l'unité de prédiction tester la seconde copie du branchement, et rebelote. Il teste toutes les copies, une par une, et s’arrête quand il tombe sur un branchement pris. Si aucune copie n'est prise, alors le branchement est prédit comme non-pris. Première remarque : il s'agit d'un processus itératif, où on test un branchement à la fois. Et oui, c'est une prédiction par cycle d'horloge. Prédire un branchement indirect peut donc demander plusieurs cycles d'horloge. Mais ce n'est pas un problème, car le prédicteur peut servir d'''overriding predictor'', d'unité de prédiction lente mais précise. La prédiction peut être utilisée par l'unité de décodage, pour annuler les instructions chargées à tord. Deuxième remarque : comment passer d'un branchement à l'autre ? La première copie a un ''program counter'' normal, qui correspond à l'adresse du branchement. La seconde a un ''program counter'' qui est calculé à partir de cette première adresse, la troisième copie a une adresse calculée à partir de la seconde, etc. Concrètement, n'importe quelle fonction un tant soit peu simple suffit, tant qu'elle est déterministe. L'article qui a proposé cette techniques (''Virtual Program Counter (VPC) Prediction : Very Low Cost Indirect Branch Prediction Using Conditional Branch Prediction Hardware'') se contenait de faire un XOR entre l'adresse précédente et une constante, avec une constante différente par itération. Troisième remarque : l'algorithme demande de faire une parenthèse dans le fonctionnement normal du BTB. Quand un branchement indirect est détecté, il lance une succession de prédiction pour prédire un branchement. L'incrémentation du ''program counter'' normale cesse pendant ce processus. Et cela ne doit être fait que quand un branchement indirect est détecté. Pour cela, le BTB mémorise le type de chaque branchement, sans l'entrée du BTBT : est-ce qu'il est conditionnel, inconditionnel, mais aussi direct ou indirect. Et le type est accesible en sortie du BTB, sur une sortie de quelques bit. Quand l'unité de prédiction remarque qu'un branchement indirect vient de sortir du BTB, alors elle lance le processus. [[File:Virtual program counter.png|centre|vignette|upright=2.5|Virtual program counter]] L'avantage de cette technique est qu'elle s’implémente facilement, son cout en transistors est faible. Elle demande juste d'ajouter quelques circuits autour d'un BTB classique. Elle utilise l'unité de prédiction classique, pas besoin d'une unité de prédiction séparée. Vu qu'il y a un seul BTB, on n'a pas les désavantages d'un BTB séparé pour les branchements indirects. Les entrées du BTB sont réparties entre branchements directs et indirects de manière optimale. Niveau performance, elle est un peu moins bonne que les unités de prédiction ITTAGE qu'on détaillera dans la suite du chapitre. Mais elle est suffisante pour être utilisée sur certains processeurs commerciaux. ===La prédiction de l'adresse de retour d'une fonction=== Presque tous les processeurs conçus après les années 2000 peuvent prévoir l'adresse à laquelle il faudra reprendre lorsqu'une fonction/sous-programme a fini de s’exécuter. Ils font de la '''prédiction de retour de fonction'''. Pour cela, ils possèdent pour une unité de prédiction de branchement spécialisée, appelée le ''return stack predictor'', ou '''''pile de prédiction de retour de fonction'''''. Le terme ''return stack predictor'' étant plus court, nous l'utiliserons dans la suite du cours. Le ''return stack predictor'' est capable de reconnaitre les instructions de retour de fonction, et de prédire l'adresse de retour. Pour comprendre comment elle fait, rappelez-vous le chapitre sur la pile d'appel. Dans ce chapitre, j'avais introduit le concept de ''pile d'adresse de retour'', à savoir une pile d'appel très simplifiée, qui ne contient que les adresses de retour des fonctions en cours d'exécution. Le ''return stack predictor'' est essentiellement composé d'une telle pile d'adresse de retour, avec des circuits pour empiler et dépiler les adresses de retour. Le ''return stack predictor'' doit reconstruire une pile d'adresses de retour, à partir du programme exécuté. La pile d'adresse est mémorisée dans le ''return stack predictor'', elle est totalement séparée de la pile d'appel normale. Le programme exécuté continue d'utiliser la même pile d'appel qu'avant, ce qui fait que les adresses de retour sont dispersées dans la pile d'appel, parfois dans des registres spéciaux du processeur. Mais cela ne pose pas de problèmes pour le processeur, qui reconstruit la pile d'adresse de retour avec l'aide du décodeur. La reconstruction se fait à partir des instructions d'appel et de retour de fonction. Une instruction d'appel de fonction empile une adresse de retour, une instruction de retour de fonction la dépile. Lorsque le décodeur d'instruction reconnait une instruction d'appel de fonction, il extrait l'adresse de retour et l'envoie au ''return stack predictor''. L'adresse de retour est alors empilée, ce qui fait qu'elle se retrouve au sommet de la pile d'adresse de retour. Dépiler une adresse de retour revient à prédire un branchement. Lorsque le ''return stack predictor'' détecte une instruction de retour de fonction, il dépile l'adresse au sommet de la pile. L'adresse est alors envoyée du cache d'instruction, pour l'ire l'instruction adéquate, et le ''program counter'' est mis à jour avec cette adresse. Toute la difficulté revient à détecter les instructions de retour de fonction, sans quoi on ne sait pas quand dépiler l'adresse. La documentation là-dessus n'est pas très fournie, il est difficile de savoir ce qui est fait dans les processeurs modernes. Mais on peut faire quelques suppositions. La première possibilité est l'usage du ''prédécodage'', une technique qui décode partiellement les instructions lors de leur chargement dans le cache d'instruction. Elle sera vue dans le chapitre suivant, mais parlons-en rapidement. Le prédécodage ajoute quelques bits au début de chaque instruction pour préciser si c'est un branchement conditionnel, inconditionnel, un retour de fonction. On peut ainsi savoir rapidement quand dépiler : dès qu'une instruction est lue dans le cache d'instruction, le ''return stack predictor'' vérifie les bits ajoutés et agit en fonction. La seconde possibilité est que le BTB reconnait l'instruction de retour de fonction. Pour chaque branchement, il mémorise non seulement son adresse de retour, mais aussi son type. Par type, on veut dire : si c'est un branchement conditionnel, inconditionnel, un retour de fonction. Les retours de fonction sont donc reconnus à condition qu'ils aient été exécutés auparavant. Il est aussi possible d'avoir un BTB spécialisé dans les retours de fonction, qui est alimenté par les décodeurs. Dès que le décodeur reconnait une instruction de retour de fonction, il ajoute son ''program counter'' dans ce BTB spécialisé. Une dernière possibilité est que le ''return stack predictor'' agit une fois l'instruction partiellement décodée. Le décodeur d'instruction reconnait les instructions de retour de fonction rapidement, en un cycle, et consulte alors le ''return stack predictor''. L'adresse est dépilée, et le décodeur sait où brancher. Il n'a pas besoin de devoir lire l'adresse de retour en mémoire, il la lit depuis le ''return stack predictor'', directement. La prédiction se fait donc assez tard dans le pipeline, quelques instructions ont été chargées à tort. Mais le décodeur peut les annuler, comme il le ferait avec un branchement inconditionnel mal prédit. ==La prédiction de branchement locale== La prédiction de branchement tente de prédire si un branchement sera pris ou non-pris, et décide quelle adresse envoyer au cache en fonction du résultat. Si on prédit qu'un branchement est non pris, on laisse le ''program counter'' faire son travail. À l'inverse si le branchement est prédit comme étant pris, l'adresse de destination est envoyée au cache d'instruction et au ''program counter''. La prédiction de branchement assigne une probabilité qu'un soit branchement soit pris en fonction de ces informations : si la probabilité est de plus de 50%, le branchement est considéré comme pris. La prédiction de branchement se base avant tout sur des statistiques, c’est-à-dire qu'elle détermine la probabilité d’exécution d'un branchement en fonction d'informations assez diverses. La prédiction de branchement n'a d'intérêt que si les prédictions sont suffisamment fiables pour valoir le coup. Rappelons que la pénalité lors d'une mauvaise prédiction est importante, d'autant plus coûteuse que le pipeline est long. Il est généralement admis qu'il faut au minimum un taux de réussite proche de 90%, voire plus sur les processeurs modernes. Heureusement, la plupart des branchements sont des '''branchements biaisés''', c’est-à-dire qu'ils sont presque toujours pris ou presque toujours non-pris, sauf en de rares occasions. De tels branchements font que la prédiction des branchements est facile, bien qu'ils posent paradoxalement quelques problèmes, comme nous le verrons plus bas. Un autre point important est que les unités de prédiction de branchement doivent être très rapides. Idéalement, elles doivent fournir leur prédiction en un seul cycle d'horloge. Un seul cycle d'horloge est un temps très court, surtout sur les ordinateurs avec une fréquence élevée. Les techniques de prédiction dynamique ne peuvent donc pas utiliser des méthodes statistiques extraordinairement complexes. Cela va à l'encontre du fait que le taux de mauvaises prédictions doit être très faible, idéalement inférieur à 1% sur les processeurs modernes. Vous comprenez donc aisément que concevoir une unité de prédiction de branchement est un véritable défi d’ingénierie électronique qui requiert des prouesses technologiques sans précédent. Suivant la nature des informations utilisées, on peut distinguer plusieurs types de prédiction de branchement : locale, globale, dynamique, statique, etc. Nous allons d'abord voir les méthodes statiques avant de voir les méthodes dynamiques. La '''prédiction locale''' prédit chaque branchement indépendamment les uns des autres. Elle sépare bien les statistiques/moyennes pour chaque branchement. Et c'est autant un point fort qu'un point faible. En effet, elle n'exploite pas les corrélations entre branchements, à savoir le fait que l'exécution d'un branchement a tendance à déborder sur les suivants. Dans ce qui suit, nous allons voir les techniques de prédiction locales. ===Les ''branch hints''=== Dans son implémentation la plus explicite, la prédiction est inscrite dans le branchement lui-même. Quelques bits de l'opcode du branchement précisent si le branchement est majoritairement pris ou non pris. Ils permettent d'influencer les règles de prédiction de branchement et de passer outre les réglages par défaut. Ces bits sont appelés des '''suggestions de branchement''' (''branch hint''). Mais tous les processeurs ne gèrent pas cette fonctionnalité. Et de plus, cette solution marche assez mal. La raison est que le programmeur ou le compilateur doit déterminer si le branchement est souvent pris ou non-pris, mais qu'il n'a pas de moyen réellement fiable pour cela. Une solution possible est d’exécuter le programme sur un ensemble de données réalistes et d'analyser le comportement de chaque branchement, afin de déterminer les suggestions de branchement adéquates, mais c'est une solution lourde et peu pratique, qui a de bonnes chances de donner des résultats peu reproductibles d'une exécution à l'autre. ===La prédiction statique de branchement=== La '''prédiction statique''' prédit le résultat du branchement en fonction de caractéristiques fixes du branchement lui-même, comme son adresse, son mode d'adressage, l'adresse de destination, la position du branchement en mémoire, si le branchement est direct ou indirect, etc. Les informations en question sont connues une fois le branchement décodé, il n'y a pas besoin de mémoriser la moindre information supplémentaire. Une première implémentation, assez évidente, fait la différence entre les branchements inconditionnels toujours pris, et les branchements conditionnels au résultat variable. L'idée est de prédire les branchements inconditionnels comme toujours pris alors que les branchements conditionnels sont prédits comme non-pris (ce qui est une approximation). L'implémentation est relativement simple : on utilise un BTB qui ne mémorise que les branchements inconditionnels, et basta ! Les branchements conditionnels sont simplement ignorés, traités comme n'importe quelle autre instruction. Cette méthode est particulièrement inefficace pour les branchements de boucle, où la condition est toujours vraie, sauf en sortie de boucle ! Il a donc fallu affiner légèrement l'algorithme de prédiction statique. Une implémentation plus subtile fait la différence entre les branchements des boucles et les autres. Reste à identifier ceux-ci d'une manière ou d'une autre. Et un bon moyen est de regarder si le branchement revient en arrière ou au contraire saute une portion de code. Un programme s'exécute des adresses basses vers les adresses hautes, les instructions vont naturellement dans le sens croissant des adresses. Un branchement de boucle fait revenir en arrière, histoire de répéter un morceau de code, pas les autres branchements (du moins pas forcément). Et cela nous amène à faire la distinction entre les branchements conditionnels ascendants et descendants. Un '''branchement conditionnel ascendant''' est un branchement qui demande au processeur de reprendre plus loin dans la mémoire : l'adresse de destination est supérieure à l'adresse du branchement. Un branchement '''conditionnel descendant''' a une adresse de destination inférieure à l'adresse du branchement : le branchement demande au processeur de reprendre en arrière dans la mémoire, avant le branchement. Les branchements ascendants sont rarement pris (ils servent dans les conditions de type SI…ALORS), contrairement aux branchements descendants (qui servent à fabriquer des boucles). On peut ainsi modifier l’algorithme de prédiction statique comme suit : * les branchements inconditionnels sont toujours pris ; * les branchements descendants sont toujours pris ; * les branchements ascendants ne sont jamais pris. L'implémentation demande d'élargir le BTB. Le BTB n'a pas seulement à mémoriser l'adresse du branchement et son adresse de destination, mais aussi le type du branchement. En général, il se contente de deux bits, qui indiquent si le branchement est conditionnel, inconditionnel, ou un retour de fonction. Les retours de fonction sont traités à part, histoire de profiter du ''return stack predictor''. {|class="wikitable" |+ BTB avec prédiction de branchement statique évoluée |- ! Adresse du branchement !! Adresse de destination !! Type du branchement |- | 0x4165485 || 0x14657445541 | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... | style="background: #F0F000" | Retour de fonction |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Inconditionnel |- | ... || ... | style="background: #F0F000" | Conditionnel |- | ... || ... || ... |} ===La prédiction avec des compteurs à saturation=== Les méthodes de prédiction de branchement statique sont intéressantes, mais elles montrent rapidement leurs limites. Pour plus de performances, il faut utiliser la '''prédiction dynamique''', qui extrait des statistiques sur l’exécution des branchements. Pour le reste du chapitre, toutes les techniques utilisent la prédiction dynamique, qu'elles soient locales ou globales. Sa version la plus simple se contente de mémoriser ce qui s'est passé lors de l’exécution précédente du branchement. Si le branchement a été pris, alors on suppose qu'il sera pris la prochaine fois. De même, s'il n'a pas été pris, on suppose qu'il ne le sera pas lors de sa prochaine exécution. Pour cela, chaque entrée du BTB contient un bit qui mémorise la dernière exécution du branchement : 1 s'il a été pris, 0 s'il n'a pas été pris. C'est ce qu'on appelle la '''prédiction de branchements sur 1 bit'''. Au vu de sa simplicité, il ne faut pas s'attendre à des miracles. La méthode fonctionne bien pour les branchements des boucles et les branchements inconditionnels, mais elle n'est pas assez performante. Une forme plus élaborée de prédiction utilise des '''compteurs à saturation'''. Un compteur à saturation est un compteur qui est incrémenté à chaque fois qu'un branchement est pris, décrémenté s'il est non-pris. Le compteur est initialisé à la moitié de sa valeur maximale, ce qui correspond à une probabilité proche de 50%. Les compteurs à saturation sont très utilisés et pas seulement pour de la prédiction locale, les méthodes globales en utilisent aussi. Pour faire la prédiction, l'unité de prédiction regarde le bit de poids fort du compteur. Le branchement est considéré comme pris si ce bit de poids fort est à 1, et non pris s'il vaut 0. En clair : on considère que le branchement est pris si le compteur indique une probabilité supérieure à 50%. [[File:Compteur à saturation.png|centre|vignette|upright=2|Compteur à saturation.]] : Pour la culture générale : le compteur à saturation du Pentium 1 était légèrement bogué, ce qui fait que sa prédiction de branchement était sous-optimale. La plupart des processeurs utilisent un compteur à saturation par branchement, ce qui veut dire un compteur à saturation par entrée dans le BTB. Vu chaque compteur est associé à une entrée du BTB, le phénomène d’''aliasing'' mentionné plus haut peut perturber les prédictions de branchement. Deux branchements peuvent se voir associés à la même entrée du BTB, et agir sur le même compteur à saturation, ce qui entraine l'apparition d''''interférences entre branchements'''. L'interférence peut avoir des effets positifs, neutres ou négatifs, suivant la corrélation entre les deux branchements. L’''aliasing'' n'est pas un problème si les deux branchements sont fortement corrélés, par exemple si les deux sont tout le temps pris ou tout le temps non-pris. Si les deux branchements vont dans le même sens, l'interférence est neutre, voire légèrement positive. Mais si l'un est souvent pris et l’autre souvent non-pris, l'interférence est négative. Les deux branchements vont se marcher dessus sans vergogne, chacun interférant sur les prédictions de l'autre ! Il est rare que l’''aliasing'' ait un impact significatif sur les unités de prédiction des deux paragraphes précédents. Il se manifeste surtout quand la BTB est très petite et la seule solution est d'augmenter la taille du BTB. ===La prédiction avec un historique local=== La prédiction avec des compteurs à saturation est très simple, mais il est possible de créer une implémentation alternative, très proche, avec des avantages et désavantages différents. Un compteur à saturation enregistre l'historique d'un branchement, à savoir ses dernières exécutions. L''''historique''' d'un branchement dit quel a été son résultat dans le passé, pour ses exécutions précédentes. Et il est possible d'enregistrer cet historique et l'utiliser pour faire des prédictions. Par exemple, prenons un branchement sont ses 6 dernières exécutions sont "pris, non-pris, pris, pris, pris, non-pris". Le processeur peut encoder cet historique sur 6 bits. Un branchement pris correspond à un bit à 1, un branchement non-pris correspond à un 0. Le bit de poids fort correspond à l'exécution la plus ancienne, le bit de poids faible à l'exécution la plus récente. Par exemple, si l'historique contient 010, cela veut dire que le branchement a été non pris, puis pris, puis non pris. L'historique d'un branchement est appelé son '''historique local''', mais nous parlerons simplement l'historique. Le processeur peut mémoriser l'historique de chaque branchement. Pour cela, il réserve n bits par branchements, pour mémoriser ses n exécutions précédentes. L'historique d'un branchement est mémorisé dans son entrée du BTB. Et plus précisément dans un champ séparé qui est alimentée comme un registre à décalage. Reste à exploiter cet historique local pour faire des prédictions. Une idée assez simple serait de l'utiliser pour calculer une moyenne des N exécutions précédentes. Si la probabilité d'être pris est supérieure à 50 %, le branchement est considéré comme pris. Dans le cas contraire, il est considéré comme non pris. Le calcul de la moyenne utilise une simple porte à majorité, qui prend en entrée tous les bits de l'historique. Cependant, cette méthode est équivalente à utiliser un compteur à saturation par branchement. La sortie de la porte à majorité est équivalente à ce qu'on aurait dans un compteur à saturation. La seule différence : le cout en circuit et la simplicité de conception. Prenons l'exemple des premiers processeurs PA RISC. Ils mémorisaient, pour chaque branchement, les trois dernières exécutions du branchement, avec trois bits par entrée du BTB. La prédiction était réalisée avec une porte à majorité, dont le résultat était utilisé comme prédiction. Ces processeurs ont préféré utiliser un historique de 3 bits, car il était plus simple à concevoir qu'un compteur à saturation équivalent de deux bits. Il suffisait d'alimenter un registre à décalage avec un bit, pas besoin d'incrémenter ou de décrémenter un compteur, pas besoin de signaux d'incrémentation/décrémentation séparés, etc. Mais pour des historiques plus longs, la donne s'inverse et des compteurs à saturation de deux-trois bits l’emportent. Un défaut de la méthode précédente, qui est partagée avec les compteurs à saturation, est qu'elle marche mal pour les branchements qui suivent un ''pattern''. Je veux dire par là que l'exécution d'un branchement suit un cycle, par exemple pris, pris, non-pris, pris, pris, non-pris, et ainsi de suite en boucle. Notons que l'apparition de tels motifs correspond le plus souvent à la présence de boucles dans le programme. Quand une boucle s’exécute, les branchements qui sont à l'intérieur tendent à exprimer de tels motifs. Avec de tels motifs, les compteurs à saturation feront des prédictions incorrectes, là où les prédicteurs qu'on va voir font des prédictions parfaites. Par exemple, prenons un branchement qui suit le ''pattern'' pris, pris, pris, non-pris. Si l'historique local vaut 1110, le branchement est censé refaire une boucle et être pris. Et certes, on a supposé que l'historique faisait 4 bits. Mais le''pattern'' sera visible tant que l'historique fait plus de 4 bits. Par exemple, le ''pattern'' serait 01110 pour un historique local de 5 bits, 101110 pour un historique local de 6 bits, 1101110 pour un historique local de 7 bits, etc. Un ''pattern'' correspond donc à une valeur bien précise de l'historique local. L'historique local permet de voir ces cycles, reste à les exploiter. Prédire correctement les ''pattern'' demande simplement d'associer un compteur à saturation à chaque valeur possible de l'historique local. Le compteur à saturation dira, quand tel pattern est rencontré, si le branchement sera pris ou non-pris par la suite. Au total, il faut donc un registre d'historique local de n bits couplé à 2^n compteurs à saturation. Et ce pour chaque branchement possible. Les compteurs à saturation sont regroupés dans une '''''Pattern History Table''''', que nous noterons PHT dans ce qui suit. Le résultat est une '''unité de prédiction à deux niveaux locale''', qui contient plusieurs registres d'historique et tout autant de PHT locales. En tout, il y a une PHT et un historique par branchement, donc par entrée du BTB. Quand un branchement est exécuté, son historique local indique quel compteur à saturation choisir dans la PHT locale. En général, l'historique local est mémorisé dans le BTB, reste à gérer la PHT. [[File:Unité de prédiction à deux niveaux purement locale.png|centre|vignette|upright=2.5|Unité de prédiction à deux niveaux avec des historiques et des PHT locales.]] Une unité de prédiction locale à deux niveaux contient plusieurs PHT, reste à savoir quelle PHT correspond à tel branchement. La correspondance se fait idéalement à partir de l'adresse du branchement, mais c'est là une implémentation idéale, qui demande beaucoup de circuits. Généralement, l'unité de prédiction n'utilise que les bits de poids faible de l'adresse du branchement, ce qui permet de faire un choix correct, mais avec une possibilité d'''aliasing''. Il est aussi possible de séparer l'historique local du BTB. Les historiques locaux sont alors mémorisés dans une mémoire RAM dédiée, appelé la '''''Branch History Table''''', ou encore la '''table des historiques locaux''', que nous noterons BHT dans ce qui suivra. Chaque byte de la BHT contient l'historique local associé à un branchement. Pour récupérer l'historique local voulu, l'adresse du branchement est envoyée à la BHT, sur les entrées d'adresse de la BHT, directement. Seuls les bits de poids faible de l'adresse sont utilisés. L'historique local est ensuite envoyé à la PHT adéquate, choisie en fonction des mêmes bits de poids faible du PC. Un bon moyen pour cela est d'accéder à toutes les PHT en parallèle, mais de sélectionner la bonne avec un multiplexeur, ce dernier étant commandé par les bits de poids faible du PC. [[File:Table des historiques locaux.jpg|centre|vignette|upright=2|Table des historiques locaux]] Un défaut des unités de prédiction purement locales de ce type est leur cout en termes de circuits. Utiliser un registre d'historique et une PHT par branchement a un cout en transistor élevé, avec tous les défauts que ca implique en termes de consommation électrique et thermique. Elles ont aussi des temps de calcul un peu plus important que les unités purement globales, mais pas de manière flagrante. Les gains en performance avec un historique local s'observent surtout pour les branchements qui sont dans des boucles ou pour les branchements utilisés pour concevoir des boucles, alors que l'implémentation globale marche bien pour les if..else (surtout quand ils sont imbriqués). Les deux sont donc complémentaires. Dans les faits, elles sont rarement utilisées seules du fait de leurs défauts, au profit d'unités de prédiction hybrides, qui mélangent historiques et PHT locaux et globaux. ==La prédiction de branchement globale/mixte== Utiliser un historique local avec une PHT est très puissant. Mais le cout en circuit est très important. Cependant, on peut adapter l'idée pour faire de la prédiction globale. La '''prédiction globale''' fait des prédictions holistiques, qui tiennent compte de tous les branchements, sans les traiter séparément. Elle ne sépare pas les branchements, tous sont mis dans le même panier. La prédiction globale fait donc le compromis inverse : elle exploite les corrélations entre branchements, mais perd les informations statistiques précise pour chaque branchement. L'idée est qu'au lieu d'utiliser un historique par branchement, on utilise un '''historique global''' qui mémorise les exécutions de tous les branchements conditionnels. L'historique enregistre les N exécutions de branchements conditionnels. L'historique global est mémorisé dans un registre unique, appelé le '''registre d'historique global'''. C'est un registre à décalage mis à jour à chaque fois que le processeur exécute un branchement : on fait rentrer un 1 si le branchement est pris, et un 0 sinon. Une unité de prédiction globale utilise cet historique pour faire sa prédiction. L'unité de prédiction globale utilise un compteur à saturation pour chaque valeur possible du registre d'historique. En clair, pour un registre de N bits, elle utilise 2^n compteurs à saturation. Là encore, les compteurs à saturation sont regroupés dans une ''Pattern History Table''. Nous parlerons de '''PHT globale''' pour désigner une PHT commandée par un historique global, de ''PHT locale'' si elle est adressée par un historique local. [[File:Prédiction dynamique à deux niveaux.png|centre|vignette|upright=2|Prédiction dynamique à deux niveaux.]] Il est admis que l'historique global donne de meilleures prédictions que l'historique local. Mais les deux sont complémentaires, ce qui fait qu'il existe des approches hybrides. Les approches hybrides exploitent à la fois les statistiques pour chaque branchement et les corrélations entre branchements. Les prédicteurs hybrides ont de meilleures performances que les prédicteurs purement globaux ou locaux. Avec un historique global, la prédiction tient compte d'éventuelles corrélations entre branchements consécutifs/proches pour faire les prédictions. C'est un avantage pour les applications qui enchaînent des if...else ou qui contiennent beaucoup de if...else imbriqués les uns dans les autres, qui s’exécutent plusieurs fois de suite. Le résultat de chaque if...else dépend généralement des précédents, ce qui rend l'utilisation d'un historique global intéressant. Par contre, ils sont assez mauvais pour le reste des branchements. Mais cette qualité devient un défaut quand elle détecte des corrélations fortuites ou parasites, inutiles pour la prédiction (corrélation n'est pas causalité, même pour prédire des branchements). Du fait de ce défaut, la prédiction globale a besoin de registres d'historiques globaux très larges, de plusieurs centaines de bits au mieux. [[File:2 level branch predictor.svg|centre|vignette|upright=2|Unités de prédiction de branchement à deux niveaux avec une seule PHT.]] Le circuit précédent a cependant un problème : si deux branchements différents s'exécutent et que l'historique est le même, le processeur n'y verra que du feu. Il y a alors une interférence, à savoir que les deux branchements vont agir sur le même compteur à saturation. On se retrouve alors dans une situation conceptuellement identique à l’''aliasing'' dans le BTB ou avec des compteurs à saturation. Sauf que les interférences sont alors beaucoup plus nombreuses et qu'elles sont clairement un problème pour les performances ! Mitiger les interférences devient alors bien plus important qu'avec les unités de prédiction précédentes. Heureusement, si augmenter la taille de la PHT peut mitiger le problème, d'autres solutions sont possibles sur les unités de prédiction globales. Elles sont au nombre de deux : mitiger les interférences liées aux branchements biaisés, ajouter des informations qui discriminent les branchements. Voyons ces solutions dans le détail. ===La mitigation des interférences par usage de l'adresse de branchement=== La première solution pour réduire l'impact de l’''aliasing'' est d'augmenter la taille de la PHT et de l'historique. Plus l'historique et la PHT sont grands, moins l’''aliasing'' est fréquent. Mais avoir des historiques et des PHT de très grande taille n'est pas une mince affaire et le cout en termes de performances et de portes logiques est souvent trop lourd. Une autre solution consiste à utiliser, en plus de l'historique, des informations sur le branchement lui-même pour choisir le compteur à saturation à utiliser. Typiquement, on peut utiliser l'adresse du branchement en plus de l'historique, pour choisir le compteur à saturation adéquat. Pour être précis, on n'utilise que rarement l'adresse complète du branchement, mais seulement les bits de poids faible. La raison est que cela fait moins de bits à utiliser, donc moins de circuits, mais l’''aliasing'' est juste atténué, pas éliminé. Des branchements différents ont beau avoir des adresses différentes, les bits de poids faible de leurs adresses peuvent être identiques. Des confusions sont donc possibles, mais sont assez rares pour que cela suffise en pratique. Une unité de prédiction de ce type est illustrée ci-dessous. Il y a plusieurs PHT séparées, donc plusieurs ensembles de 2^n compteurs à saturation. Le choix de la PHT est réalisé par l'adresse du branchement, alors que le choix du compteur à saturation est réalisé par l'historique. Les compteurs à saturation d'un branchement sont mis à jour seulement quand le branchement s'est chargé pendant que l'historique associé était dans le registre d'historique. Cette méthode a cependant le défaut de gâcher énormément de transistors. [[File:Unité de prédiction de branchement basée sur des compteurs à saturation sélectionnés par l'historique global.jpg|centre|vignette|upright=2|Unité de prédiction de branchement basée sur des compteurs à saturation sélectionnés par l'historique global]] D'autres unités de prédiction fonctionnent sur le même principe que l'unité précédente, sauf que les différentes PHTs sont fusionnées en une seule grosse PHT. C'est ce que font les unités de prédiction '''''gshare''''' et '''''gselect''''', ainsi que leurs dérivées. Avec les unités de prédiction '''''gselect''''', on concatène les bits de poids faible de l'adresse du branchement et l'historique pour obtenir le numéro du compteur à saturation à utiliser. [[File:Prédiction « gselect ».png|centre|vignette|upright=2|Prédiction « gselect ».]] Avec les unités de prédiction '''''gshare''''', on fait un XOR entre le registre d'historique et les bits de poids faible de l'adresse du branchement. Le résultat de ce XOR donne le numéro du compteur à utiliser. Avec cette technique, on utilise une PHT globale et un historique global, mais le registre d'historique est manipulé de manière à limiter l'''aliasing''. [[File:Prédiction « gshare ».png|centre|vignette|upright=2|Prédiction « gshare ».]] Intuitivement, on pourrait croire que les unités de prédiction gselect ont de meilleures performances. C'est vrai que les unités gshare combinent l'adresse du branchement et l'historique d'une manière qui fait perdre un peu d'information (impossible de retrouver l'adresse du branchement et l'historique à partir du résultat du XOR), alors que les unités gselect conservent ces informations. Mais il faut prendre en compte le fait que les deux unités doivent se comparer à PHT identique, de même taille. Prenons par exemple une PHT de 256 compteurs, adressée par 8 bits. Avec une unité gselect, les 8 bits sont utilisés à la fois pour l'historique et pour les bits de poids faible de l'adresse du branchement. Alors qu'avec une unité gshare, on peut utiliser un historique de 8 bits et les 8 bits de poids faible de l'adresse du branchement. L'augmentation de la taille de l'historique et du nombre de bits utilisés fait que l’''aliasing'' est réduit, et cela compense le fait que l'on fait un XOR au lieu d'une concaténation. Les unités précédentes réduisent l'''aliasing'' en segmentant la PHT : telles entrées sont allouées à un branchement, telles entrées à tel autre branchement, etc. Cependant, l'historique local n'est pas utilisé. Mais quitte à allouer des entrées à un branchement, autant en profiter pour leur associer son historique local. Il s'agit alors d'approches hybrides. Par exemple, on peut citer la '''prédiction mixte''' (''alloyed predictor''). Avec celle-ci, la table des compteurs à saturation est adressée avec la concaténation : de bits de l'adresse du branchement, du registre d'historique global et du registre d'historique local adressé par l'adresse du branchement. [[File:Prédiction mixte.jpg|centre|vignette|upright=2|Prédiction mixte.]] Une version optimisée de ce circuit permet d’accéder à la PHT et à la BHT en parallèle. Avec elle les bits de poids faible de l'adresse du branchement sont concaténés avec l'historique global. Le résultat est ensuite envoyé à plusieurs PHT locales distinctes, en parallèle. Les différences PHT envoient leurs résultats à un multiplexeur, commandé par l'historique local, qui sélectionne le résultat adéquat. L'inconvénient de cette mise en œuvre est que le circuit est assez gros, notamment en raison de la présence de plusieurs PHT séparées. [[File:Alloyed predictor optimisé.jpg|centre|vignette|upright=2|''Alloyed predictor'' optimisé]] ===La mitigation des interférences par la structuration en caches des PHTs=== Les techniques précédentes utilisent les bits de poids faible de l'adresse pour sélectionner la PHT adéquate, ou pour sélectionner le compteur dans la PHT. Mais le reste de l'adresse n'est pas utilisé. Cependant, il est théoriquement possible de conserver les bits de poids fort, afin d'identifier le branchement associé à un compteur. Pour cela, chaque compteur à saturation se voit associé à un ''tag'', qui mémorise le reste de l'adresse du branchement associé au compteur, comme pour les mémoires caches. Lorsque l'unité de prédiction démarre une prédiction, elle récupère les bits de poids fort de l'adresse du branchement et compare ceux-ci avec le ''tag''. S’il y a un ''match'', c'est signe que le compteur est bien associé au branchement à prédire. Dans le cas contraire, c'est signe qu'on fait face à une situation d’''aliasing'' et le compteur n'est pas utilisé pour la prédiction. Le résultat transforme la PHT en une sorte de cache assez particulier, qui associe un compteur à saturation à chaque couple adresse-historique. La technique du paragraphe précédent peut être encore améliorée afin de réduire l’''aliasing''. L’''aliasing'' sur une unité de prédiction de branchement est similaire aux conflits d'accès au cache, où deux données/adresses atterrissent dans la même ligne de cache. Une PHT peut formellement être vue comme un cache directement adressé. Une solution pour limiter ces conflits d'accès à de tels, est de les transformer en un cache associatif à n voies. On peut faire la même chose avec les unités de prédiction de branchement, ce qui revient à dupliquer les PHT, de manière à ce que les bits de poids faible de l'adresse se voient attribuer à plusieurs compteurs à saturation, un par branchement possible. Ce faisant, on réduit les interférences entre branchements. Mais cette technique augmente la quantité de circuits utilisés et la consommation d'énergie, ainsi que le temps d'accès, sans compter qu'elle requiert d'utilisation de ''tags'' pour fonctionner à la perfection. Pour les unités de prédiction de branchement, les mesures à ce sujet ne semblent pas montrer un impact réellement important sur les performances, comparé à une organisation de type directement adressé. Il est possible de pousser la logique encore plus loin en ajoutant des caches de victime et d'autres mécanismes courant sur les caches usuels. ===La mitigation des interférences liées aux branchements biaisés=== L’''aliasing'' a un impact négatif quand deux branchements aliasés (qui sont attribués au même compteur à saturation) vont dans des sens différents : si l'un est non-pris, l'autre a de bonnes chances d'être pris (ou inversement). Or, la plupart des branchements sont biaisés, à savoir très fortement orientés dans une direction qu'une autre (presque toujours pris ou presque toujours non-pris, à plus de 90/99% de chances). Plus un branchement a une probabilité élevée d' être pris ou non-pris, plus sa capacité à interférer avec les autres est forte. Le pire des cas est quand deux branchements sont biaisés mais dans des directions inverses : l'interférence est maximale. En conséquence, les branchements biaisés ou quasi biaisés sont les plus problématiques pour l’''aliasing''. Or, il est possible de concevoir des prédicteurs de branchements qui atténuent fortement les interférences des branchements biaisés ou quasi biaisés. C’est le cas de l’''agree predictor'' et de l'unité de prédiction bimodale, que nous allons voir dans ce qui suit. Les branchements très souvent pris vont interférer très fortement avec les branchements très souvent non-pris, et inversement. Par contre, les branchements très souvent pris n'interféreront pas entre eux, de même que les branchements non-pris iront bien ensemble. D'où l'idée de séparer ces deux types de branchements dans des PHT séparées, pour éviter le mauvais ''aliasing''. L''''unité de prédiction bimodale''' part d'une unité gshare, et sépare la PHT globale en deux : une PHT dédiée aux branchements qui sont très souvent pris et une autre pour les branchements très peu pris. Les deux PHT fournissent chacune une prédiction. La bonne prédiction est choisie par un multiplexeur commandé par une unité de sélection, qui se charge de déduire quelle unité a raison. Cette unité de sélection est une unité de prédiction basée sur des compteurs à saturation de 2 bits. On peut encore améliorer l'unité de sélection de prédiction en n'utilisant non pas des compteurs à saturation, mais une unité de prédiction dynamique à deux niveaux, ou toute autre unité vue auparavant. [[File:Prédiction bimodale.jpg|centre|vignette|upright=2|Prédiction bimodale.]] La '''prédiction par consensus''' (''agree predictor'') combine une unité de prédiction à historique global (idéalement de type gshare ou gselect, mais une unité normale marche aussi) et des compteurs à saturation. Il s'agit techniquement d'une technique de prédiction mixte, à la fois globale et locale. Le BTB intègre un compteur à saturation de un bit, qui indique la direction privilégiée du branchement. L'unité à historique global a un fonctionnement changé : elle calcule non pas la probabilité que le branchement soit pris ou non-pris, mais la probabilité que le résultat du branchement soit compatible avec le résultat de l'unité de prédiction de 1 bit. La prédiction finale vérifie que ces deux circuits sont d'accord, en faisant un NXOR entre les résultats des deux unités de prédiction de branchement. [[File:Prédiction par consensus.png|centre|vignette|upright=2|Prédiction par consensus.]] Des calculs simples de probabilités montrent que l'''agree predictor'' a des résultats assez importants. Ses résultats sont d'autant meilleurs que les branchements sont biaisés et penchent fortement vers le côté pris ou non-pris. ==La prédiction de branchement avec des perceptrons== Les méthodes précédentes utilisent au moins un compteur à saturation par historique possible. Sachant qu'il y a 2^n valeurs possibles pour un historique de n bits, le nombre de compteurs à saturation augmente exponentiellement avec la taille de l'historique. Ce qui limite grandement la taille de l'historique, alors qu'on aurait au contraire besoin d'un historique assez large pour faire des prédictions excellentes. Pour contourner le problème, des alternatives basées sur des techniques d'apprentissage artificiel (''machine learning'') existent. Un exemple est celui des unités de prédiction de branchement dites de ''neural branch prediction'', basée sur des perceptrons. Dans ce qui suit, nous les appelerons des '''unités perceptron'''. Les perceptrons sont un algorithme d'apprentissage automatique très simple, ce qui fait que les explications qui vont suivre devraient être simples. En théorie, il est possible d'utiliser des techniques d'apprentissage automatique plus élaborées, mais cela n'est pas possible en pratique. Rappelons qu'une unité de prédiction de branchement doit idéalement fournir sa prédiction en un cycle d'horloge. L'implémentation d'un perceptron est déjà problématique de ce point de vue, car elle demande d'utiliser beaucoup de circuits arithmétiques complexes (multiplication et addition). Autant dire que les autres techniques de ''machine learning'', encore plus gourmandes en calculs, ne sont pas praticables. ===Les perceptrons et leur usage en tant qu'unité de prédiction de branchements=== L'idée derrière les unités perceptron est que certains bits de l'historique sont plus importants que d'autres. En soi, ce genre de situation est réaliste. Il arrive que certains branchements soient pris uniquement si les deux branchements précédents ne le sont pas, ou que si l'avant-dernier branchement est lui aussi pris. Mais les autres résultats de branchement présents dans l'historique ne sont pas utiles. En conséquence, deux historiques semblables, qui ne sont différents que d'un ou deux bits, peuvent donner des prédictions presque identiques. Dans ce cas, on peut faire la prédiction en n'utilisant que les bits identiques entre ces historiques, ou du moins en leur donnant plus d'importance qu'aux autres. On exploite alors des corrélations avec certains bits de l'historique, plutôt qu'avec l'historique entier lui-même. Pour coder ces corrélations, on associe un coefficient à chaque bit de l'historique, qui indique à quel point ce bit est important pour déterminer le résultat final. Ce coefficient est proportionnel à la corrélation entre le bit de l'historique associé, et le résultat du branchement à prédire. Les coefficients sont des nombres entiers qui peuvent être positifs, nuls ou négatifs. Un coefficient positif signifie que si le branchement associé a été pris, alors le branchement à prédire a de bonnes chances de l'être aussi (et inversement). Un coefficient nul signifie que les deux branchements sont indépendants et que le bit de l'historique associé n'est pas à prendre en compte. Pour un coefficient négatif, cela signifie que si le branchement associé a été pris, alors le branchement à prédire a de bonnes chances d'être non-pris (et inversement). Les coefficients en question sont généralement compris entre -1 et 1. Une fois qu'on connait ces coefficients de corrélations, on peut calculer la probabilité que le branchement à prédire soit pris, avec des calculs arithmétiques simples. Dans les calculs qui vont suivre, un branchement pris est codé par un 1, alors qu'un branchement non-pris est codé par un -1 ! Ce détail permet de simplifier grandement les calculs. Dans sa version la plus simple, le perceptron calcule un nombre Z qui est positif ou nul si le branchement est pris, négatif s'il ne l'est pas. Tous les coefficients <math>p_i</math> sont alors des nombres relatifs, pouvant être positifs, nuls ou négatifs. Ils sont généralement compris entre -1 et 1. La formule devient donc celle-ci : : <math>Z = w_0 + \sum_i (h_i \times w_i)</math> [[File:ArtificialNeuronModel francais.png|centre|vignette|upright=2|Perceptron.]] : Sur le plan mathématique, le perceptron effectue un produit scalaire entre deux vecteurs : l'historique et le vecteur des poids. Choisir les coefficients adéquats est le but de l'algorithme du perceptron. L'unité de prédiction part de poids par défaut, qui sont mis à jour à chaque bonne ou mauvaise prédiction. Toute la magie de cet algorithme tient dans la manière dont sont mis à jour les poids. L'idée est de prendre le vecteur des poids, puis d'ajouter ou soustraire l'historique suivant le résultat du branchement. Les poids évoluent ainsi à chaque branchement, améliorant leurs prédictions d'une exécution à l'autre. La règle de mise à jour des poids est donc la suivante : : <math>w_i \leftarrow w_i + t \times h_i</math>, avec t = 1 pour un branchement pris, -1 pour un branchement non-pris. ===L'implémentation matérielle des perceptrons et ses optimisations=== Les calculs réalisés par un perceptrons sont simples, juste des multiplications et des additions et cela se sent dans la conception du circuit. L'unité perceptron utilise un perceptron par entrée du BTB, un par branchement pour simplifier. Pour cela, on utilise une mémoire SRAM qui mémorise les poids de chaque perceptron. La SRAM est adressée par les bits de poids faible du ''program counter'', de l'adresse du branchement. Une fois les poids récupérés, ils sont envoyés à un circuit de calcul de la prédiction, composé de circuits multiplieurs suivis par un additionneur multi-opérande. Le circuit de mise à jour des poids récupère la prédiction, les poids du perceptron, mais aussi le résultat du branchement. La mise à jour étant une simple addition, le circuit est composé d'additionneurs/soustracteurs, couplés à des registres pour mémoriser la prédiction et les poids du perceptron en attendant que le résultat du branchement soit disponible. Vu qu'il y a un délai de quelques cycles avant que le résultat du branchement soit disponible et que les prédictions doivent continuer pendant ce temps, les poids du perceptron et la prédiction sont stockés dans des FIFOs lues/écrites à chaque cycle. [[File:Unité de prédiction de branchement basée sur des perceptrons.png|centre|vignette|upright=2|Unité de prédiction de branchement basée sur des perceptrons]] Rien de bien compliqué sur le principe, mais rappelons que le perceptron doit fournir un résultat en moins d'un cycle d'horloge, et cela tient compte de l'accès au BTB. C'est un temps très court, surtout pour un circuit qui implique des multiplications. Les multiplications prennent facilement deux à trois cycles d'horloge. Autant dire que si on ajoute le temps d'accès à une petite SRAM, pour récupérer le perceptron, c'est presque impossible. Mais quelques optimisations simples permettent de rendre le calcul plus rapide. Premièrement, la taille de la SRAM est réduite grâce à quelques économies assez simples. Notamment, le perceptron utilise des poids codés sur quelques bits, généralement un octet, parfois 7 ou 5 bits, guère plus. Les poids sont encodés en complément à 1 ou en complément à 2, là où les perceptrons normaux utilisent des nombres flottants. Rien que ces deux choix simplifient les circuits et les rendent plus rapides. Le désavantage d'utiliser peu de bits par poids est une perte mineure en termes de taux de prédiction, qui est plus que compensée par l'économie en portes logiques, qui permet d'augmenter la taille de la SRAM. D'autres optimisations portent sur le circuit de calcul. Par exemple, la multiplication <math>h_i \times w_i</math> est facultative. Vu que l'historique contient les valeurs 1 et -1, il suffit d'additionner le poids associé si la valeur est 1 et de le soustraire s'il vaut -1. On peut même simplifier la soustraction et se limiter à une complémentation à un (une inversion des bits du poids). En faisant cela, les circuits multiplieurs disparaissent et le circuit de prédiction se résume à un simple additionneur multi-opérande couplé à quelques inverseurs commandables. Certains perceptrons prennent en compte à la fois l'historique global et l'historique local pour faire leur prédiction. Les deux historiques sont simplement concaténés et envoyés en entrée du perceptron. Cela marche assez bien, car les perceptrons peuvent utiliser des historiques de grande taille sans problèmes. Par contre, il y a besoin d'ajouter une ''branch history table'' au circuit, ce qui demande des portes logiques en plus, mais aussi un temps de calcul supplémentaire (l'accès à cette table n'est pas gratuit). ===Les avantages et inconvénients des perceptrons pour la prédiction de branchements=== L'avantage des unités perceptron est qu'elles utilisent un nombre de portes logiques qui est proportionnel à la taille de l'historique, et non pas exponentiel. Cela permet d'utiliser un historique de grande taille, et donc d'obtenir un taux de bonnes prédictions très élevé. Leur désavantage est que leurs performances de prédiction sont moins bonnes à historique équivalent. Les unités perceptrons ont des taux de prédiction élevés, mais seulement quand certaines conditions mathématiques sont respectées par les historiques. La condition en question est que les historiques correspondants à un branchement pris et les historiques où ce même branchement est non-pris doivent être linéairement séparables. Heureusement, les branchements des programmes informatiques sont une majorité à respecter la condition de séparation linéaire, ce qui rend les perceptrons parfaitement adaptés à la prédiction de branchements. La notion de linéairement séparable est un peu difficile à comprendre. Pour l'expliquer, imaginez que les N bits de l'historique forme un espace à N-dimensions discrets. Chaque historique est un point dans cet espace N-dimensionnel et chaque bit sert de coordonnée pour se repérer dans cet espace. Une fonction est linéairement séparable si l'espace N-dimensionnel peut être coupé en deux par un hyperplan : d'un côté de ce plan se trouvent les historiques des branchements pris, de l'autre se trouvent les historiques des branchements non-pris. Cet hyperplan est défini par l'ensemble des points qui respecte l'équation suivante : : <math>w_0 + \sum_i (h_i \times w_i) = 0</math> Un exemple où cette condition est respectée est quand le résultat d'une prédiction se calcule avec un ET entre plusieurs branchements de l'historique. Si un branchement est pris quand plusieurs branchements de l’historique sont tous pris ou tous non-pris, la condition est respectée et le perceptron donnera des prédictions correctes. Un exemple où cette condition n'est pas respectée est un banal XOR. Prenons l'exemple d'un historique global de 2 bits. Imaginons que le branchement à prédire soit pris : soit quand le premier branchement de l'historique est pris et le second non-pris, soit quand le premier est non-pris et le second pris. Concrètement, la prédiction exacte se calcule en faisant un XOR entre les deux bits de l'historique. Dans ce cas, la condition "linéairement séparable" n'est pas respectée et le perceptron n'aura pas des performances optimales. Les unités perceptron utilisent idéalement un perceptron par branchement. Cela demande d'associer, l'adresse du branchement pour chaque perceptron, généralement en donnant un perceptron par entrée du BTB. Mais une telle organisation n'est pas possible en pratique, car elle demanderait d'ajouter un ''tag'' à chaque perceptron/entrée du BTB, ce qui demanderait beaucoup de circuits et de ressources matérielles. Dans les faits l'association entre un branchement et un perceptron se fait en utilisant les bits de poids faible de l'adresse de branchement. Mais faire cela entrainera l'apparition de phénomènes d'''aliasing'', comme pour les unités de prédiction à deux niveaux. Les techniques vues précédemment peuvent en théorie être adaptées sur les unités à perceptrons, dans une certaine mesure. Notons que les perceptrons sont des algorithmes ou des circuits qui permettent de classer des données d'entrée en deux types. Ici, la donnée d'entrée est l'historique global et la sortie du perceptron indique si le branchement prédit est pris ou non-pris. Ils ne sont donc pas adaptés à d'autres tâches de prédiction, comme pour le préchargement des lignes de cache ou la prédiction de l'adresse de destination d'un branchement, ou toute autre tâche d'exécution spéculative un tant soit peu complexe. Leur utilisation reste cantonnée à la prédiction de branchement proprement dit, guère plus. Par contre, les autres techniques vues plus haut peuvent être utilisées pour le préchargement des lignes de cache, ou d'autres mécanismes de prédiction plus complexes, que nous aborderons dans la suite du cours. ==La prédiction des branchements des boucles== Les boucles sont à part, pour ce qui est de la prédiction de branchement. Rappelons qu'une boucle FOR/WHILE répète une série d'instruction N fois, le nombre N étant appelé le '''compteur de boucle'''. Ce qui fait que les branchements d'une boucle FOR sont pris n − 1 fois, la dernière exécution étant non prise. La conséquence est que les unités de prédiction usuelles sont assez mauvaises pour prédire les branchements des boucles. Par contre, la prédiction statique marche particulièrement bien comparé au reste. La prédiction statique prédit que les branchements des boucles sont toujours pris, ce qui donne des prédictions qui sont d'autant meilleures que la boucle est répétée un grand nombre de fois. Reste à gérer les branchements des boucles au mieux, en sachant cela. L'idée est d'utiliser plusieurs unités de prédiction séparées : une spécialisée dans les boucles et d'autres unités de prédiction. Uen unité de méta-prédiction se charge d'activer l'unité de prédiction des boucles dès qu'une boucle est détéctée. Les unités de prédiction modernes essayent de détecter les branchements des boucles afin de les mettre à part. En soi, ce n'est pas compliqué les branchements des boucles sont presque toujours des branchements descendants et réciproquement. De plus, ces branchements sont pris à chaque répétition de la boucle, et ne sont non-pris que quand on quitte la boucle. Le compteur de boucle analyse l'exécution des branchements, pour détecter les branchements des boucles. Un branchement est considéré comme un branchement de boucle si : il est pris N fois, avant de ne pas être pris une fois. Au moment où le branchement n'est plus pris, le ''Loop Predictor'' lui attribue une entrée dédié. L'entrée mémorise mémorise le nombre de fois que ce branchement a été pris, à savoir N, et est associée à un compteur. Le compteur est incrémenté/décrémenté à chaque exécution du branchement, et sa valeur est comparée à la valeur N. Quand le compteur atteint N+1, il est réinitialisé. Il s'agit d'une implémentation simple, mais est visiblement proche de celle utilisée sur le Pentium M, si j'en crois l'article "The Intel Pentium M Processor : Microarchitecture and Performance". Il faut noter que la boucle doit s'exécuter au moins une fois pour être détectée, et seules les exécutions suivantes sont prédites correctement. La boucle doit donc s'exécuter plusieurs fois, ce qui implique qu'elle est elle-même dans une boucle plus grande qu'elle. Une telle unité est donc capable de prédire spécifiquement les branchements de boucles FOR imbriquées, à savoir où une boucle FOR est dans une autre boucle FOR. Concrètement, cela permet de répéter la première boucle FOR plusieurs fois de suite, souvent un grand nombre de fois. Avec les boucles imbriquées, le compteur de boucle peut changer d'une répétition à l'autre, mais ce n'est que rarement le cas. Dans de nombreux cas, le compteur de boucle reste le même d'une répétition à l'autre. Ce qui fait qu'une fois la boucle exécutée une première fois, on peut prédire à la perfection les exécutions suivantes. Pour cela, il suffit de déterminer le compteur de boucle après la première exécution de la boucle, puis de le mémoriser dans un compteur. Lors des exécutions suivantes de la boucle, on compte le nombre de fois que le branchement de la boucle s'exécute : il est prédit comme pris tant que le compteur est différent du compteur de boucle, mais non-pris en cas d'égalité. ==La prédiction avec plusieurs unités de branchement distinctes== Il est possible de combiner plusieurs unités de prédiction de branchement différentes et de combiner leurs résultats en une seule prédiction. On peut par exemple combiner une unité de prédiction spécialisée dans les boucles, une unité de prédiction 2 bits basée sur des compteurs à saturation, une unité de prédiction statique et une unité ''gshare''. Tout le problème est de décider quelle unité a fait la bonne prédiction, comment combiner les résultats des différentes unités de prédiction. Soit on choisit un résultat qui parait plus fiable que les autres, soit on combine les résultats pour faire une sorte de moyenne des prédictions. La méthode la plus simple de choisir le résultat d'une unité de prédiction parmi toutes les autres. Pour implémenter le tout, il suffit d'un multiplexeur qui effectue le choix de la prédiction. Reste à commander le multiplexeur, ce qui demande un '''circuit méta-prédicteur''' qui sélectionne la bonne unité de prédiction. Il arrive que le circuit méta-prédicteur soit conçu avec les mêmes techniques que les unités de prédiction de branchement elle-mêmes. Dans le cas le plus simple, il s'agit d'un simple compteur à saturation, mis à jour suivant la concordance des prédictions avec le résultat effectif du branchement. Mais c'est loin d'être systématique. Une autre solution combine le résultat des différentes unités de prédiction avec une fonction mathématique adéquate. Avec un nombre impair d'unités de prédiction, une méthode assez efficace est de simplement prendre le résultat majoritaire. Si une majorité d'unités pense que le branchement est pris, alors on le considère comme pris, et comme non pris dans le cas contraire. Par contre, avec un nombre pair d'unités, cette méthode simple ne marche pas, il y a un risque d'égalité. L'unité de prédiction de branchement « '''''e-gskew''''' » utilise trois unités gshare et combine leurs résultats avec un vote à majorité. Les trois unités gshare utilisent des XOR légèrement différents, dans le sens où les bits de l'adresse du branchement choisi ne sont pas les mêmes dans les trois unités, sans compter que le résultat du XOR peut subir une modification qui dépend de l'unité de prédiction choisie. Autrement dit, la fonction de hachage qui associe une entrée à un branchement dépend de l'unité. [[File:Unité de prédiction « e-gskew ».png|centre|vignette|upright=2|Unité de prédiction « e-gskew ».]] Enfin, une dernière solution est que certaines unités de prédiction ont la priorité sur toutes les autres. Par exemple, prenons un processeur avec un ''return stack predictor'', une unité qui détecte les boucles et une unité de prédiction avec un historique. Si le ''return stack predictor'' détecte une instruction de retour de fonction, sa prédiction passera avant toutes les autres unités de prédiction. Et dans ce qui va suivre, c'est cette solution qui sera utilisé pour les unités de prédiction qu'on va étudier. ===Le prédicteur L-TAGE=== Il est possible d'utiliser plusieurs unités de prédiction, chacune utilisant des historiques de taille différente. Pour donner un exemple simple, on pourrait avoir une unité avec un historique de 2 bits, une autre avec un historique de 3 bits, une autre de 4 bits, etc. Une méta-unité de branchement prédit alors quelle taille d'historique donne les meilleurs résultats. Il est fréquent que les tailles d'historiques utilisées suivent une formule mathématique précise, appelée suite géométrique, ce qui est un mot bien barbare pour dire que l'on passe d'un historique au suivant en multipliant sa taille par une constante bien précise. Par exemple, on pourrait avoir des historiques de taille 2, 4, 8, 16, 32. C'est d'ailleurs le principe derrière les '''unités de branchement TAGE (''TAgged GEometric length predictor'')'''. Elles utilisent plusieurs registres d'historiques, chacun avec sa propre PHT, dont la taille suit une suite géométrique. Avec cette méthode, les branchements qui ont un motif répétitif sont alors parfaitement détectés, peu importe sa taille. Par exemple, un branchement avec un historique dont le cycle est pris, non-pris, non-pris, sera parfaitement prédit avec un historique de 3 bits, 6 bits, ou de 3*n bits, mais pas forcément avec un historique de 4 ou 8 bits. De ce fait, utiliser plusieurs unités de branchement avec plusieurs tailles d'historique fonctionne plutôt bien. Le prédicteur TAGE a été raffiné avec le temps, sur plusieurs points, que je ne vais pas détailler dans ce qui suit. Précisons cependant que les PHT sont structurées en caches, afin d'éliminer les phénomènes d'''aliasing''. L'ajout d'une unité de prédiction pour les boucles, a donné le '''prédicteur L-TAGE''', qui a lui-même été amélioré par la suite. Les unités de branchement TAGE sont les plus utilisées au moment où j'écris ces lignes (2026), et la majorité des processeurs x86 commerciaux utilisent une variante de ce prédicteur, à quelques exceptions près. Le '''prédicteur ITTAGE''' peut être adapté pour prédire des branchements indirects. La seule différence est que chaque table d'historique est associée non seulement à une prédiction pris/non-pris, mais aussi à une adresse de destination. L'idée derrière l'ITTAGE est que l'adresse de destination dépend de l'historique. L'adresse de destination dépend de ce qui se passe avant qu'on exécute le branchement indirect, donc de l'historique global. Il est donc fréquent que des historiques différents donne un résultat différent, que ce soit pour la prédiction pris/non-pris, mais aussi pour l'adresse de destination. L'ITTAGE associe donc une adresse de destination par ''pattern'' prédit. ==Les optimisations de la prédiction de branchements== La prédiction de branchement est complexe et toute économie est bonne à prendre. Pour améliorer les performances, ou simplement améliorer l'utilisation du BTB et des PHT, diverses techniques ont été inventées. Ces techniques marchent quel que soit l'unité de prédiction prise en compte. Elles peuvent s'appliquer aussi bien aux unités basées sur des perceptrons, que sur des unités à deux niveaux ou des unités de prédiction dynamique en général. Ces techniques sont assez variés : certaines profitent du fait que de nombreux branchements sont biaisés, d'autres tentent de réduire les interférences entre branchements sans utiliser l'historique global/local, etc. ===Le filtrage de branchements=== Une première optimisation permet d'économiser l'usage des différentes ressources matérielles, comme la BTB ou les PHT, en les réservant aux branchements non-biaisés. Idéalement, les branchements biaisés peuvent être prédits en utilisant des techniques très simples. D'où l'idée d'utiliser deux unités de prédiction spécialisées : une pour les branchements biaisés et une autre plus complexe. L'unité de prédiction pour les branchements biaisés est une unité de prédiction à 1 bit qui indique si le branchement est pris ou non, ce qui suffit largement pour de tels branchements. Cette technique s'appelle le '''filtrage de branchement''' et son nom est assez parlant. On filtre les branchements biaisés pour éviter qu'ils utilisent des PHT et d'autres ressources qui gagneraient à être utilisées pour des branchements plus difficiles à prédire. Appliquer cette idée demande de reconnaître les branchements fortement biaisés d'une manière ou d'une autre, ce qui est plus facile à dire qu'à faire. Une solution similaire enregistre quels branchements sont biaisés dans le BTB, et utilise cette information pour faire des prédictions. ===L'usage de fonctions de hashage pour indexer les diverses SRAM/tables=== Plus haut, nous avons vu que les unités de prédiction de branchement contiennent des structures qui sont adressées par l'adresse de branchement. Tel est le cas du ''Branch Target Buffer'', de certaines PHT globales ou locales, de la ''Branch History Table'' qui stocke les historiques locaux, ou encore de la SRAM des poids des unités à base de perceptrons. En pratique, ces structures sont adressées non pas par l'adresse de branchement complète, mais pas les bits de poids faible de cette adresse. Cela a pour conséquence l'apparition d'un ''aliasing'' lié au fait que ces structures vont confondre deux branchements pour lesquels les bits de poids faible de l'adresse sont identiques. Il est possible d'utiliser autre chose que les bits de poids faible de l'adresse du branchement, afin de limiter les interférences. Et les possibilités sont multiples. Les possibilités en question s'inspirent des traitements effectués sur les adresses des banques dans les mémoires évoluées. Une première solution serait de faire un XOR entre les bits de poids faible de l'adresse du branchement, et d'autres bits de cette même adresse. Ainsi, deux branchements éloignés en mémoire donneraient des résultats différents, même si leurs bits de poids faible sont identiques. Une autre possibilité serait de diviser l'adresse du branchement par un nombre et de garder le reste. Ce calcul de modulo n'est en soi pas très différent du fait de conserver seulement les bits de poids faible. Conserver les N bits de poids faible consiste en effet à prendre le modulo par 2^N de l'adresse. Ici, l'idée serait de faire un modulo par un nombre P, qui serait un nombre premier proche de 2^N. Le fait que le nombre soit premier limite les cas où deux adresses différentes donneraient le même reste, ce qui réduit l'''aliasing''. Le fait de prendre un nombre proche de 2^N pour une entrée de N bits est que le résultat reste assez proche de ce qu'on obtiendrait en gardant les bits de poids faible, cette dernière étant une solution pas trop mauvaise. D'autres possibilités similaires se basent sur des réductions polynomiales ou d'autres astuces impliquant des nombres premiers. L'efficacité de ces méthodes dépend grandement de la taille de la SRAM/table considérée, des adresses des branchements et de beaucoup d'autres paramètres. La réduction des interférences par telle ou telle méthode dépend aussi de l'unité de prédiction considérée. Les résultats ne sont pas les mêmes selon que l'on parle d'une unité à perceptrons ou d'une unité à deux niveaux ou d'unités à base de compteurs à saturation, ou que l'on parle du BTB. Par contre, toutes les méthodes ne sont pas équivalentes en termes de temps de calcul ou de portes logiques. Autant la première solution avec un XOR ajoute un temps de calcul négligeable et quelques portes logiques, autant les autres méthodes requièrent l'ajout d'un diviseur très lent et gourmand en portes logiques pour calculer des modulos. Elles ne sont généralement pas praticables pour ces raisons, le temps de calcul serait trop élevé. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Le pipeline | prevText=Le pipeline | next=Les optimisations du chargement des instructions | nextText=Les optimisations du chargement des instructions }} </noinclude> bs8ki1gujc7dbwbzv5cbhym7sc0xvxf Fonctionnement d'un ordinateur/Les processeurs superscalaires in-order 0 65956 774417 774283 2026-10-08T02:15:03Z Mewtow 31375 774417 wikitext text/x-wiki Les processeurs vus auparavant ne peuvent émettre au maximum qu'une instruction par cycle d'horloge. Ils peuvent avoir plusieurs instructions qui s'exécutent en même temps, dans des unités de calcul séparées. C'est le cas dès qu'une instruction multi-cycles s'exécute. Par contre, ils ne peuvent démarrer qu'une seule instruction par cycle. Et quand on court après la performance, ce n'est pas assez ! Les concepteurs de processeurs ont inventés des processeurs qui émettent plusieurs instructions par cycle : les '''processeurs à émissions multiples'''. Un processeur à émission multiple charge plusieurs instructions en même temps, les décode en parallèle, puis les émet en même temps sur des unités de calculs séparées. Pour cela, il faut gérer les dépendances entre instructions, répartir les instructions sur différentes unités de calcul, et cela n'est pas une mince affaire. En général, pour un processeur à émission multiple capable d'émettre N instructions en parallèle, on parle de '''processeur à N voies''', ou encore un ''processeur à N pipelines''. [[File:Superscalarpipeline.svg|centre|vignette|upright=1.5|Pipeline RISC classique à cinq étages sur un processeur superscalaire. On voit bien que plusieurs instructions sont chargées en même temps.]] Les processeurs à émission multiple sont de deux types : les processeurs VLIW et les '''processeurs superscalaires'''. Nous mettons les processeurs VLIW de côté en attendant le prochain chapitre. La raison est qu'ils ont un jeu d'instruction spécialisé qui expose le parallélisme d'instruction directement au niveau du jeu d'instructions, là où les processeurs superscalaires restent des processeurs au jeu d'instruction normal. La différence principale entre processeur VLIW et superscalaire est qu'un processeur superscalaire répartit les instructions sur les unités de calcul à l’exécution, là où un processeur VLIW délègue cette tâche au compilateur. Nous allons voir uniquement les processeurs superscalaires dans ce chapitre. Et plus précisément ceux sans exécution dans le désordre. Un processeur superscalaire peut utiliser l'exécution dans le désordre, mais ce n'est pas une obligation. Historiquement, les premiers processeurs superscalaires étaient des processeurs ''in-order'' (ce qui veut dire qu'ils n'utilisaient pas d'exécution dans le désordre). Le meilleur exemple est celui du Pentium 1 d'Intel. Ils étaient tous deux des processeurs superscalaires ''in-order''. Dans ce chapitre, nous allons voir uniquement les processeurs superscalaires ''in-order'' pour simplifier les explications. Il faut cependant garder à l'esprit que les explications qui vont suivre marchent aussi pour les processeurs superscalaires ''out of order'', sauf pour quelques exceptions. Aussi, ce chapitre servira d'introduction au chapitre suivant. ==Le nombre de voies d'un processeur superscalaire== En général, les processeurs superscalaires ''in-order'' ne permettent que d'émettre deux micro-opérations à la fois. D'où leur nom de processeur ''dual issue'', ce qui se traduit en '''processeur à double émission'''. Les processeurs triple émission permettent eux d’émettre trois instructions à la fois, mais ils sont plus rares. A l'opposé, les CPU superscalaires les plus puissants peuvent émettre jusqu'à 8-10 instructions simultanément. En 2026, les CPU superscalaires à exécution dans le désordre peuvent monter jusqu’à 10 µops émises en même temps, ce qui est énorme ! Et cela nous amène à faire une distinction entre les processeurs superscalaires dits '''étroits''' et ceux dits '''larges'''. Dans ce wikilivre, on part du principe que les processeurs superscalaires étroits émettent jusqu'à 4-5 µops à la fois, alors que les larges peuvent en émettre plus, l'intervalle entre les deux est une zone grise. Le nombre exact n'est pas fixé dans le marbre, la distinction est volontairement vague. Nous parlerons de la '''largeur d'un CPU superscalaire''' pour parler du nombre de voies, du nombre de µops émises en même temps. Plus un CPU superscalaire est large, plus il est performant ''toutes choses égales par ailleurs''. Mais dans les faits, vous pouvez oublier la partie en italique. Élargir un CPU superscalaire demande de rajouter beaucoup de circuits. Et ce cout en circuit aura un impact sur la performance. À budget en transistors limité, il y a un compromis entre élargir un CPU et rajouter du cache ou la taille des ''schedulers''/ROB et autres. ===Les contraintes d’appariement=== Prenons un processeur double émission, capable d'émettre deux instructions simultanément. Émettre deux instructions en même temps n'est pas toujours possible. Par exemple, si deux instructions sont dépendantes, on ne peut pas les émettre en même temps. Mais oublions un instant ces histoires de dépendances de données, et partons du principe que le CPU ait affaire à deux instructions indépendantes, une paire idéale. Un CPU double émission "parfait" pourrait exécuter n'importe quelle paire d'instruction. Par exemple, il pourrait émettre deux multiplications consécutives, deux lectures consécutives, une lecture et une écriture en même temps, etc. En pratique, les processeurs qui font ça sont rare. Implémenter un CPU double émission "parfait" a un cout en circuit conséquent. Par exemple, pour émettre deux multiplications en même temps, il faudrait deux multiplieurs séparés. Idem pour exécuter deux accès mémoire simultanés : cela demande d'avoir deux unités mémoire et un cache multiport. La très grosse majorité des CPUs double émission se débrouille avec un seul circuit multiplieur et une seule unité mémoire. Et cela entraine l'apparition de dépendances structurelles, qui font que certaines paires d'instructions sont impossibles. Par exemple, impossible d'émettre deux multiplications en même temps avec un seul circuit multiplieur. Mais émettre une multiplication en parallèle d'une addition est parfaitement possible. Il y a donc des paires d'instructions autorisées et des paires interdites. Pour le dire autrement, il y a des '''contraintes d'appariement''' sur les instructions à émettre en même temps. Ces contraintes d’appariement ont un impact sur les performances, mais celui-ci peut être grandement mitigé. Les programmes informatiques utilisent en pratique beaucoup plus d'opérations entières de base que de multiplication, branchements et accès mémoire. Il y a environ 5 opérations entières de base pour une multiplication entière. En pratique, il est rare de devoir exécuter deux multiplications à la fois. Idem pour les décalages et rotations, qui sont assez éloignés les uns des autres. Deux branchements consécutifs sont quant à eux plus fréquents. Deux accès mémoire consécutifs est assez rare, mais ils restent assez proches les uns des autres malgré tout. La conséquence est que certaines paires d'instructions sont privilégiées par rapport au reste. Les paires regroupant deux calculs basiques (additions, soustractions, ...) sont systématiquement supportées, car dupliquer des ALU entières est facile. Les paires regroupant un calcul et une autre instruction sont elles aussi systématiquement supportées. Par contre, les paires avec deux branchements, deux accès mémoire, deux décalages/rotations, ou deux multiplications/divisions sont fréquemment interdites. Seuls les CPU superscalaires larges peuvent les permettre, et encore : sous conditions. Le résultat est que le processeur peut se débrouiller avec une seule unité mémoire et une unité de branchement. Décrire les contraintes d’appariement demande de dire quelles combinaisons d’instructions sont autorisées ou interdites. Et pour cela, nous allons introduire les abréviations suivantes : * INT est un raccourci pour parler d'une opération entière simple, qu'une ALU entière peut exécuter. * MUL correspond à une opération entière complexe, typiquement une multiplication, éventuellement à une division. * FLOAT désigne une opération flottante, peut importe laquelle. * MEM désigne un accès mémoire, à savoir une instruction ou µop mémoire. * BRANCH désigne une instruction de branchement. Cette terminologie permettra de définir des paires d'instructions, qu'elles soient possibles ou interdites. Par exemple, l'expression INT + FLOAT désigne une paire d'instruction regroupant une instruction entière et une instruction flottante, peu importe l'ordre des deux instructions. Il en sera de même pour les triplets, quadruplets, ou plus. Par exemple, l'expression INT + INT + FLOAT + MUL désigne un quadruplet d'instructions regroupant : deux instructions entières, une instruction flottante, et une multiplication entière. J'utiliserais aussi des parenthèses pour regrouper certaines combinaisons. Par exemple, l'expression INT + (MUL /FLOAT) veut parler d'une paire d'instruction où la première est une opération entière basique, la seconde est : soit une multiplication, soit une opération flottante. ===L'historique simplifié de la superscalairité=== L'histoire des CPU superscalaires a commencé dès 1960, mais est resté un projet de recherche pendant plusieurs décennies. De nombreux designs ont été conçus, mais abandonnés avant la production, comme l'ACS-1 d'IBM. Les années 1980 ont vu l'arrivée de prototypes de processeurs VLIW, mais qui n'étaient pas superscalaires. Par exemple, l'Apollo DN10000 et l'Intel i860 pouvaient émettre deux instructions à la fois, sous conditions, mais demandaient que le compilateur fasse une bonne partie du travail. Les premiers CPU superscalaires commercialisés sont sortis en 1989. Deux CPUs superscalaires sont sortis cette année-là : le RS/6000 d'IBM et l'i960CA d'Intel. Puis, la compétition s'est aussi lancée dans la bataille. Les CPU commercialisés avant l'année 1993 étaient à double émission, à l'exception du SuperSPARC qui utilisait la triple émission. Les modèles après 1993 hésitaient entre double et quadruple émission. Dans le tableau ci-dessous, les processeurs quadruple émission sont en bleu, les triple émission sont en rouge, les autres ne sont pas en couleur. {|class="wikitable" |- ! !! IBM !! HP PA-RISC !! SPARC !! Intel!! Alpha !! Autres |- ! 1989 | RS/6000 || || || i960CA || || |- ! 1990 | || || || || || |- ! 1991 | || || || || || Motorola 88110 |- ! 1992 | RSC || PA 7100 || class="f_rouge | SuperSPARC || || 21064 || |- ! 1993 | PPC 601 || || HyperSPARC || Pentium || || |- ! 1994 | class="f_bleu | POWER 2 || PA 7200 || || || class="f_bleu | 21164 || |- ! 1995 | 603/604 || class="f_bleu | PA 8000 || class="f_bleu | UltraSPARC 1 || Microarchitecture P6 (+ concurrence AMD) || || class="f_bleu | MIPS R1000 |- ! 1996 | class="f_bleu | 620 || || || || class="f_bleu | 21264 || |- ! ... || ... || ... || ... || ... || ... || ... |} De nos jours, même les processeurs superscalaires utilisent l'exécution dans le désordre, et sont tous superscalaires. Et ce même pour les processeurs basse consommation. La loi de Moore nous permet d'utiliser un budget en transistor permettant une exécution dans le désordre minimale, qui améliore grandement les performances pour une consommation en énergie très faible. Les processeurs superscalaires utilisent donc tous l’exécution dans le désordre. Tous ? Non ! Une poignée d'irréductibles processeurs ARM résistent encore et toujours à la tendance globale. ARM a une gamme de processeurs superscalaires sans exécution dans le désordre, qui sont séparés de leurs autres processeurs superscalaires plus puissants. Il s'agit des processeurs ARM Cortex A53, A55, et A510. Il s'agit de processeurs double ou triple émission, prévu pour des applications embarquées, qui privilégient la très basse consommation sur les performances. Ils étaient utilisés dans des ''System On Chip'' assez divers, où ils étaient soit utilisés seuls, soit couplés à des cœurs plus puissants et avec une microarchitecture plus évoluée. ==Le ''front-end'' des processeurs superscalaires ''in-order''== Un processeur superscalaire doit charger, décoder, et exécuter plusieurs instructions par cycle. Par exemple, un processeur triple émission doit charger 3 instructions lors d'un premier cycle, puis les décoder au cycle suivant, et les exécuter au cycle suivant. Il travaille donc pas paquets de 3 instructions, dans cet exemple. Si c'est un processeur superscalaire ''in-order'', cela donne l'impression que le pipeline dispose de plusieurs '''pipelines séparés'''. Intuitivement, charger, décoder et exécuter plusieurs instructions à la fois demande de dupliquer des circuits. Par exemple, il faut dupliquer les décodeurs pour décoder plusieurs instructions. Les unités de calcul sont aussi dupliquées, sauf sur certains processeurs qui arrivent à ruser, mais laissons cela de côté pour plus tard. Mais d'autres circuits sont simplement modifiés, comme l'unité de chargement ou l'unité d'émission. Voyons ce qu'il en est dans le détail. {|class="wikitable" |- ! rowspan="2" | Processeur sans émission multiple | rowspan="2" | Chargement | rowspan="2" | Décodage | rowspan="2" | Émission | Exécution / ALU | rowspan="2" | ''Commit''/ROB |- | Exécution / ALU |- ! rowspan="4" | Processeur superscalaire | rowspan="4" | Chargement | rowspan="2" | Décodage | rowspan="4" | Émission | Exécution / ALU | rowspan="4" | ''Commit''/ROB |- | Exécution / ALU |- | rowspan="2" | Décodage | Exécution / ALU |- | Exécution / ALU |} Nous allons d'abord voir ce qu'il en est pour le ''front-end'', à savoir la partie du CPU qui s'occupe de charger et décoder les instructions. Elle regroupe l'unité de chargement, les décodeurs, l'unité de prédiction de branchement, la file de µops, la file d'instruction, et divers caches. En soi, les changements portent surtout sur l'unité de chargement et les décodeurs. ===Le chargement de plusieurs instructions consécutives=== Un processeur superscalaire charge plusieurs instructions en même temps, ''en tenant compte de la présence de branchements''. La partie en italique est importante et elle a une conséquence très forte : la manière de faire est fortement différente entre un CPU superscalaire étroit et large. Un CPU superscalaire étroit fait au plus simple : il charge un bloc de 2, 3, 4 instructions consécutives depuis le cache d'instruction (ou depuis la mémoire RAM). Il faut alors doubler/tripler/quadrupler la taille du bus connecté au cache d'instruction. Par exemple, pour charger quatre instructions de 32 bits chacune, il suffit de lire un bloc de 128 bits dans le cache d'instruction. Un bloc de 8, 16, 32 octets est donc lu depuis le cache et est ensuite découpé en instructions, envoyées chacun à un décodeur. Charger plusieurs instructions consécutives ne marche bien qu'en absence de branchements. La présence de branchements casse cette dynamique, et il faut gérer leur présence. * Sans prédiction de branchement, les instructions d'un bloc sont chargées, décodées et exécutées en même temps, même s'il y a un branchement dans le tas. * Avec prédiction de branchement, les instructions situées après un branchement pris ne sont pas décodées ni exécutées. L'unité de chargement coupe le bloc chargé au niveau du premier branchement pris, remplit les vides avec des NOP, avant d'envoyer le tout à l'unité de décodage. Les instructions à l'adresse de destination seront chargées au cycle suivant, en chargeant un nouveau bloc. [[File:Fetch sur un processeur superscalaire avec prediction de branchements.png|centre|vignette|upright=2|Fetch sur un processeur superscalaire avec prédiction de branchements.]] Un autre défaut est que sur le CPU charge un bloc de 128 bits à la fois, les blocs d'instructions doivent être alignées sur 128 bits. La moindre instruction à cheval sur deux blocs posera une erreur d'alignement, que le CPU devrait gérer. Et on peut généraliser à n'importe quelle taille de bloc. Il arrivera qu'une instruction est à cheval sur deux blocs, avec un morceau dans le premier bloc, un autre dans le second. Dans ce cas, l'instruction est chargée en deux fois, et des circuits doivent gérer la situation pour recoller les morceaux. Sur les CPU avec des instructions de taille fixe, le problème ne survient que rarement. Mais il est fréquent avec des instructions de taille variable. Un autre défaut est lié aux branchements. Imaginez qu'un branchement s'exécute et branche vers une instruction de destination. Rien n'indique que l'instruction de destination sera au tout début d'un bloc. Si ça se trouve, elle sera en troisième position d'un bloc de 4 instructions. Une solution simple charge le bloc en respectant l'alignement, et les deux premières instructions du bloc ne sont pas à exécuter. Il y a donc une petite perte de performance. Certains CPU, comme l'IBM RS/6000, ont la machinerie charger un bloc de N instructions même s'il n'est pas aligné. La machinerie en question se trouve dans le cache d'instruction et est tout sauf simple à expliquer, ce qui fait que je me permets de la passer sous silence. Un détail, cependant : elle ne permet pas de charger des blocs qui sont à cheval sur deux lignes de cache. ===Le décodage parallèle des instructions=== Pour le décodage des instructions, plusieurs instructions sont décodées en même temps. On parle alors de '''décodage parallèle'''. Pour ce faire, un processeur superscalaire contient plusieurs décodeurs, chacun pouvant décoder une instruction en parallèle des autres. Prenons par exemple un processeur superscalaire à 4 voies : le décodage se fera dans quatre décodeurs séparés, qui fonctionneront en parallèle. Cependant, cette implémentation ne marche bien que pour les processeurs RISC, les processeurs CISC devant faire avec un microcode gourmand en circuits. Les processeurs CISC superscalaires utilisent des décodeurs hybrides, avec un microcode qui complémente un décodeur câblé. Dupliquer le microcode aurait un cout en transistors trop important, ce qui fait que seuls les décodeurs câblés sont dupliqués. Les CPU CISC superscalaires disposent donc de plusieurs décodeurs simples, capables de décoder les instructions les plus courantes, avec un seul microcode. La conséquence est qu'il n'est pas possible de décoder deux instructions microcodées en même temps. Par contre, il reste possible de décoder plusieurs instructions non-microcodées ou une instruction microcodée couplée à une instruction non-microcodée. Vu qu'il est rare que deux instructions microcodées se suivent dans un programme, le cout en performance est extrêmement mineur. Il faut noter qu'une instruction correspond à une ou plusieurs µops. Il est donc possible de charger 5 instructions et de se retrouver avec 8 µops en sortie des décodeurs. Et c'est la source d'une distinction très importante. Plus haut, j'ai parlé de processeurs superscalaires à N voies. La terminologie peut avoir deux interprétations. La première est que le processeur charge/décode N instructions à la fois. Une seconde est qu'il est capable d'exécuter/émettre N µops en même temps. Les deux ne sont pas équivalentes, vu qu'un processeur superscalaire peut, par exemple, charger/décoder N instructions et émettre/exécuter N+2 µops. La distinction est particulièrement importante sur les processeurs superscalaires larges. Certains d'entre eux sont capables de charger 5 instructions et d'en émettre le double ! Un exemple classique était les processeurs Athlon d'AMD, qui pouvaient décoder deux instructions par cycle, mais émettre 4 µops par cycle. Ce n'était pas un problème, car beaucoup d'instructions, même basiques, étaient décodées en 2 µops. ===L'émission parallèle des instructions=== La superscalarité modifie en profondeur l'unité d'émission, notamment au niveau de son interface. Avant, elle recevait une micro-opération sur son entrée, et fournissait la micro-opération émise sur une sortie. Et cela vaut aussi bien pour une unité d'émission simple, un ''scoreboard'', un ''scheduler'' ou des stations de réservation. Mais avec l'émission multiple, les sorties et entrées sont dupliquées. Pour la double émission, il y a deux entrées vu qu'elle doit recevoir deux micro-opération décodées/renommées, et deux sorties pour émettre deux micro-opérations. Dans ce qui suit, nous ne parlerons pas d'entrée et de sortie, mais de '''ports de décodage''' et de '''ports d'émission'''. Les ports de décodage reçoivent des micro-opérations provenant de l'unité de décodage, les ports d'émissions sont pour les micro-opérations émises. Les ports d'émission injectent les micro-opérations émises dans le pipeline, elles sont propagées au banc de registre, aux ALUs, etc. Un processeur superscalaire a forcément plusieurs ports d'émission (et de décodage). [[File:Ports de décodage et d'émission.png|centre|vignette|upright=2|Ports de décodage et d'émission]] Un détail important est que l'unité d'émission peut avoir plus de ports d'émission que de ports de décodage. Et n'allez pas croire qu'un surplus de ports d'émission est du gâchis. Sur les processeurs avec exécution dans le désordre, ce surplus peut être utilisé pour émettre une µops sur chaque port d'émission par cycle d'horloge, ce qui donne un gain de performance appréciable. Ceci dit, les processeurs sans exécution dans le désordre utilisent aussi des ports d'émission en surplus. La raison sera expliquée dans quelques sections, mais nous pouvons dire que cela simplifie la conception du processeur. Si l'unité d'émission a un surplus de ports d'émission, on doit alors distinguer sa ''largeur de décodage'' et sa ''largeur d'émission''. La '''largeur de décodage''' est le nombre de µops fournies par l'unité de décodage par cycle d'horloge, la '''largeur d'émission''' est le nombre maximal de µops émises par cycle d'horloge. La largeur d'émission est égale ou supérieure à la largeur de décodage, tout dépend du processeur. [[File:Processeur superscalaire avec un excès de ports d'émission.png|centre|vignette|upright=2|Processeur superscalaire avec un excès de ports d'émission]] Si l'interface de l'unité d'émission change, l'intérieur est tout autant modifié. Sur un CPU non-superscalaire, l'unité d'émission reçoit une µop "candidate à l'émission", et détecte les dépendances entre avec les µops en vol, en cours d'exécution dans le pipeline. Et c'est toujours le cas sur les processeurs superscalaires : l'unité d'émission détecte les dépendances entre les µops candidates et les µops en vol. Mais avec la siperscalarité, elle doit en plus tester les dépendances entre les µops candidates. Si l'unité d'émission est un ''scoreboard'', il doit détecter les dépendances entre instructions émises simultanément et les sérialiser si besoin. Avec un ''scheduler'', il n'y a pour ainsi dire rien à faire, vu que les dépendances sont éliminées par le renommage de registre et que les signaux de réveil gèrent les dépendances RAW. C'est la raison pour laquelle les processeurs superscalaires modernes n'utilisent pas de ''scoreboard''. ==Le ''back-end'' des processeurs superscalaires ''in-order''== Posons-nous cette question : de combien d'unités un processeur superscalaire a besoin ? Les unités en question ne sont pas nécessairement des unités de calcul, l'unité mémoire et l'unité de branchement comptent aussi. La réponse dépend du nombre de voies. Si le ''front-end'' charge et décode un paquet de N instructions, exécuter ces N instructions simultanément demandera là aussi au moins N unités de calcul. Notez le terme : ''au moins'', car c'est là tout l'enjeu de cette section. La réponse dépend aussi des contraintes d'appariement. Pour un CPU superscalaire "parfait", sans contraintes d’appariement, toutes les unités doivent être dupliquées, y compris l'unité mémoire. Un processeur à double émission parfaite devrait dupliquer l'ALU entière, le ''barrel shifter'', le circuit multiplieur, la FPU, l'unité mémoire, et l'unité de branchement, ce qui ferait passer de 6 unités à 12. Le cout en circuit est énorme, même pour de la double émission ! Et pour un CPU à N pipelines, le cout en circuit encore plus prohibitif ! En pratique, les processeurs superscalaires qui font ça sont très rares. La majeure partie des processeurs superscalaires utilisent d'autres techniques : le partitionnement, la duplication partielle des ALUs, la duplication des autres unités. ===Le partitionnement des unités fonctionnelles=== Les CPU superscalaires les plus simples ne dupliquent pas leurs unités de calcul, ils se contentent d'exploiter les unités existantes. Et pour expliquer comment, nous allons partir de l'exemple d'un processeur non-superscalaire contient une ALU entière, une FPU, une unité mémoire et une unité de branchement. Les quatre unités peuvent fonctionner en parallèle, à savoir qu'une unité peut exécuter une instruction à elle toute seule, sans interagir avec les autres. Cela suffit pour créer un processeur quadruple émission, qui peut émettre en même temps : une opération entière, une opération flottante, un branchement, un accès mémoire. Le principe marche aussi sur un processeur avec moins de 4 unités, par exemple sans FPU et/ou sans unité de branchement, ou avec au contraire des unités en plus. L'idée générale est d’émettre une µops séparée dans chaque unité fonctionnelle. Utiliser ainsi les unités existantes, pour rendre un CPU superscalaire, s'appelle le '''partitionnement superscalaire'''. Il consiste à attribuer un port d'émission à chaque unité existante. Le terme n'est pas beaucoup utilisé en dehors de quelques vieux articles académiques. Le partitionnement est fortement dépendant du jeu d'instruction, car il demande de bien séparer instructions entières, flottantes, branchements et instructions mémoire. Et autant c'est bien le cas sur les CPU RISC, autant les CPU CISC ne sont pas dans ce cas. Les CPU CISC ont des instructions ''load-op'', qui effectuent une instruction arithmétique et une µop mémoire. Et ces instructions sont très compliquées à implémenter avec le partitionnement. Et au-delà de ça, les CPU CISC ont beaucoup d'instructions qui sont décodées en plusieurs µops, à exécuter dans un ordre bien précis. On peut pas émettre les µops en question simultanément. Le partitionnement a un cout en circuit très limité, pour un gain en performance lui aussi limité. Pour reprendre le CPU de l'exemple précédent, il est rare que l'on ait à exécuter ces quatre instructions en même temps. En pratique, les combinaisons fréquentes sont les combinaisons calcul + accès mémoire, calcul + branchement, calcul entier + calcul flottant. Utiliser une quadruple émission de ce type n'est donc pas une bonne idée, alors que la double émission parait plus adaptée. Avant de poursuivre, précisons que la FPU regroupe un additionneur flottant et un multiplieur flottant. Et ce fait peut être exploité pour approfondir le partitionnement. La '''double émission FADD/FMUL''' permet d'émettre une addition flottante en même temps qu'une multiplication flottante. Elle a été utilisée sur les processeurs x86, Alpha et SPARC. Les autres circuits de calcul flottant sont souvent regroupés avec l'additionneur. La division est parfois e même port que le multiplieur flottant, parfois sur celui de l'additionneur (quand elle est microcodée et exécutée en enchainant les soustractions). ===La duplication des ALU entières=== Le partitionnement est une technique élégante, simple, facile à implémenter et au cout en transistors modeste. Mais il faut avouer que cela ne donne pas des CPU superscalaires très efficaces. Le partitionnement permet d'émettre des paires, triplets, quadruplets de micro-opérations qui sont rares en pratique. Et les paires, triplets, quadruplets d'instructions courants ne sont pas supportés. Les contraintes d’appariement sont trop strictes. Les combinaisons les plus fréquentes en pratique sont des paires/triplets d'opérations entières, souvent des paires/triplets d'additions ou d'opérations entières simples. Il est donc primordial d'émettre plusieurs opérations entières simultanément. Le fait d'émettre et d'exécuter plusieurs opérations entières en même temps s'appelle l''''émission entière multiple'''. Pour exécuter la seconde/troisième opération entière, il faut dupliquer les ALU entières (ou réutiliser l'AGU comme ALU entière comme le fait l'Intel i960). Le processeur doit avoir deux ALU entières pour de la double émission entière, trois pour de triple émission entière, etc. De plus, il faut ajouter des ports au banc de registre, pour lire deux fois plus d'opérandes et décrire deux résultats. Et enfin, il faut rajouter un port d'émission pour pouvoir émettre une micro-opération entière en plus. [[File:Processeur non-superscalaire basique 03.png|centre|vignette|upright=2|Processeur non-superscalaire basique]] La tendance est de multiplier les ALU entières, mais pas le circuit multiplieur. Le cout en transistors est alors grandement réduit, au prix de l'apparition de dépendances structurelles. Le CPU peut émettre deux additions en même temps, ou une addition et une multiplication, mais pas deux multiplications. Il en est de même avec le ''barrel shifter'', qui n'est pas dupliqué. Il est donc possible d'émettre un décalage avec une opération de base, mais pas deux décalages en parallèle. Mais ces contraintes d’appariement ne sont pas un problème, car il est rare de devoir émettre deux multiplications/décalages en même temps. La plupart des programmes sont majoritairement remplis d'additions, avec des multiplications assez rares et des décalages qui le sont encore plus. La moyenne est proche d'une multiplication pour 4/5 additions. Vu que des multiplications consécutives sont très rares, dupliquer le multiplieur est presque inutile. Disposer de plusieurs circuits multiplieurs ou de plusieurs ''barrel shifters'' serait donc un cout en circuits qui ne servirait que rarement et n'en vaut pas la chandelle. Il faut noter que sur les CPU 32 bits, les multiplications peuvent être exécutées dans la FPU ou dans un multiplieur séparé, tout dépend de si le CPU exécute les multiplications dans la FPU ou pas. {|class="wikitable" |- ! Voie d'émission n°1 !! Voie d'émission n°2 !! Voie d'émission n°3 |- | ALU entière || ALU entière || FPU |} Reste à connecter les unités de calcul à l'unité d'émission (indirectement, car il y a des étages de pipeline entre les deux). Une implémentation basique utilisée un port d'émission par unité. Prenons l'exemple d'un CPU superscalaire avec 2 ALU entières, un multiplieur entier, un ''barrel shifter'' une FPU, et une unité mémoire. Vu qu'il y a 6 unités, cela fait donc 6 ports d'émission. C'est beaucoup, l'unité d'émission est très complexe. Suffisamment complexe pour se demander s'ils n'existerait pas des optimisations qui permettraient d'en réduire le nombre sans trop réduire les performances, ni le nombre d'ALU. Et cela nous amène à la section suivante. ===Le partage des ports d'émission=== La présence d'un circuit multiplieur et d'un ''barrel shifter'' pose des problèmes pour le partitionnement. Il est possible d'avoir un port d'émission dédié au multiplieur, et un autre au ''barrel shifter'', qui sont séparés de ceux pour l'ALU entière. Il est alors possible d'émettre une opération entière, une multiplication et un décalage, simultanément. Nous parlerons de '''partitionnement entier''' pour désigner cette possibilité. [[File:Partitionnement superscalaire entier.png|centre|vignette|upright=2|Partitionnement superscalaire entier.]] Le partitionnement entier est cependant très rare, car le gain en performance serait trop faible au regard des couts en circuit. La raison est que les multiplications et les décalages sont assez rares dans le code, ce qui fait que le port d'émission associés sont sous-utilisés. Et un port d'émission a un cout en circuit assez important. Et c'est sans compter qu'ajouter des ports sur le banc de registres pour un multiplieur et un ''barrel shifter'' n'en vaut pas la peine. L'idéal serait de trouver un moyen de réduire le nombre de ports d'émissions, sans réduire le nombre d'unités de calcul. [[File:Emission multiple des opérations entières, implémentation naive.png|vignette|Émission multiple des opérations entières, implémentation naive]] La solution consiste à '''partager les ports d'émission''', à savoir connecter un port d'émission à plusieurs unités de calcul. L'exemple classique est justement lié aux circuits multiplieur et ''barrel shifters''. L'idée est que le multiplieur et le ''barrel shifter'' sont mis sur le même port d'émission qu'une ALU entière. Prenons par exemple le processeur à double émission entière illustré ci-contre. Un port d'émission est relié à une ALU entière seule, et l'autre port d'émission alimente la seconde ALU entière et le multiplieur. La conséquence est que le processeur peut émettre deux opérations entières, dont une multiplication. Si on avait utilisé un port d'émission dédié au multiplieur, il aurait été possible d'émettre deux opérations entières et une multiplication. On perd donc une voie, mais on gagne en flexibilité et on économise des circuits. Notons que ce partage signifie aussi que le multiplieur partage les ports sur le banc de registre, ce qui fait une double économie. [[File:Emission multiple des opérations entières, implémentation courante.png|centre|vignette|upright=1.5|Émission multiple des opérations entières, implémentation courante]] En général, le ''barrel shifter'' et le multiplieur sont placés sur deux voies d'émission séparés, ce qui permet d'émettre un décalage et une multiplication en même temps. Les mettre sur le même port ne le permettrait pas. {|class="wikitable" |- ! Voie d'émission n°1 !! Voie d'émission n°2 !! Voie d'émission n°3 |- | * ALU entière * Multiplieur | * ALU entière * Barrel shifter | FPU |- | * Additions, soustractions, opérations bit à bit, etc. * Multiplication | * Additions, soustractions, opérations bit à bit, etc. * Décalages et rotations | Opération flottante |} Précisons cependant que quelques processeurs offrent un port dédié pour le multiplieur entier. Les processeurs ARM haute performance sont souvent dans ce cas, avec un port d'émission dédié au multiplieur entier. Mais ils sont l'exception qui confirme la règle. Terminons concernant les multiplieurs avec une dernière subtilité. Pour rappel, les processeurs modernes intègrent en réalité deux multiplieurs différents. Le résultat d'une multiplication est très large et prend deux registres. Par exemple, une multiplication de deux opérandes 64 bits donne un résultat sur 128 bits, ce qui ne tient pas dans un registre, mais en prend deux. L'instruction de multiplication classique ne calcule que les 64 bits de poids faible. mais elle est complétée par d'autres instructions de multiplication qui peuvent faire le calcul complet. Pour résoudre le problème, les processeurs modernes intègrent deux instructions de multiplications : une qui calcule les bits de poids faible du résultat, une autre qui calcule les bits de poids fort. Et ces deux instructions sont associées à deux multiplieurs séparés, aux circuits différents. Le multiplieur qui calcule les bits de poids faible du résultat est appelé le ''low multiplier'', l'autre est appelé le ''high multiplier''. Le second multiplieur est souvent placé sur un second port d'émission, différent du premier multiplieur, partagé avec une ALU. L’avantage de mettre les deux multiplieurs sur deux ports d'émission différents est qu'une multiplication complète peut être réalisée en utilisant les deux multiplieurs en parallèle. Les deux sous-multiplications sont émises en même temps, exécutées en même temps. pas besoin d'exécuter l'une puis la seconde, d'où un gain performance appréciable. Nous venons de voir comment partager un port d'émission entre une ALU et un multiplieur. Mais au-delà de cet exemple, le partage des ports d'émission peut appliquer à n'importe quelle unité. Prenons l'exemple d'un CPU superscalaire avec 4 ALU entières, une FPU, et une unité mémoire. Vu qu'il y a 6 unités, cela fait donc 6 ports d'émission. Cependant, il est rare qu'un bloc de 6 instructions contienne exactement 4 opérations entières, une opération flottante, et un accès mémoire. Beaucoup de ports d'émission seraient sous-utilisés. La solution est de partager des ports d'émission, pour passer à la quadruple émission. Le CPU peut alors répartir 4 instructions sur les ports d'émission adéquats, à chaque cycle. Le CPU a donc 4 ports de décodage et 6 ports d'émission. Une implémentation classique permet n'importe quelle combinaison de 4 instructions compatibles avec les 6 unités. Par exemple, il devient possible d'émettre simultanément 4 additions, la combinaison 2 * INT + FLOAT + MEM, 3 * INT + MEM, et bien d'autres. L'exemple relie une ALU entière, la FPU et l'unité mémoire sur le même port. Le CPU a alors quatre ports d'émission, qui ne sont pas "identiques". Trois d'entre eux sont reliés à une ALU entière chacun, rien d'autre. Le quatrième est lui relié à une ALU entière, la FPU et l'unité mémoire. On économise alors deux ports d'émission, mais cela entraine l'apparition de contraintes d’appariement. Le CPU peut émettre 3 opérations entières + n'importe quelle autre opération. Mais les autres combinaisons sont interdites. Le CPU peut émettre 6 opérations entières simultanément, grâce à 6 ALU entières. Les accès mémoire ou opérations flottantes ne viennent pas en plus, mais en remplacement des opérations entières. Regrouper plusieurs ALU sur un même port d'émission est donc à l'origine de dépendances structurelles, de nouvelles contraintes d’appariement. Dans certaines situations de '''''port contention''''', on a assez d'ALU pour exécuter N micro-opérations, mais la répartition des ALUs sur les ports l'empêche. Impossible d'émettre deux micro-opérations sur deux ALU si elles sont sur le même port d'émission. On peut réduire les cas de ''port contention'' en répartissant correctement les ALU sur les ports d'émission, mais sans jamais les supprimer complétement. Un exemple est celui du PowerPC 604. Il implémentait la quadruple émission, alors qu'il avait 5 unités de calcul. Mais deux unités partageaient un même port d'émission. Dans le détail, il avait deux ALU entières, un circuit multiplieur/diviseur, une FPU, une unité mémoire. La FPU et le multiplieur étaient sur le même port d'émission. En conséquence, le CPU pouvait émettre seulement les combinaisons INT + INT + MEM + (MUL/FLOAT). : Il est intéressant de voir l'exemple d'un processeur superscalaire parfait, sans contraintes d'appariement, à N voies. Pour cela, on part d'un processeur non-superscalaire, avec M unités de calcul. Un tel processeur peut s'implémenter avec N ports d'émission, chacun relié à M unités de calcul possibles, ce qui fait que les M unités de calcul sont dupliquées en N exemplaires. Si on prend par exemple un CPU non-superscalaire avec une ALU, une FPU, une unité mémoire et une unité de branchement, un port d'émission sera relié à une ALU, une FPU, une unité mémoire et une unité de branchement. Dupliquer les ALU entière est une première étape, mais il est aussi possible de dupliquer d'autres unités, comme l'unité mémoire ou l'unité de branchement. La rentabilité en performance dépend surtout de si cela autorise des paires d'instructions fréquentes. Le cout en circuit n'est aussi pas le même. Sur les CPU superscalaires ''in-order'', seuls les ALU et FPU sont dupliquées. ===Les CPU superscalaires mélangent les trois techniques=== Les CPU superscalaires utilisent toutes les techniques à la fois : partitionnement, duplication de l'ALU entière, duplication des autres unités fonctionnelles. Chaque processeur utilise ces trois stratégies à sa sauce, en fonction de son budget en transistor. Les quatre techniques ont cependant un cout en circuit différent, et donnent des gains de performances eux aussi forts différents. * Le '''partitionnement superscalaire''' a un cout en circuit modéré, car on n'a pas besoin de dupliquer d'ALU. * La '''duplication des ALU entières''' est la technique la plus importante : elle a un cout en circuit modéré, et donne des gains en performance impressionnants. * Le '''partage des ports d'émission''' réduit le cout en circuit du CPU, pour des pertes de performances mineures si bien fait. L'usage du partitionnement seul, sans duplication, était surtout le fait des processeurs commercialisés entre les années 1989 et 1993. De nos jours, les processeurs utilisent un mix différent d'unités, et leur partitionnement est différent. Ils tendent à s'adapter au mieux aux besoins des programmes exécutés. D'après diverses estimations, un programme informatique exécute environ 40% de son code dans des instructions arithmétiques, un autre 40% dans les accès mémoire, et un le reste pour... le reste. L'idéal est donc de pouvoir émettre autant de µops mémoire que de µops entières, à chaque cycle. Dit autrement, la largeur du processeur doit pouvoir être répartie ''moitié opération - moitié mémoire''. Bien évidemment, le processeur peut gérer d'autres combinaisons. Pour s'adapter, les processeurs dupliquent d'abord les ALU entières, suivi par l'unité mémoire, et enfin par les autres unités. Pour s’adapter au mieux, un processeur à N voies doit disposer d'au moins N/2 ALU entières, l'idéal étant d'aller au-delà. Les CPU superscalaires historiques avaient 2 ALU pour de la quadruple émission, ce qui était très limite. Les CPU superscalaires modernes mettent le paquet niveau ALU entière. La plupart des CPU x86 modernes descendent rarement sous les 2/3, la plupart remplissent presque la totalité de la largeur du CPU. Pour donner un exemple, la micro-architecture Golden Cove d'Intel est un processeur à 6 voies, avec 5 ALU entières. Ensuite, les processeurs modernes dupliquent leurs unités mémoire. Par exemple, la micro-architecture ''Golden Cove'' d'Intel dispose de cinq unités mémoire : deux spécialisées dans les écritures, trois dans les lectures. La micro-architecture ''Gracemont'' peut décoder 5 instructions, mais émettre 4 µops entières et 4 µops mémoire. Notez que le ratio est équilibré entre opérations entières et mémoire, avec soit égalité, soit un avantage pour les ALU entières. Une autre régularité est que la largeur totale du processeur peut être occupée par des opérations entières/mémoire. Pour le dire autrement, sur un processeur quadruple émission, il pourra émettre un mix de 4 instructions entière/mémoire, avec zéro opération flottante. Idem avec des processeurs triples ou quintuple émission, voire plus. Si le processeur émet une opération flottante, elle remplace une instruction entière ou mémoire. Prenons l'exemple des processeurs AMD K6, sortis en 1997, juste avant les AMD Athlon. Ils avaient 10 unités de calcul, 6 ports d'émission, 4 ports de décodage. Voici exactement comment étaient répartie les unités de calcul sur les ports d'émission. C'était des processeurs quadruple émission, ce qui fait que la moitié de la largeur du processeur sert pour des opérations entières, et l'autre moitié pour les accès mémoire. {|class="wikitable" |- ! Port d'émission n°1 !! Port d'émission n°2 !! Port d'émission n°3 !! Port d'émission n°4 !! Port d'émission n°5 !! Port d'émission n°6 ! |- | ALU entière || ALU entière || FPU || Unité de branchement || Unité LOAD || Unité STORE |- | || Multiplieur || || || || Unité de calcul d'adresse (instruction LEA) |- | || ''Barrel shifter'' || || || || Unité pour les instructions PUSH |} Les micro-architectures K7, K8 et K10, utilisées sur les anciens CPU AMD Athlon, sont un exemple moins représentatif. Ces micro-architectures utilisent le partage des ports d'émission au maximum. C'était des micro-architectures à triple émission, avec six ports d’émission. Il y avait trois ports d'émission pour les opérations flottantes, et trois ports d'émission pour les micro-opérations entières/mémoire. Il y avait trois ALU entières et trois unités mémoire, chaque ALU partageait un port d'émission avec une unité mémoire. Elle pouvait donc émettre soit trois opérations entières, soit trois µops mémoire, soit un mix des deux. En plus des mix avec les opérations flottantes et branchements. {|class="wikitable" |- ! Port d'émission n°1 !! Port d'émission n°2 !! Port d'émission n°3 !! Port d'émission n°4 !! Port d'émission n°5 !! Port d'émission n°6 ! |- | ALU entière || ALU entière || ALU entière || FADD || FMUL || FPU (autres opérations) |- | Multiplieur || || || || || |- | LOAD/STORE || LOAD/STORE || LOAD/STORE || || || |} Les CPU superscalaires larges dupliquent les multiplieurs et/ou les ''barrel shifters'', et même les unités de branchement. La raison est que quand la largeur du CPU augmente, le processeur traite des paquets d'instructions de plus en plus gros. Par exemple, pour un CPU à 10 voies, cela veut dire émettre 10 instructions en même temps. Il est alors plus fréquent d'avoir deux multiplications, deux décalages, deux accès mémoire, deux branchements. Par exemple, la moyenne est d'une multiplication tous les 5 instructions. Les CPU superscalaires à 10 voies ont donc tendance à avoir deux multiplications par paquet d'instruction, en moyenne. Avoir deux multiplieurs est alors un gain en performance clair. Par exemple, la micro-architecture ''Golden Cove'' a deux ''barrel shifters''. C'est une architecture à 6 voies. {|class="wikitable" |- ! Port d'émission n°1 !! Port d'émission n°2 !! Port d'émission n°3 !! Port d'émission n°4 !! Port d'émission n°5 !! Port d'émission n°6 !! Port d'émission n°7 !! Port d'émission n°8 !! Port d'émission n°9 !! Port d'émission n°10 |- | ALU entière || ALU entière || ALU entière || ALU entière || ALU entière || LOAD || LOAD || LOAD || STORE || STORE |- | ''Barrel shifter'' || Multiplieur || Multiplieur || ''Barrel shifter'' || || || || || || |- | Branchements || Diviseur || || Branchements || || || || || || |} ==Les processeurs ''in-order'' à double émission== Dans cette section, nous allons étudier la forme la plus basique de superscalarité : les processeurs à double émission. De tels processeurs n'utilisent généralement pas l'exécution dans le désordre, mais il y a quelques exceptions. Les premiers CPU à double émission sont assez représentatifs des CPU à double émission modernes. Il faut dire que se limiter à deux voies ne permet pas de faire beaucoup de choses. En général, les processeurs à double émission ne dupliquent pas l'unité mémoire. Cela ne servirait à rien de faire deux accès mémoire simultanés sur un processeur à double émission. Et il en est de même pour la FPU et l'unité de branchement. Ils se limitent à deux techniques : dupliquer l'ALU entière, le partitionnement. Quant au partage des ports d'émission, c'est là que les ennuis commencent. L'implémentation la plus simple de la double émission utilise deux ports d'émission. Elle donne l'illusion que le processeur dispose de '''deux pipelines séparés'''. Ils partent d'un processeur non-superscalaire, et ajoutent un second pipeline, en sortie de l'unité d'émission. Le second pipeline permet l'émission parallèle d'une seconde instruction. Le problème est que les deux ports d'émission sont reliés à un grand nombre d'unités de calcul. L'unité d'émission doit donc partager des ports d'émission, et c'est là que les subtilités commencent. Il faut répartir les unités au mieux pour éviter d'avoir trop de contraintes d’appariement problématiques. Le second pipeline n'est pas identique au pipeline originel, sur plusieurs points. Premièrement, il n'a pas exactement les mêmes circuits. Le second pipeline est généralement composé d'une unité de calcul, et permet d'exécuter une seconde opération de calcul, pas plus. En conséquence, les contraintes d'appariement assez fortes. Et il n'a pas forcément la même longueur, il peut avoir des étages en moins ou en plus. Il s'agit de la forme la plus simple de processeur superscalaire et nous allons leur consacrer cette section. Une autre implémentation utilise de la double émission, mais avec 3, 4, 5, ... ports d'émission. Le processeur ne donne plus l'impression d'avoir deux pipelines séparés, mais gagne en flexibilité. Les contraintes d'appariement sont plus faibles, moins nombreuses, moins problématiques. Mais le cout en circuit n'est pas négligeable. Voici le tableau chronologique disant quel CPU utilisait quelle technique. * Les CPU en vert ajoutaient un second pipeline pour les opérations flottantes. * Les CPU en rouge ajoutaient un second pipeline pour les opérations entières. * Les CPU en jaune utilisaient un ''partitionnement complet'' entre ALU, FPU et unité mémoire. * Les CPU sans couleur utilisaient un grand nombre d'unités, avec beaucoup de ports d'émission. * Les CPU barrés utilisent la triple ou quadruple émission et seront vu dans la suite. {|class="wikitable" |- ! !! IBM !! HP PA-RISC !! SPARC !! Intel!! Alpha !! Autres |- ! 1989 | class="f_vert | RS/6000 || || || class="f_rouge | i960CA || || |- ! 1990 | || || || || || |- ! 1991 | || || || || || Motorola 88110 |- ! 1992 | class="f_vert | RSC || class="f_vert | PA 7100 || <s>SuperSPARC</s> || || class="f_jaune | 21064 || |- ! 1993 | class="f_vert | PPC 601 || || class="f_jaune | HyperSPARC || class="f_rouge | Pentium || || |- ! 1994 | <s>POWER 2</s> || PA 7200 || || || <s>21164</s> || |} ===La double émission entière-flottante=== L'exemple le plus simple utilise un second pipeline pour les opérations flottantes, séparé du pipeline originel. L'ajout d'un pipeline flottant permet d'émettre une micro-opération entière en même temps qu'une micro-opération flottante, ce qui s'appelle la '''double émission entière-flottante'''. La micro-opération entière s'exécute dans l'ALU entière, la micro-opération flottante dans la FPU. Les exemples les plus notables sont le PA-RISC 7100, le POWER 1 (RIOS .& et .9) et le PowerPC 601. L'Apollo DN10000 et l'Intel i860 utilisaient aussi cette forme de double émission, mais étaient des prototypes de CPU VLIW. [[File:Double émission entière-flottante.png|centre|vignette|upright=2|Double émission entière-flottante]] Pour bien expliquer les choses, prenons le processeur illustré ci-dessous, avec deux ''back-end'' séparés : un pour la FPU, et un autre pour le reste. Les instructions entières et mémoire passent toutes par le ''back-end'' généraliste. {| |[[File:Processeur non-superscalaire basique 01.png|centre|vignette|upright=1.5|Processeur non-superscalaire basique]] |[[File:CPU non-superscalaire basique, avec une FPU.png|centre|vignette|upright=1.5|CPU non-superscalaire basique, avec une FPU]] |} Les deux ''back-end'' sont des pipelines séparés, mais il faut que l'unité d'émission soit spécialement conçue pour en profiter. Un processeur simple émission peut démarrer une seule µop par cycle : soit la µop est envoyée au ''back-end'' flottant, soit elle est envoyée au ''back-end'' général. Avec la double émission entière-flottante, le processeur peut envoyer une µop dans chaque pipeline, à chaque cycle. Vu que la FPU et son banc de registre forment déjà un pipeline séparé, les seules modifications se font au niveau du ''front-end'', unité d'émission inclue. {| |- |[[File:CPU simple émission avec une FPU.png|centre|vignette|upright=1.5|CPU simple émission avec une FPU.]] |[[File:Double émission entière flottante avec un second pipeline.png|centre|vignette|upright=1.5|CPU avec un second pipeline flottant.]] |} Notez que le terme double émission ''entière''-flottante est un peu trompeur. En réalité, la technique permet d'émettre une instruction flottante avec n'importe quelle autre instruction non-flottante, avec des restrictions pour les branchements. Il est par exemple possible d'exécuter un calcul flottant en parallèle d'un accès mémoire. Sur les CPU 32 bits, il est possible de faire les multiplications dans la FPU. Pour rappel, la FPU contient un multiplieur entier 53-54 bits, pour multiplier les mantisses de deux flottants 64 bits, qu'on peut utiliser pour faire les multiplications entières 32 bits. Cela permet d'émettre une multiplication en même temps qu'une opération entière dans l'ALU. Ainsi, le CPU pouvait émettre deux instructions : une opération entière de base, avec soit une multiplication, soit une opération flottante. En clair, une émission de type INT + (MUL /FLOAT). Par contre, on perd la possibilité d'émettre une multiplication entière avec une opération flottante, mais c'était un compromis qui en valait la peine. Le CPU PA-RISC 7100 implémentait cette optimisation. Il s'agissait d'un CPU double émission entière-flottante, qui utilisait donc un partitionnement assez simple. Le combo double émission entière-flottante + multiplication dans la FPU permettait ici de gagner en performance, pour un cout en matériel très limité. D’autant plus limité que le jeu d'instruction prévoyait le reconditionnement de la FPU. Les multiplications ne lisaient pas leurs opérandes dans les registres entiers, mais dans les registres flottants. ===La double émission entière=== Une autre possibilité ajoute un second pipeline, capable d'exécuter des opérations entières simples, appelé le pipeline entier. Le processeur supporte alors la '''double émission entière''', à savoir qu'ils peuvent émettre une opération entière en plus d'une autre instruction. L'opération entière en question est une opération simple, comme une addition, une soustraction, une opération bit à bit, une comparaison, etc. Un exemple de CPU double émission de ce type est celui du Power PC 440, un CPU destiné à l'embarqué. Vu qu'il était destiné à l'embarqué, il n'avait pas de FPU, il n'avait pas besoin de manipuler de nombres flottants. Il n'a donc que des ALU entières, un multiplieur et une unité mémoire. Il dispose de deux ports d'émission. Le premier est connecté à une ALU entière et un circuit multiplieur, le second est relié à l'unité mémoire et une seconde ALU entière. Pour résumer : une ALU entière par port, l'unité mémoire sur un port, le multiplieur sur l'autre. Une organisation simple, mais très efficace ! [[File:PowerPC 440.png|centre|vignette|upright=2|Micro-architecture du PowerPC 440.]] Le processeur précédent n'avait pas de FPU, ce qui fait qu'il n'avait pas grand chose à connecter sur ses ports d'émission. le partage des ports d'émission était assez simple. Aussi il est intéressant de voir des exemples avec une FPU. Les exemples classiques sont les processeurs Intel i960 et Pentium. L'implémentation était différente entre l'i960 et le Pentium : Le Pentium avait une seconde ALU entière, alors que le i960 rusait. Le Pentium dupliquait l'ALU entière, pour créer un second pipeline entier. Sur le Pentium originel, les contraintes d'appariement étaient assez strictes. Le Pentium avait une FPU, mais ne pouvait pas l'utiliser en double émission. Il lui était impossible d'exécuter une opération flottante en même temps qu'une opération entière (branchements inclus). Le Cyrix 6x86 était une sorte de Pentium produit par l'entreprise Cyrix, avec une micro-architecture différente. Il avait des contraintes d’appariement réduites. Par exemple, il pouvait émettre une opération flottante en parallèle d'une opération entière. Nous détaillerons ces processeurs dans le prochain chapitre, qui porte justement sur les CPU superscalaires x86. [[File:CPU simple et double émission avec un second pipeline entier.png|centre|vignette|upright=2.5|CPU simple et double émission avec un second pipeline entier.]] L'Intel i960 n'avait pas de FPU, juste une ALU entière et une unité mémoire. Il utilisait l'unité mémoire comme une seconde ALU entière limitée. Pour rappel, une unité mémoire contient une AGU, à savoir une ALU spécialisée dans les calculs d'adresse. Elle est capable de faire des additions, soustractions, et quelques décalages limités (des décalages par 2, 4, 8, pas plus). L'Intel i960 ajoutait des connexions entre AGU et banc de registre, afin que celle-ci serve de seconde unité de calcul. L'AGU est déjà reliée aux deux ports de lecture, pour lire les opérandes du calcul d'adresse, il suffit de la connecter sur le port d'écriture. Le processeur peut alors émettre deux instructions/µops entières, sous certaines conditions. [[File:Emission multiple des opérations entières, double émission.png|centre|vignette|upright=1.5|Émission multiple des opérations entières, double émission.]] Le processeur alpha 21264 implémentait cette optimisation, mais c'était un processeur quadruple émission, aussi je le mentionne juste en passant. Il avait deux ALU entières et deux AGUs, les deux AGUs pouvaient faire des additions/soustractions et des opérations logiques. ===La double émission INT-FLOAT-MEM=== La '''double émission INT-FLOAT-MEM''' utilise la technique du partitionnement superscalaire, vue plus haut, avec cependant une petite subtilité. Elle utilise précisément une ALU entière, une FPU et une unité mémoire. En théorie, le partitionnement devrait donner un CPU triple émission, pas double émission. Et c'est là que la différence entre ports d'émission et de décodage devient intéressante. L'idée est d'avoir une unité d'émission avec deux ports de décodage, et 2 ports d'émission (un par unité). Les contraintes d’appariement varient alors suivant le processeur, mais la règle générale est que si deux micro-opérations tombent dans deux unités différentes, on peut les émettre en même temps. En clair, on peut émettre une opération entière avec une opération flottante, une opération entière avec un accès mémoire, une opération flottante avec un accès mémoire. Et cela rend le partitionnement bien plus performant que prévu. L'avantage sur la double émission flottante est que les combinaisons INT + MEM et FLOAT + MEM étaient autorisées. Le processeur pouvait donc émettre une opération en parallèle d'un accès mémoire, ce qui est un doublet d'instruction assez fréquent. Le gain en performance par rapport à la doublé émission entière-flottante était substantiel. Un détail est que cette forme de double émission ne peut pas s'interpréter comme un processeur à deux pipelines. Le processeur n'a pas deux ports d'émission, mais trois, et les contraintes d'appariement collent assez mal avec deux pipelines séparés. Il est possible d'interpréter le tout comme un CPU à trois pipelines, mais qui n'autorise que les paires d'instructions, mais ce serait assez tiré par les cheveux. Aussi, la notion de "plusieurs pipelines indépendants" ne marche pas pour décrire ce genre de processeurs superscalaires. Leur nombre de voies reste de deux, mais le nombre de pipelines est indéterminé. Les exemples classiques sont l'HyperSPARC, l'alpha 21064 et le PowerPC 603. Le PowerPC 603 implémentait une double émission "parfaite". C'est à dire que toutes les combinaisons possibles à deux instructions étaient possibles, que ce soit INT + FLOAT, INT + MEM, FLOAT + MEM. En plus de cette double émission "parfaite", le CPU utilisait la technique dite de ''branch folding'', qui permettait d'exécuter un branchement sans utiliser d'unité de calcul, ce qui fait qu'il était qualifié de ''CPU 2-way + Branch''. A l'opposé, le processeur alpha 21064 avait pas mal de contraintes d’appariement, à savoir que certaines combinaisons de deux instructions étaient interdites, bien que compatibles avec les unités disponibles. Une des raisons est que le banc de registre n'avait pas assez de ports de lectures, ce qui fait que certaines combinaisons avec une écriture mémoire n'étaient pas permises. Les concepteurs avaient volontairement inséré ces contraintes afin de simplifier le design du processeur. ===La double émission avec beaucoup d'unités entières=== Nous venons de voir qu'il est possible d'utiliser efficacement un surplus de ports d'émission, afin de réduire les contraintes d'appariement. Des ports d'émission qui étaient partagés sont simplement dupliqués et les ALU sont réparties sur des ports d'émission différents. Il est possible de pousser cette logique encore plus loin, en utilisant un grand nombre d'unité de calcul. L'exemple historique le plus impressionnant est celui du Motorola 88110, un CPU double émission qui possède pas moins de 10 unités ! Il contient deux ALU entières, un circuit multiplieur, un circuit diviseur, une unité de manipulation de bits, une unité mémoire, un additionneur flottant, et deux unités graphiques pour des calculs impliquant des pixels. Le multiplieur est utilisé à la fois pour les multiplications entières, flottantes et le produit de deux pixels. Idem pour le diviseur, qui sert pour les divisions entières et flottantes. Et le partitionnement est poussé au point où chaque unité a son propre port d'émission. La conséquence est que les contraintes d'appariement du Motorola 88110 sont simples : tant que deux µops atterrissent dans des unités séparées, la paire est permise. Il est par exemple possible d'émettre simultanément deux opérations entières simples, vu qu'il y a deux ALU. Ou encore, d'émettre une addition et une multiplication, une multiplication et un décalage, un décalage et une division, une addition entière et une addition flottante, etc. Par contre, impossible d'émettre simultanément deux multiplications, deux divisions, deux opérations flottantes, deux manipulations de bit, deux accès mémoire, etc. De plus, le partage du multiplieur fait qu'on ne peut pas émettre une multiplication entière avec une multiplication flottante. Idem avec les divisions, vu que le diviseur est aussi partagé. Ce sont les seules contraintes, et c'est très peu comparé aux autres processeurs double émission. Un autre exemple récent est celui des processeurs ARM Cortex A53, A55 et A510. Le fait que ce soit des processeurs récents fait qu'ils peuvent implémenter une double émission assez poussée, car leur budget en transistors n'est pas aussi limité que leurs ancêtres. Les contraintes d’appariement sont assez faibles, ils peuvent émettre des paires d'instructions que les autres CPU en double émission ne peuvent pas émettre. Pour les opérations flottantes, ils ont deux ports d'émission flottantes, chacun alimentant un additionneur flottant et un multiplieur flottant. En conséquence, ils peuvent émettre deux multiplications flottantes ou deux additions flottantes en même temps, grâce à la présence de deux additionneurs flottants et de deux multiplieurs flottants. Par contre, les instructions dites SIMD ne supportent pas la double émission, mais laissons-les de côté. Pour les opérations entières, le processeur a deux ALU entières, deux ''barrel shifters'', un multiplieur entier (en réalité une unité MAD), un diviseur entier, une unité de branchement. Le processeur peut donc émettre deux opérations entières simples (additions/soustraction et décalages), mais une seule multiplication/division par cycle (en parallèle d'une opération ALU) et un seul branchement. Il y a aussi quelques contraintes d’appariement qui interdisent des paires d'instructions pourtant compatibles avec les unités présentes. Par exemple, on ne peut pas émettre un branchement avec une multiplication entière. Le pipeline mémoire est séparé des autres, afin d'implémenter les ''lectures non-bloquantes''. Le pipeline mémoire est composé d'une unité pour les µops LOAD et une autre pour les µops STORE. Malgré la présence de ces deux unités, le Cortex A53 ne peut émettre qu'une seule instruction par cycle. Son successeur, le A55, peut lui émettre une lecture et une écriture par cycle. Les deux unités LOAD et STORE ont leur propre unité d'adresse, les calculs d'adresse ne sont pas fait dans l'ALU entière. L'implémentation est donc plus simple, vu que l'absence d'exécution dans le désordre fait qu'on n'a pas besoin de faire le calcul d'adresse séparément, pas besoin de scinder les µops mémoire en deux. Le processeur incorpore aussi un réseau de contournement, pour l'unité mémoire. Les données lues dans le cache peuvent aller en entrée des ALU entières, mais aussi en entrée de l'unité LOAD. Cela accélère le cas où une lecture a une adresse lue par une lecture précédente. Les caches sont de type non-bloquant, avec cependant des contraintes assez fortes. ===La double émission de µops dépendantes=== De rares processeurs pouvaient émettre deux µops dépendantes simultanément. Leur idée était de fusionner deux µops consécutives en une seule µops, mais qui faisait plusieurs calculs consécutifs. L'unité de calcul entière était fortement modifiée, et devenait capable d’exécuter deux µops simples consécutives. Les exemples les plus connus sont le processeur SuperSPARC, le processeur POWER 1, et le Motorola 68060. Un exemple est celui du processeur POWER 1, qui pouvait émettre deux additions si elles avaient une dépendance. Pour cela, l'ALU entière intègre un additionneur trois-opérande, ce qui permet de faire le calcul A + B + C. Le processeur SuperSPARC allait plus loin. Il disposait de trois ALU entières enchainées. Les deux premières ALU pouvaient travailler indépendamment les unes des autres. Le processeur peut émettre deux µops entières dedans, les deux ALUs vont en traiter une chacune. Mais les deux µops sont dépendantes, les deux ALU envoient leurs résultat àç une troisième ALU, qui combine les deux résultats. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Le parallélisme mémoire | prevText=Le parallélisme mémoire | next=Les processeurs superscalaires out-of-order | nextText=Les processeurs superscalaires out-of-order }} </noinclude> 85vavupa77cyvkz2s5xwviz0t8fh5i8 Planétologie/Le système solaire 0 69631 774419 707779 2026-10-08T05:06:47Z ~2026-54198-93 124691 774419 wikitext text/x-wiki <noinclude>{{Planétologie}}</noinclude> Le système solaire est composé du Soleil et de l'ensemble des planètes et autres corps qui gravitent autour du lui : satellites, planètes naines, astéroïdes, comètes et bien d'autres. La taille du système solaire est d'environ 10 000 milliards de kilomètres. Toutes les planètes tournent autour du Soleil dans le même sens. Si on regarde le Soleil du dessus, toutes les planètes tournent dans le sens des aiguilles d'une montre. De plus, elles tournent approximativement sur un même plan : l'écliptique (en réalité, les orbites ne sont pas exactement parallèles, et l'écliptique est le plan sur lequel se trouve l'orbite de la Terre, mais on omet ces détails pour le moment). <gallery mode="packed" heights="220px"> Solar System size to scale fr.svg|Illustration du système solaire. </gallery> Il est convenu de découper le système solaire en plusieurs portions : * un '''système solaire interne''', qui regroupe les quatre planètes solides : Mercure, Venus, Terre, Mars ; * une '''ceinture d’astéroïdes''', formée de petits corps, située entre Mars et Jupiter ; * un '''système solaire externe''', qui contient les quatre planètes gazeuses : Jupiter, Saturne, Uranus, Neptune ; * une '''région transneptunienne''', qui contient des corps glacés, dont Pluton. On sait peu de choses sur ce qu'il y a au-delà de la région transneptunienne. On suppose que cette zone relativement éloignée est emplie de petits corps glacés, et c'est vraisemblablement de là que proviennent certaines comètes. Ce réservoir à comètes, appelé le '''nuage de Oort''', n'est pour l'instant qu'une supposition. [[File:Kuiper belt oort cloud.svg|centre|vignette|upright=1.5|Ceinture de Kuiper et nuage de Oort.]] ==La classification des corps célestes de l'UAI - 2006== Peut-être avez-vous appris à l'école qu'il y a 9 planètes qui gravitent autour du soleil : Mercure, Venus, la Terre, Mars, Jupiter, Saturne, Uranus, Neptune et Pluton. Mais cela fait quelques années que les astronomes ont rétrogradé Pluton à l'état de "planète naine", faisant passer le nombre de planètes à 8. Cette décision peut paraître étrange, mais elle est motivée par la découverte de corps semblables à Pluton au confins du système solaire. Cela fait quelques années que les astronomes ont découvert des "planètes" semblables à Pluton : éloignement au soleil comparable, taille identique ou supérieure, etc. Ces mini-planètes similaires à Pluton, rapidement désignés sous le terme de '''plutoïdes'', avaient des caractéristiques suffisantes pour en faire des planètes, du moins si Pluton gardait son statut. Dans ces conditions, le système solaire aurait dû passer à plus de 13 planètes ! Ce qui a poussé l'Union Astronomique Internationale à revoir la classification des corps du système solaire, en 2006. Depuis, on classe les astres qui orbitent autour du Soleil d'une manière assez arbitraire, codifiée par l'Union astronomique internationale. Elle se base sur plusieurs critères. * Premièrement, on fait la distinction entre les corps qui tournent autour du Soleil de ceux qui tournent autour d'une planète. * Deuxièmement, on distingue les objets sphériques et ceux qui ne le sont pas. * Troisièmement, on distingue les corps qui ont « nettoyé » leurs environs grâce à leur force de gravité, et les autres, dont la masse ne le permet pas. Pour ce qui est des corps qui tournent autour du Soleil, cette classification distingue : les '''planètes''', les '''planètes naines''' et les '''petits corps'''. * Les petits corps regroupent les objets non sphériques comme les '''astéroïdes''', les '''comètes''' et quelques autres. * Contrairement aux petits corps, les planètes naines sont approximativement sphériques. Leur particularité est de ne pas avoir nettoyé leur entourage du fait d'une gravité trop faible. * Enfin, les planètes classiques sont des corps sphériques qui ont « nettoyé » leurs environs grâce à leur force de gravité, en attirant tous les corps qui les entouraient. : ''Les planètes naines et petits corps sont parfois regroupés sous le nom de planètes mineures, terme qui exclu cependant les comètes.'' Les '''satellites''', à savoir les corps qui orbitent autour d'une planète, sont placés dans une catégorie à part. {|class="wikitable" |- ! Nom ! Tournent autour du soleil ! Forme sphérique ! A nettoyé son entourage par sa gravité |- ! Satellite | Non, tournent autour d'une planète. | colspan="2" | Oui/Non |- ! Petit corps | rowspan="3" | Oui | Non | Non |- ! Planète naine | Oui | Non |- ! Planète | Oui | Oui |} La taille augmente des petits corps aux planètes : les petits corps sont les plus petits (quelques kilomètres de diamètres), les planètes naines sont intermédiaires (quelques centaines de kilomètres de diamètre) et les planètes sont les plus grosses (quelques milliers de kilomètres de diamètre). La raison à cela est liée à la gravité, au fait que plus un corps est gros, plus sa gravité est importante. Les corps les plus petits n'ont pas une gravité suffisante pour prendre une forme sphérique. Les corps plus gros sont assez massifs pour se sphériser et donnent des planètes, naines ou classiques. Les planètes naines sont assez grosses pour devenir sphériques, mais pas assez pour attirer les corps à proximité. ''A contrario'', les planètes classiques sont assez massives pour nettoyer leurs environ, tous les corps proches étant inexorablement attirés vers la planète et s'y écrasent. Cela explique aussi que les planètes se trouvent à proximité du Soleil, alors que les planètes naines et les petits corps se retrouvent dans tous le système solaire, y compris à des distances très éloignées. Les planètes, très grosses, sont fortement attirées par la gravité du Soleil, alors que les planètes naines et petits corps ont plus de latitude et peuvent s'en éloigner plus facilement. [[File:The Planets Definition 2.svg|centre|vignette|upright=2.0|Définition d'une planète]] {|class="wikitable" |- ! Type d'astre ! Nombre dans le système solaire |- ! Petit corps | Plus de 175 |- ! Planète naine | 5 : Cérès, Pluton, Charon, Éris, Makémaké et Haumea. |- ! Planète | 8 : Mercure, Vénus, Terre, Mars, Jupiter, Saturne, Uranus et Neptune. |} Avec cette nouvelle classification, Pluton se voit reléguée au rang de planète naine, au même titre que les autres plutoïdes. ==La classification des planètes== Les planètes sont des corps avec une variabilité assez importante. Par exemple, peu de choses sont comparables entre Jupiter et Mercure : leur taille, leur composition chimique, leur gravité, leur surface, leur atmosphère, etc. sont extrêmement différentes. Pour s'y retrouver, les astronomes ont établi diverses sous-classes, des catégories de planètes. Les distinctions entre ces planètes se fondent sur leur composition chimique et leur état gazeux ou rocheux. Ces corps sont relativement nombreux, sauf pour les planètes qui ne sont qu'au nombre de 8. ===Les corps transneptuniens=== [[File:SolarSystemBodies.png|vignette|upright=1.5|Corps du système solaire.]] La nomenclature distingue les corps transneptuniens du reste des planètes/astres/petits corps. Il faut dire qu'il existe une différence de composition chimique assez marquée : les corps transneptuniens sont riches en eau et en glaces, ainsi qu'en éléments chimiques légers/volatils. Ce qui n'est pas le cas des corps plus proches du Soleil, qui ont beaucoup plus d’éléments plus lourds, moins volatils. Cette distinction est orthogonale avec la distinction en satellite, petits corps, planètes naines et planètes. Les liens entre ces deux méthodes de classification sont illustrés dans le schéma ci-contre. Au-delà de la planète Neptune, on rentre dans le domaine des '''corps transneptuniens''', qui regroupent des satellites, des petits corps et des planètes naines. Ce sont, pour simplifier, de grosses boules de glace d'eau mélangées à des fragments rocheux, du méthane gelé et de l'ammoniac solide. L'expression consacrée dit que ce sont "des boules de neige sales". Les corps transneptuniens les plus célèbres ne sont autres que les comètes, de petits corps transneptuniens, mais ce ne sont pas les seuls. Sachez qu'il existe des planètes naines au-delà de Neptune, la région transneptunienne étant très riches en planètes naines. Les planètes naines au-delà de Neptune possèdent des points communs avec Pluton (éloignement du Soleil similaire, taille similaire), ce qui leur valu le nom de '''plutoïdes'''. Ils sont généralement de petite taille, la plupart étant plus petits que la Lune, malgré leur statut de planète naine. ===Les planètes gazeuses et telluriques=== Si on omet les objets transneptuniens, les corps du système solaires peuvent elles-mêmes se classer en un nombre limité de types. On distingue notamment les corps telluriques et les planètes gazeuses. Les '''planètes telluriques''' sont composées intégralement de roches et de métal, avec une atmosphère relativement fine, contrairement aux '''planètes gazeuses''' où la portion gazeuse est prédominante : ce sont de grosses boules de gaz qui entourent un petit noyau rocheux. {| |[[File:Planète tellurique générale.png|vignette|Planète tellurique.]] |[[File:Planète de type gazeux.png|vignette|Planète gazeuse.]] |} De nombreux caractères distinguent les planètes gazeuses des planètes telluriques. Par exemple, les planètes telluriques se sont formées à proximité du Soleil, alors que les planètes gazeuses se sont formées en périphérie du système solaire. Du fait de leur composition gazeuse, leur densité est bien plus faible que celle des planètes telluriques. Leur taille est beaucoup plus importante, de même que leur gravité. C'est d'ailleurs grâce à cela que les planètes gazeuses arrivent à conserver une atmosphère de plusieurs milliers de kilomètres d'épaisseur. {|class="wikitable" |- ! !Planète tellurique !Planète gazeuse |- !Distance au Soleil |Proches du soleil |Éloignées du soleil |- !État de la matière |Solides, formées de roches, de métaux et de glaces. |Gazeuses, formées de gaz qui entoure un cœur rocheux. |- !Composition chimique |Riches en Silicium, Oxygène, Fer et Magnésium |Riches en Hydrogène et Hélium |- !Densité |Forte densité, comprise entre 3 et 5,5. |Densité faible, proche de celle de l'eau. |- !Taille |Petite taille, similaire ou inférieure à celle de la Terre. |Grande taille, largement supérieure à celle de la Terre. |- !Atmosphère |Atmosphère ténue, parfois inexistante. |Atmosphère épaisse. |} Dans le système solaire, on trouve quatre planètes telluriques : Mercure, Vénus, la Terre et Mars, et quatre planètes gazeuses : Jupiter, Saturne, Uranus et Neptune. Outre les planètes, d'autres corps célestes peuvent être qualifiés de telluriques : les satellites de la plupart des planètes, les astéroïdes et météorites sont en effet des corps solides, la seule différence avec les planètes étant une faible taille. [[File:Solar System objects summary.png|centre|vignette|upright=2.0|Résumé des planètes du système solaire.]] <noinclude> {{NavChapitre | book=Planétologie | next=La formation du système solaire | nextText=La formation du système solaire }}{{autoCat}} </noinclude> 8qikcgftibdhs504o7vejlj20a7afr8 Mathc initiation/a06 0 76337 774355 735133 2026-10-07T12:00:28Z Xhungab 23827 774355 wikitext text/x-wiki __NOTOC__ [[Catégorie:Mathc initiation (livre)]] {{Partie{{{type|}}}|[[Mathc initiation/Fichiers h : d00|* Les nombres complexes]]}} {{Partie{{{type|}}}|[[Mathc initiation/a592|* Calculer le module et l'argument d'un nombre complexe]]}} {{AutoCat}} 1loeu01cbxfkfywx2blqqya9v1lmqnt Fonctionnement d'un ordinateur/Les méthodes de synchronisation entre processeur et périphériques 0 78309 774370 771167 2026-10-07T16:26:38Z Mewtow 31375 /* Les coprocesseurs d'entrée-sorties */ 774370 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'' (sur les anciens ''mainframes'' d'IBM), ''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.]] Les coprocesseurs I/O varient en complexité, allant de coprocesseurs IO très absiques à des coprocesseurs très évolués. Les plus simples sont des '''contrôleurs DMA programmables'''. Si on met de côté les rares contrôleurs DMA émulés avec un processeur, ce sont des contrôleurs DMA 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 plus évolués exécutent un véritable 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-processeurs 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, 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''. Les ''channel IO'' d'IBM avaient plusieurs ports d'entrée-sorties, chacun connecté à un bus/contrôleur de périphérique. Ils avaient essentiellement deux types de ports : les ''Selector Channel'' et les ''Byte Multiplexer Channel''. Les premiers étaient pour les périphériques rapides, avec des vitesses de plusieurs mébioctets par secondes, obtenues avec des transferts en rafale. Les ''Byte Multiplexer Channel'' étaient spécialisés pour les périphériques lents, avaient un débit de quelques centaines de kibioctets par secondes, et faisaient des transferts octet par octet. 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> n3rk9ildgb1fs9e1msu4p2dhyqlilmq 774371 774370 2026-10-07T16:34:08Z Mewtow 31375 /* Les coprocesseurs d'entrée-sorties */ 774371 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'' (sur les anciens ''mainframes'' d'IBM), ''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.]] Les coprocesseurs I/O varient en complexité, allant de coprocesseurs IO très absiques à des coprocesseurs très évolués. Les plus simples sont des '''contrôleurs DMA programmables'''. Si on met de côté les rares contrôleurs DMA émulés avec un processeur, ce sont des contrôleurs DMA 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 plus évolués exécutent un véritable 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-processeurs 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, 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''. Les ''channel IO'' d'IBM avaient plusieurs ports d'entrée-sorties, chacun connecté à un bus/contrôleur de périphérique. Ils étaient appelés les '''canaux IO''', ou ''channel IO'' et se classaient en deux types : les ''Selector Channel'' et les ''Byte Multiplexer Channel''. Les premiers étaient pour les périphériques rapides, avec des vitesses de plusieurs mébioctets par secondes, obtenues avec des transferts en rafale. Les ''Byte Multiplexer Channel'' étaient spécialisés pour les périphériques lents, avaient un débit de quelques centaines de kibioctets par secondes, et faisaient des transferts octet par octet. Par la suite, des canaux IO dédié aux connexions en fibre optique sont apparus. [[File:Canal IO.png|centre|vignette|upright=2|Canal IO]] 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> cvuiqj43vsytje2f3cpsp8bglpt519s 774372 774371 2026-10-07T16:34:18Z Mewtow 31375 /* Les coprocesseurs d'entrée-sorties */ 774372 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'' (sur les anciens ''mainframes'' d'IBM), ''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.]] Les coprocesseurs I/O varient en complexité, allant de coprocesseurs IO très absiques à des coprocesseurs très évolués. Les plus simples sont des '''contrôleurs DMA programmables'''. Si on met de côté les rares contrôleurs DMA émulés avec un processeur, ce sont des contrôleurs DMA 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 plus évolués exécutent un véritable 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-processeurs 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, 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''. Les ''channel IO'' d'IBM avaient plusieurs ports d'entrée-sorties, chacun connecté à un bus/contrôleur de périphérique. Ils étaient appelés les '''canaux IO''', ou ''channel IO'' et se classaient en deux types : les ''Selector Channel'' et les ''Byte Multiplexer Channel''. Les premiers étaient pour les périphériques rapides, avec des vitesses de plusieurs mébioctets par secondes, obtenues avec des transferts en rafale. Les ''Byte Multiplexer Channel'' étaient spécialisés pour les périphériques lents, avaient un débit de quelques centaines de kibioctets par secondes, et faisaient des transferts octet par octet. Par la suite, des canaux IO dédié aux connexions en fibre optique sont apparus. [[File:Canal IO.png|centre|vignette|upright=2.5|Canal IO]] 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> ox7orm5btvzr5274qe4so02kiggsvoq 774373 774372 2026-10-07T16:38:15Z Mewtow 31375 /* Les coprocesseurs d'entrée-sorties */ 774373 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'' (sur les anciens ''mainframes'' d'IBM), ''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.]] Les coprocesseurs I/O varient en complexité, allant de coprocesseurs IO très absiques à des coprocesseurs très évolués. Les plus simples sont des '''contrôleurs DMA programmables'''. Si on met de côté les rares contrôleurs DMA émulés avec un processeur, ce sont des contrôleurs DMA 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 plus évolués exécutent un véritable 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. 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. ===La connexion coprocesseur IO avec les périphériques : les canaux IO=== Les co-processeurs 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, 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''. Les ''channel IO'' d'IBM avaient plusieurs ports d'entrée-sorties, chacun connecté à un bus/contrôleur de périphérique. Ils étaient appelés les '''canaux IO''', ou ''channel IO''. Un cal IO commandait un contrôleur de périphérique, parfois plusieurs. Et ces contrôleurs commandaient eux-même plusieurs périphériques. Pour gérer la présence de plusieurs périphériques sur un même contrôleur/port/canal IO, les ''mainframes'' IBM supportaient des sous-canaux, identifiés par un numéro. Il s'agissait d'une abstraction qui cachait le fonctionnement réel du matériel. Le matériel utilisait généralement des adresses pour identifier les périphériques, ce qui n'était pas éloigné de cette abstraction. [[File:Canal IO.png|centre|vignette|upright=2.5|Canal IO]] Sur les ''mainframes IBM'', les canaux IO se classaient en deux types : les ''Selector Channel'' et les ''Byte Multiplexer Channel''. Les premiers étaient pour les périphériques rapides, avec des vitesses de plusieurs mébioctets par secondes, obtenues avec des transferts en rafale. Les ''Byte Multiplexer Channel'' étaient spécialisés pour les périphériques lents, avaient un débit de quelques centaines de kibioctets par secondes, et faisaient des transferts octet par octet. Par la suite, des canaux IO dédié aux connexions en fibre optique sont apparus. ===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> 7y7269mc872jm79qnoc3blrhgqdn588 774374 774373 2026-10-07T16:42:43Z Mewtow 31375 /* Les interconnexions entre coprocesseur IO, CPU et entrée-sorties */ 774374 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'' (sur les anciens ''mainframes'' d'IBM), ''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.]] Les coprocesseurs I/O varient en complexité, allant de coprocesseurs IO très absiques à des coprocesseurs très évolués. Les plus simples sont des '''contrôleurs DMA programmables'''. Si on met de côté les rares contrôleurs DMA émulés avec un processeur, ce sont des contrôleurs DMA 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 plus évolués exécutent un véritable 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. 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. ===La connexion coprocesseur IO avec les périphériques : les canaux IO=== Les co-processeurs 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, 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''. Les ''channel IO'' d'IBM avaient plusieurs ports d'entrée-sorties, chacun connecté à un bus/contrôleur de périphérique. Ils étaient appelés les '''canaux IO''', ou ''channel IO''. Un cal IO commandait un contrôleur de périphérique, parfois plusieurs. Et ces contrôleurs commandaient eux-même plusieurs périphériques. Pour gérer la présence de plusieurs périphériques sur un même contrôleur/port/canal IO, les ''mainframes'' IBM supportaient des sous-canaux, identifiés par un numéro. Il s'agissait d'une abstraction qui cachait le fonctionnement réel du matériel. Le matériel utilisait généralement des adresses pour identifier les périphériques, ce qui n'était pas éloigné de cette abstraction. [[File:Canal IO.png|centre|vignette|upright=2.5|Canal IO]] Sur les ''mainframes IBM'', les canaux IO se classaient en deux types : les ''Selector Channel'' et les ''Byte Multiplexer Channel''. Les premiers étaient pour les périphériques rapides, avec des vitesses de plusieurs mébioctets par secondes, obtenues avec des transferts en rafale. Les ''Byte Multiplexer Channel'' étaient spécialisés pour les périphériques lents, avaient un débit de quelques centaines de kibioctets par secondes, et faisaient des transferts octet par octet. Par la suite, des canaux IO dédié aux connexions en fibre optique sont apparus. ===Les interconnexions entre coprocesseur IO, CPU et RAM=== 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> 5sjl02na2olwr4su4xoxfc0u5wd0o9t Fonctionnement d'un ordinateur/L'espace d'adressage du processeur 0 79337 774358 765824 2026-10-07T13:50:10Z Mewtow 31375 /* Le mode long : l'adressage 64 bits */ 774358 wikitext text/x-wiki L''''espace d'adressage''' du processeur est l'ensemble des adresses utilisables par le processeur. Par exemple, un processeur 16 bits peut adresser 2^16 = 65536 adresses, l'ensemble de ces adresses forme son espace d'adressage. L'espace d'adressage n'est pas toujours égal à la mémoire réellement installée. S'il n'y a pas assez de RAM installée, des adresses seront inoccupées. De plus, une partie de l'espace d'adressage peut être détourné pour communiquer avec les périphériques, comme nous le verrons plus bas. Nous verrons aussi qu'un processeur peut avoir plusieurs espaces d'adressages séparés. ==L'adressage de la RAM et de la ROM== Avoir plusieurs espaces d'adressage spécialisés est quelque chose que nous avons déjà rencontré dans les chapitres précédents, mais sans le dire ouvertement. Aussi, nous allons faire quelques rappels sur les cas déjà rencontrés. En premier lieu, nous allons rappeler la différence entre architectures Von Neumann et Hardvard. La différence entre les deux tient dans l'adressage des mémoires RAM et ROM : est-ce qu'elles sont dans un seul espace d'adressage, ou dans des espaces d'adressage séparés. L''''architecture Von Neumann''' a un seul espace d'adressage, découpé entre la mémoire RAM d'un côté et la mémoire ROM de l'autre. Une adresse correspond soit à la mémoire RAM, soit à la mémoire ROM, mais pas aux deux. Typiquement, la mémoire ROM est placée dans les adresses hautes, les plus élevées, alors que la RAM est placée dans les adresses basses en commençant par l'adresse 0. C'est une convention qui n'est pas toujours respectée, aussi mieux vaut éviter de la tenir pour acquise. [[File:Vision de la mémoire par un processeur sur une architecture Von Neumann.png|centre|vignette|upright=2|Vision de la mémoire par un processeur sur une architecture Von Neumann.]] L''''architecture Harvard''' utilise des espaces d'adressage séparés pour la RAM et la ROM. Une même adresse peut correspondre soit à la ROM, soit à la RAM. Le processeur voit bien deux mémoires séparées, chacune dans son propre espace d'adressage. Les deux espaces d'adressage n'ont pas forcément la même taille. Il est possible d'avoir un plus gros espace d'adressage pour la RAM que pour la ROM. Cela implique que les adresses des instructions et des données soient de taille différentes. C'est peu pratique et c'est rarement implémenté, ce qui fait que le cas le plus courant est celui où les deux espaces d'adressages ont la même taille. [[File:Vision de la mémoire par un processeur sur une architecture Harvard.png|centre|vignette|upright=2|Vision de la mémoire par un processeur sur une architecture Harvard.]] Sur une '''architecture Harvard modifiée''', le programme est placé dans un espace d'adressage à part du reste. Le problème, c'est que cet espace d'adressage ne contient pas que le code machine à exécuter. Il contient aussi des constantes, à savoir des données qui gardent la même valeur lors de l'exécution du programme. Elles peuvent être lues, mais pas modifiées durant l'exécution du programme. L'accès à ces constantes demande d'aller lire celles-ci dans l'autre espace d'adressage, pour les copier dans les registres généraux. Le processeur doit pour cela avoir des instructions de lecture dédiées. En clair, il y a une instruction LOAD pour lire dans la mémoire ROM, une autre pour lire dans la RAM. [[File:Espaces d'adressage sur une archi harvard modifiée.png|centre|vignette|upright=3|Espaces d'adressage sur une archi harvard modifiée]] Une autre solution utilise des instructions de copie, qui copient les constantes en mémoire RAM, donc d'un espace d'adressage à l'autre. [[File:Organisation des espaces d'adressage sur une archi harvard modifiée.png|centre|vignette|upright=3|Organisation des espaces d'adressage sur une archi harvard modifiée]] ==L'adressage des périphériques== Passons maintenant à l'adressage des périphériques. La communication avec les périphériques se fait par l'intermédiaire de registres d’interfaçage. Et ces registres peuvent soit avoir un espace d'adressage séparé, soit être inclus dans l'espace d'adressage des mémoires. Dans ce qui suit, nous allons supposer que l'architecture des de type Von Neumann pour simplifier les explications. ===Les entrées-sorties mappées en mémoire=== Une partie de l'espace d'adressage peut être détourné pour communiquer avec les périphériques. Le périphérique se retrouve inclus dans l'ensemble des adresses utilisées pour manipuler la mémoire : on dit qu'il est mappé en mémoire. Les adresses mémoires associées à un périphérique sont redirigées automatiquement vers les registres du périphérique en question, voire vers sa mémoire intégrée. On parle alors d''''entrées-sorties mappées en mémoire'''. [[File:IO mappées en mémoire.png|centre|vignette|upright=2.0|IO mappées en mémoire]] On remarque un défaut inhérent à cette technique : les adresses utilisées pour les périphériques ne sont plus disponibles pour la RAM. On ne peut plus adresser autant de RAM qu'avant. La perte dépend des périphériques installés et de leur gourmandise en adresses mémoires. C'est ce qui causait un problème assez connu sur les ordinateurs 32 bits, capables d'adresser 4 gibioctets de mémoire. Certaines personnes installaient 4 gigaoctets de mémoire sur leur ordinateur 32 bits et se retrouvaient avec « seulement » 3,5 à 3,8 gigaoctets de mémoire, les périphériques prenant le reste. Il est possible que la RAM d'un périphérique soit mappée en RAM. Un exemple classique est celui des cartes graphiques qui incorporent une mémoire RAM appelée la mémoire vidéo. La mémoire vidéo est mappée en mémoire, ce qui permet au processeur d'écrire directement dedans. Toute lecture ou écriture dans les adresses associées est redirigée vers le bus PCI/AGP/PCI-Express. Nous verrons plus bas des exemples d'ordinateurs où la mémoire vidéo est mappée en mémoire, que ce soit totalement ou partiellement. Si je dis totalement ou partiellement, c'est parce que les cartes graphiques modernes disposent de tellement de mémoire qu'on ne peut pas la mapper totalement dans l'espace d'adressage. Sur les systèmes d'avant 2008, seuls 256 mégaoctets de mémoire vidéo sont mappés en mémoire RAM. Le reste de la mémoire vidéo est invisible du point de vue du processeur, mais manipulable par le GPU à sa guise. Après 2008, la spécification du PCI-Express ajouta un support de la technologie ''Resizable Bar'', qui permet au processeur d’accéder directement à plus de 256 mégaoctets de mémoire vidéo, voire à la totalité de la mémoire vidéo. ===L'espace d'adressage séparé pour les entrées-sorties=== Les entrées-sorties et périphériques peuvent avoir leur propre espace d'adressage dédié, séparé de celui utilisé pour les mémoires RAM et ROM. [[File:Espaces_d'adressages_séparés_entre_mémoire_et_périphérique.png|centre|vignette|upright=3|Bit IO.]] Une même adresse peut donc adresser soit une entrée-sortie, soit une case mémoire. Et pour faire la différence, le processeur doit avoir des instructions séparées pour adresser les périphériques et la mémoire. Il a des instructions de lecture/écriture pour lire/écrire en mémoire, et d'autres pour lire/écrire les registres d’interfaçage. Sans cela, le processeur ne saurait pas si une adresse est destinée à un périphérique ou à la mémoire. Cela élimine aussi les problèmes avec les caches : les accès à l'espace d'adressage de la RAM passent par l'intermédiaire de la mémoire cache, alors les accès dans l'espace d'adressage des périphériques le contournent totalement. Là encore, les deux espaces d'adressage n'ont pas forcément la même taille. Il arrive que les deux espaces d'adressage aient la même taille, le plus souvent sur des ordinateurs complexes avec beaucoup de périphériques. Mais les systèmes embarqués ont souvent des espaces d'adressage plus petits pour les périphériques que pour la ou les mémoires. L'implémentation varie grandement suivant le cas, la première méthode imposant d'avoir deux bus séparés pour les mémoires et les périphériques, l'autre permettant un certain partage du bus d'adresse. Nous reviendrons dessus plus en détail dans le chapitre sur l'adressage des périphériques. ==La ''memory map'' d'un ordinateur== Les deux sections précédentes nous ont appris que l'on peut utiliser un espace d'adressage séparé pour la ROM et un autre pour les périphériques. En tout, cela donne quatre possibilités distinctes. {|class="wikitable" |- ! ! IO mappées en mémoire ! IO séparées |- ! ROM mappée en mémoire (architecture Von Neumann) | Un seul espace d'adressage | Deux espaces d'adressage : * un pour les mémoires RAM/ROM ; * un pour les IO. |- ! ROM séparée (architecture Harvard) | Deux espaces d'adressage : * un pour la RAM et les IO ; * un pour la ROM. | Trois espaces d'adressages : * un pour la RAM ; * un pour la ROM ; * un pour les IO. |} Les quatre solutions ont des avantages et inconvénients divers, mais il est intéressant de contraster un espace d'adressage unique avec plusieurs espaces d'adressages. ===L'espace d'adressage unique=== Avec un espace d'adressage unique, la ROM est au sommet de l'espace d'adressage, les périphériques sont juste en-dessous, la RAM commence à l'adresse 0 et prend les adresses basses. Faire simplifie grandement l'implémentation matérielle de l'adressage. Notons que d'autres composants que les périphériques ou les mémoires peuvent se trouver dans l'espace d'adressage. On peut y trouver les horloges temps réels, des timers, des senseurs de température, ou d'autres composants placés sur la carte mère. Un exemple un peu original est le suivant : la console de jeu Nintendo DS incorporait une unité de calcul spécialisée dans les divisions et racines carrées, séparée du processeur, qui était justement mappée en mémoire ! [[File:Espace d'adressage classique avec entrées-sorties mappées en mémoire.png|centre|vignette|upright=2|Espace d'adressage classique avec entrées-sorties mappées en mémoire]] L'espace d'adressage unique est plus simple pour les programmeurs, malgré un désavantage dont il faut parler. Le problème est que des adresses censées être disponibles pour la RAM sont détournées vers la ROM ou les périphériques, ce qui limite la quantité de RAM qui peut être réellement adressée en pratique. C'est un défaut qui se manifeste seulement pour les petits espaces d'adressages, de 8 à 16 bits, qui ne permettent pas d'adresser beaucoup de RAM. Avec des adresses codées sur 16 bits, un espace d'adressage de 64 kibioctets de RAM est grignoté par des entrées-sorties assez rapidement. Par contre, pour des adresses de 32 à 64 bits, une bonne partie de l'espace d'adressage est inutilisé, car il n'y a pas assez de RAM. L'espace d'adressage unique n'a donc pas de désavantages. Les vielles consoles de jeux et certains vieux ordinateurs (Commodores et Amiga), utilisaient un espace d'adressage unique. Elles n'avaient pas une variété de cartes graphiques ou de cartes sons différentes, comme sur les PCs modernes. Au contraire, elles avaient la même configuration matérielle, le matériel ne pouvait pas être changé ni upgradé. Le système d'exploitation était rudimentaire et ne contrôlait pas vraiment l'accès au matériel. Les programmeurs avaient donc totalement accès au matériel et mapper les entrées/sorties en mémoire rendait la programmation des périphériques très simple. <gallery widths=200px heights=500px> N5200mk2 memory map.svg|Adressage mémoire (carte mémoire) du N5200mk2. PC-9801VM memory map.svg|Adressage mémoire (carte mémoire) du PC-9801VM. </gallery> ===La commutation de banques (''bank switching'')=== Le '''''bank switching''''', aussi appelé '''commutation de banque''', permet d'utiliser plusieurs espaces d'adressage sur un même processeur, sans attribuer chaque espace d'adressage pour une raison précise. L'espace d'adressage est présent en plusieurs exemplaires appelés des '''banques'''. Les banques sont numérotées, chaque numéro de banque permettant de l'identifier et de le sélectionner. Le but de cette technique est d'augmenter la mémoire disponible pour l'ordinateur. Par exemple, supposons que j'ai besoin d'adresser une mémoire ROM de 4 kibioctets, une RAM de 8 kibioctets, et divers périphériques. Le processeur a un bus d'adresse de 12 bits, ce qui limite l'espace d'adressage à 4 kibioctets. Dans ce cas, je peux réserver 4 banques : une pour la ROM, une pour les périphériques, et deux banques qui contiennent chacune la moitié de la RAM. La simplicité et l'efficacité de cette technique font qu'elle est beaucoup utilisée dans l'informatique embarquée. [[File:PC-8801MemoryMap.gif|centre|vignette|upright=2|exemple de Bank switching.]] Cette technique demande d'utiliser un bus d'adresse plus grand que les adresses du processeur. L'adresse réelle se calcule en concaténant le numéro de banque avec l'adresse accédée. Le numéro de la banque actuellement en cours d'utilisation est mémorisé dans un registre appelé le '''registre de banque'''. On peut changer de banque en changeant le contenu de ce registre. Le processeur dispose souvent d'instructions spécialisées qui en sont capables. {| class="wikitable flexible" |[[File:Banque mémoire.png|Banque mémoire.]] |[[File:Registre de banque.png|Registre de banque.]] |} ==Le placement des programmes dans l'espace d'adressage== Les ordinateurs modernes sont des architectures Von Neumann, avec un seul espace d'adressage, qui est partagé suivant les besoins. Et un point important est que le système d'exploitation et les programmes sont copiés en mémoire RAM avant d'être exécutés. Ils ne sont pas exécutés depuis une mémoire ROM, cela est réservé aux systèmes embarqués simples. Et cela a une influence sur l'espace d'adressage. Les programmes et le système d'exploitation doivent se partager l'espace d'adressage. Voyons voir comment ce partage se fait. Dans ce chapitre, nous allons volontairement mettre de côté les ordinateurs qui supportent la mémoire virtuelle. Nous en parlerons dans le prochain chapitre. Le principe est de voir comment un processeur avec un seul espace d'adressage gèrent la présence d'un ou de plusieurs programmes. ===Un seul espace d'adressage, non-partagé=== Le cas le plus simple est celui où il n'y a pas de système d'exploitation et où un seul programme s'exécute sur l'ordinateur. Le programme a alors accès à tout l'espace d'adressage, sauf pour ce qui est réservé aux périphériques et à la mémoire ROM. Le programme organise la mémoire en plusieurs sections, dans lesquelles le programme range des données différentes. Typiquement, on trouve quatre blocs de mémoire, appelés des segments, chacun étant spécialisé pour les données, le code, la pile, etc. Voici ces trois sections : * Le segment '''''text''''' 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. * Le segment pour la '''pile''', de taille variable. * le reste est appelé le '''tas''', de taille variable. [[File:Organisation d'un espace d'adressage unique utilisé par un programme unique.png|centre|vignette|upright=2|Organisation d'un espace d'adressage unique utilisé par un programme unique]] [[File:Typical computer data memory arrangement.png|vignette|upright=0.5|Typical computer data memory arrangement]] La pile et le tas sont de taille variable, ce qui veut dire qu'ils peuvent grandir ou diminuer à volonté, contrairement au reste. Entre le tas et la pile, on trouve un espace de mémoire inutilisée, qui peut être réquisitionné selon les besoins. La pile commence généralement à l'adresse la plus haute et grandit en descendant, alors que le tas grandit en remontant vers les adresses hautes. Il s'agit là d'une convention, rien de plus. Il est possible d'inverser la pile et le tas sans problème, c'est juste que cette organisation est rentrée dans les usages. Avant de poursuivre, précisons qu'il est possible de regrouper plusieurs programmes distincts dans un seul, afin qu'ils partagent le même morceau de mémoire. Les programmes portent alors le nom de '''threads'''. Les ''threads'' d'un même processus partagent le même espace d'adressage. Ils partagent généralement certains segments : ils se partagent le code, le tas et les données statiques. Par contre, chaque thread dispose de sa propre pile d'appel. [[File:Single vs multithreaded processes.jpg|centre|vignette|upright=2.0|Distinction entre processus mono et multi-thread.]] Il va de soi que cette vision de l'espace d'adressage ne tient pas compte des périphériques. C'est-à-dire que les schémas précédents partent du principe qu'on a un espace d'adressage séparé pour les périphériques. Dans le cas où les entrées-sorties sont mappées en mémoire, l'organisation est plus compliquée. Généralement, les adresses associées aux périphériques sont placées juste au-dessus de la pile, dans les adresses hautes. ===Un seul espace d'adressage, partagé avec le système d'exploitation=== [[File:Gestion de la mémoire sur les OS monoprogrammés.png|vignette|upright=1.0|Gestion de la mémoire sur les OS monoprogrammés.]] Maintenant, étudions le cas où le programme partage la mémoire avec le système d'exploitation. Sur les systèmes d'exploitation les plus simples, on ne peut lancer qu'un seul programme à la fois. Le système d'exploitation réserve une portion de taille fixe réservée au système d'exploitation, alors que le reste de la mémoire est utilisé pour le programme à exécuter. Le programme est placé à un endroit en RAM qui est toujours le même. Le système d'exploitation peut être soit placé dans une ROM, soit copié en mémoire RAM depuis une mémoire de masse, comme le programme à lancer. Si le système d'exploitation est copié en mémoire RAM, il est généralement placé dans les premières adresses, les adresses basses. A l'inverse, un OS en mémoire ROM est généralement placé à la fin de la mémoire, dans les adresses hautes. Mais tout cela n'est qu'une convention, et les exceptions sont monnaie courante. [[File:Méthodes d'allocation de la mémoire avec un espace d'adressage unique.png|centre|vignette|upright=2.0|Méthodes d'allocation de la mémoire avec un espace d'adressage unique]] Sur de tels systèmes, il faut protéger l'OS contre une erreur ou malveillance d'un programme utilisateur. Notons qu'il s'agit d'une protection en écriture, pas en lecture. Le programme peut parfaitement lire les données de l'OS sans problèmes, au moins en partie. Dans ce qui suit, on part du principe que l'OS est dans les adresses basses. La solution la plus courante interdit au programme d'écrire au-delà d'une limite au-delà de laquelle se trouve le système d’exploitation. Pour cela, le processeur incorpore un '''registre limite''', qui contient l'adresse limite au-delà de laquelle un programme peut pas écrire. Pour toute écriture en espace utilisateur, l'adresse écrite est comparée au registre limite. Si l'adresse est inférieure au registre limite, le programme cherche écrire dans la mémoire réservée à l'OS. L’accès mémoire est interdit, une exception matérielle est levée et l'OS affiche un message d'erreur. [[File:Circuit total.png|centre|vignette|upright=2.0|Protection mémoire avec un registre limite.]] ===Un seul espace d'adressage, partagé entre plusieurs programmes=== Les systèmes d’exploitation modernes implémentent la '''multiprogrammation''', le fait de pouvoir lancer 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. Et il faut alors résoudre un problème : le partage de la RAM entre les différents programmes. Les différents programmes et leurs données sont placés en mémoire RAM, et il faut trouver un moyen pour répartir les différents programmes en RAM. [[File:Partitions mémoire.png|vignette|upright=1|Partitions mémoire]] Il y a plusieurs solutions pour cela, mais l'une d'entre elle est l'usage de segments mémoire. Avec elle, chaque programme se voit attribuer un ou plusieurs blocs de mémoire. Ils sont appelés des '''partitions mémoire''', ou encore des '''segments'''. Dans ce qui va suivre, nous partons du principe qu'un programme est égal à un segment, pour simplifier les explications. Mais sachez qu'un programme peut être éclaté en plusieurs segments dispersés dans la mémoire, et même être conçu pour ! Nous en reparlerons dans le chapitre sur le mémoire virtuelle. Le système d'exploitation mémorise une liste des segments importants en mémoire RAM, appelée la ''table des segments''. Il s'agit d'un tableau dont chaque entrée mémorise les informations pour un segment. Une entrée de ce tableau est appelée un '''descripteur de segment''' et contient son adresse de base, sa taille, et des autorisations liées à la protection mémoire. Précisément, l'entrée numéro N mémorise les informations du segment numéro N. A partir de l'adresse de base de la table des segments et du numéro de segment, on peut lire ou écrire un descripteur de segment. [[File:Global Descriptor table.png|centre|vignette|upright=2|Global Descriptor table]] Toutefois, l'usage de segments amène un paquet de problèmes qu'il faut résoudre au mieux. Le premier problème est tout simplement de placer les segments dans l'espace d'adressage, mais c'est quelque chose qui est du ressort du système d'exploitation. Par contre, cela implique qu'un segment peut être placé n'importe où en RAM et sa position en RAM change à chaque exécution. En conséquence, les adresses des branchements et des données ne sont jamais les mêmes d'une exécution à l'autre. Pour résoudre ce problème, le compilateur considère que le segment commence à l'adresse zéro, puis les adresses sont corrigées avant ou pendant l'exécution du programme. Cette correction est en général réalisée par l'OS lors de la copie du programme en mémoire, mais il existe aussi des techniques matérielles, que nous verrons dans le chapitre suivant. Il faut aussi prendre en compte le phénomène de l''''allocation mémoire'''. Derrière ce nom barbare, se cache quelque chose de simple : les programmes peuvent réclamer de la mémoire au système d'exploitation, pour y placer des données. Les langages de programmation bas niveau supportent des fonctions comme malloc(), qui permettent de demander un bloc de mémoire de N octets à l'OS, qui doit alors accommoder la demande. S'il n'y a pas assez de mémoire, l'appel échoue et le programme doit gérer la situation. De même, un programme peut libérer de la mémoire qu'il n'utilise plus avec des fonctions comme free(). Avec des segments, cela revient à changer la taille d'un segment, plus précisément à la fin du segment. Et tout cela est géré par le système d'exploitation, aussi on laisse cela pour le prochain chapitre. Enfin, sans protection particulière, les programmes peuvent techniquement lire ou écrire les données des autres. Pour éviter cela, il suffit de vérifier qu'un programme accède bien à son segment et pas ailleurs. Il suffit de vérifier si l'adresse lue/écrite est dans le bon intervalle. Il suffit alors d'utiliser deux registres limites, rien de plus. Mais au-delà de cette simple vérification, si un programme pouvait modifier les données d'un autre programme, on se retrouverait rapidement avec une situation non prévue par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses. Il faut donc introduire des mécanismes de protection mémoire, pour isoler les programmes les uns des autres, et éviter toute modification problématique. La protection mémoire regroupe plusieurs techniques assez variées, qui ont des objectifs différents. Elles ont pour point commun de faire intervenir à des niveaux divers le système d'exploitation et le processeur. ==La ''memory map'' des PC IBM x86== Dans ce qui suit, nous allons étudier l'espace d'adressage des processeurs x86. L'organisation de leur espace d'adressage a évolué dans le temps et l'espace d'adressage s'est adapté. Mais il s'est adapté sans modifier l'existant, pour des raisons de compatibilité. Aussi, les processeurs x86 modernes peuvent fonctionner dans plusieurs modes, appelés mode réel, protégé, virtuel 8086, long, ''system management mode'', et compatibilité 32 bits. [[File:AMD64StateDiagram.svg|centre|vignette|upright=2|AMD64StateDiagram]] Le ''system management mode'' a déjà été abordé dans le chapitre sur les interruptions. Pour simplifier, il ne s'agit pas d'un véritable mode au sens "adressage". Dans ce mode, le système d'exploitation est mis en pause et le BIOS ou un autre firmware s'exécute sans restriction. On y rentre grâce à une interruption dédiée, lancée par le noyau de l'OS. Le système d'exploitation exécute cette interruption afin de laisser la main au BIOS, pour lui déléguer certaines tâches. Par contre, les autres modes sont bien plus intéressant, car ils ont chacun un espace d'adressage différent. Voyons-les dans le détail. ===Le mode réel : l'adressage sur 20 bits=== Le tout premier mode était le '''mode réel''', qui utilisait des adresses de 20 bits, ce qui fait 1 mébioctet de mémoire adressable. Il était utilisé sur des processeurs 16 bits, qui géraient donc des adresses de 16 bits capables d'adresser 64 kibioctets de mémoire. Pour contourner cette limitation, le processeur utilisait la segmentation, que nous verrons dans le prochain chapitre. Pour le moment, nous allons dire que la mémoire en mode réel était composée de 16 blocs de 64 kibioctets. C'est une simplification qui ne fera pas de mal. Le processeur démarre systématiquement en mode réel, y compris sur les ordinateurs modernes. Le BIOS s'exécute dans ce mode, avant de passer en mode protégé et de laisser la main à l'UEFI et au système d'exploitation. Autrefois, le système d'exploitation s'exécutait aussi en mode réel, c'est le cas du DOS. En mode réel, l'espace d'adressage est peuplé par de la RAM, la ROM du BIOS et des périphériques. La RAM prenait les adresses basses, la ROM était au sommet de l'espace d'adressage, et les périphériques entre les deux. Les premiers 640 kibioctets sont réservés à la RAM et sont appelés la '''mémoire conventionnelle'''. Les octets restants forment la '''mémoire haute''', réservée aux ROM et aux périphériques. Les deux premiers kibioctets de la mémoire conventionnelle sont initialisés au démarrage de l'ordinateur. Ils sont utilisés pour stocker le vecteur d'interruption (on expliquera cela dans quelques chapitres) et servent aussi au BIOS. La portion réservée au BIOS, la ''BIOS Data Area'', mémorise des informations en RAM. Elle commence à l'adresse 0040:0000h, a une taille de 255 octets, et est initialisée lors du démarrage de l'ordinateur. Le reste de la mémoire conventionnelle est réservée au le système d'exploitation (MS-DOS, avant sa version 5.0) et au programme en cours d’exécution. La mémoire haute est sectionnée en deux portions. Le sommet de la mémoire haute est réservé au BIOS, alors que le bas de la mémoire haute est réservé aux périphériques. Dans cette dernière, on trouve les BIOS des périphériques (dont celui de la carte vidéo, s'il existe), qui sont nécessaires pour les initialiser et pour communiquer avec eux. De plus, on y trouve la mémoire de la carte vidéo et éventuellement la mémoire d'autres périphériques comme la carte son. {| class="wikitable" style="text-align:center;" |+ Espace d'adressage en mode réel |- ! Numéro du bloc !! Contenu du bloc |- | 1024 - 960 Kio || class="f_jaune" | BIOS principal (ROM) |- | 960 Kio - 640 Kio | class="f_vert" | ROM d'extension des périphériques (XT, EGA, 3270 PC) </br> Mémoire vidéo des cartes EGA, MDA ou CGA |- | 640 Kio - 0 || class="f_rouge" | Mémoire RAM, mémoire conventionnelle |} Pour détailler un peu plus, il faut tenir compte du découpage de l'espace d'adressage en blocs de 64 kibioctets. Le découpage en blocs de 64 kibioctets est particulièrement important pour la mémoire haute. Des blocs sont attribués à un périphérique spécifique. Par exemple, le 16ème bloc est réservé au BIOS, le 12ème aux ROM des périphériques, le 10ème et le 11ème à la mémoire vidéo, etc. {| class="wikitable" style="text-align:center;" |+ Espace d'adressage en mode réel |- ! Numéro du bloc !! Contenu du bloc |- | 0 || rowspan="10" class="f_rouge" | Mémoire RAM, mémoire conventionnelle |- | 1 |- | 2 |- | 3 |- | 4 |- | 5 |- | 6 |- | 7 |- | 8 |- | 9 |- | 10 || class="f_vert" | Mémoire vidéo des cartes EGA |- | 11 || class="f_vert" | Mémoire vidéo des cartes MDA ou CGA |- | 12 || class="f_vert" | ROM d'extension des périphériques (XT, EGA, 3270 PC) |- | 13 || class="f_gris" | Autre, non-réservé |- | 14 || class="f_gris" | Autre, non-réservé |- | 15 || class="f_jaune" | ROM du BIOS |} Vous remarquerez que les blocs 13 et 14 sont non-réservés. Ils peuvent être utilisés pour mapper des périphériques en mémoire. Mais si aucun périphérique n'est mappé dedans, les blocs sont laissés libres. Les OS n'hésitaient pas à les utiliser pour adresser de la RAM. Par exemple, si l'ordinateur n'a pas de carte vidéo EGA, le bloc qui leur était réservé était réutilisé comme mémoire RAM. La mémoire conventionnelle passait alors de 640 à 704 kibioctets de RAM disponibles. L'espace d'adressage correspond à la ROM du BIOS. Mais avec le temps, la ROM est devenue plus lente au point qu'exécuter du code directement depuis la ROM du BIOS était un problème niveau performance. Je rappelle que le BIOS contient de nombreuses routines d'interruptions qui étaient appelées par les systèmes d'exploitation de l'époque, notamment le DOS. Heureusement, diverses optimisations permettaient de copier le BIOS dans la mémoire RAM, plus rapide que la ROM du BIOS. Il s'agit des techniques de '''''BIOS Shadowing'''''. Elles s'appliquaient au BIOS proprement dit, mais le BIOS vidéo pouvait aussi être optimisé ainsi, mais les ROM d'extension ne le sont généralement pas. Le ''BIOS Shadowing'' peut s'implémenter de plusieurs manières, mais la plus courante mappait temporairement de la RAM dans le même espace d'adressage que la ROM du BIOS. Le code du BIOS copiait le BIOS avec une boucle, dont chaque itération lisait une adresse du BIOS et écrivait dans cette même adresse. Juste avant la boucle, le ''chipset'' était configuré par le BIOS pour que les lectures atterrissent dans la ROM du BIOS, mais que les écritures tombaient dans une mémoire RAM mappée. Une fois la copie/boucle terminée, l'adressage tombait dans la RAM aussi bien pour les lectures et écritures. Elle demandait un support du ''chipset'' et du logiciel, pour détourner les adresses temporairement. ===Interlude : les technologies d'expanded memory=== Déjà à l'époque, 1 mébioctet était une limite assez faible, même si cela n'a pas empêché à un grand dirigeant de l'industrie informatique de dire que "640K ought to be enough for anybody". Aussi, diverses solutions matérielles ont vu le jour. Elles étaient peu utilisées et ont concrètement servi à des applications spécifiques. La principale est l''''''expanded memory''''' (EMS), une solution utilisant une carte d'extension avec plusieurs modules de RAM installés dessus. Les modules étaient accédés via la technique de la commutation de banque. [[File:Expanded memory.svg|centre|vignette|upright=2|L'espace d'adressage avec ''Expanded memory''. La carte d'extension et le matériel requis ne sont pas représentés.]] Le terme ''expanded memory'' regroupe plusieurs technologies propriétaires semblables sur le principe mais différentes dans l'exécution. Les premières utilisaient des banques de 64 kibioctets, qui étaient mappées dans un bloc de 64 kibioctets libre en mémoire haute. L'usage de banques de 64 kibioctets collait bien avec l'adressage particulier du mode réel, où la mémoire était gérée par blocs de 64 kibioctets. Les technologies ultérieures utilisaient 2 à 4 pages de 16 kibioctets, qui pouvaient être placée n'importe où en mémoire conventionnelle. [[File:EmulexPersyst 4M ISA.jpeg|centre|vignette|upright=2|Carte d'extension ''Expanded memory'' de marque Emulex Persyst, de 4 mébioctets.]] L'''expanded memory'' demandait que les programmes soient codés pour utiliser cette fonctionnalité. Pour cela, ils devaient utiliser une technique de programmation nommée ''Overlay programming''. L'idée est de découper le programme en plusieurs blocs séparés, appelés des '''''overlays'''''. Certains blocs sont en permanence en RAM, mais d'autres sont soit chargés en RAM, soit stockés ailleurs. Le ailleurs est souvent le disque dur, ou dans la RAM ''expanded memory'' si elle est disponible. Le va-et-vient des ''overlays'' entre RAM et disque dur était réalisé en logiciel, par le programme lui-même. [[File:Overlay Programming.svg|centre|vignette|upright=1.5|''Overlay Programming''.]] L'utilisation de l'''expanded memory'' utilisait l'interruption logicielle 67h, une interruption peu utilisée à l'époque, qui était libre. La carte d'extension était utilisée à travers un pilote de périphérique dédié, qui était appelé via l'interruption 67h. ===Le mode protégé : l'adressage sur 24 et 32 bits=== Par la suite, le mode réel a été remplacé par le '''mode protégé'''. Il utilise la mémoire virtuelle, ce qui fait qu'on devrait le détailler dans le chapitre suivant. Il permettait aussi de dépasser la limite du mébioctet de mémoire. L'espace d'adressage en mode protégé est passé de 24 bits sur le CPU 286, à 32 bits sur le 386 et les CPU suivants. Les programmes conçus pour le mode réel ne pouvaient pas s'exécuter en mode protégé. Pour corriger cela, le 286 a introduit le '''mode virtuel 8086''', aussi appelé mode V86. Il s'agit d'une technique de virtualisation, ce qui fait qu'on en parlera en détail dans le chapitre sur la virtualisation assistée en matériel. Tout ce que l'on peut dire ici est que le mode V86 a été détourné pour que les applications DOS utilisent la mémoire au-delà du mébioctet, appelée la '''mémoire étendue'''. Les logiciels DOS accédaient à la mémoire étendue à travers un driver DOS dédié (HIMEM.SYS). Les logiciels DOS déplaçaient des données dans la mémoire étendue, puis les rapatriaient en mémoire conventionnelle quand ils en avaient besoin. Il ne faut pas confondre mémoire étendue et ''expanded memory''. La technique de ''expanded memory'' repose sur de la commutation de banque, demande une carte d'extension dédiée, fonctionne sans mode V86. A l'inverse, la mémoire étendue ne gère pas de commutation de banque, ne demande pas de carte d'extension, mais utilise le mode V86. Par contre, il est possible d'émuler l'''expanded memory'' sans carte d'extension, avec la mémoire étendue. Quelques cartes mères intégraient des techniques pour, mais une émulation logicielle était aussi possible en réécrivant l'interruption 67h. ===Le mode long : l'adressage 64 bits=== Par la suite, les processeurs ont introduit le '''mode long''', dans lequel les adresses font 64 bits. Le CPU en mode long a aussi un jeu d'instruction totalement différent, des registres en plus et d'autres fonctionnalités actives uniquement dans ce mode. Les programmes compilés pour le 64 bits s'exécutent nativement dans ce mode, alors que les programmes 32 et 16 bits s'exécutent dans un mode de compatibilité dédié. Sur les systèmes 64 bits, le bus d'adresse fait seulement 48 bits afin d'économiser des interconnexions. Avec 48 bits, il manque 64 - 48 = 16 bits d'adresses, qui ne peuvent pas être utilisés pour adresser quoi que ce soit. La règle est que ces 16 bits doivent être tous égaux : soit ils valent tous 0, soient ils sont tous à 1. Les adresses qui respectent cette contrainte sont appelées des '''adresses canoniques'''. Le résultat est que l'espace d'adressage est coupé en trois sections, comme illustré ci-dessous. * Les '''adresses canoniques basses''' ont leurs 16 bits de poids fort à 0 et sont situées en bas de l'espace d'adressage, dans les premières adresses. * Les '''adresses canoniques hautes''' ont leurs 16 bits de poids fort à 1 et sont situées au sommet de l'espace d'adressage, dans les dernières adresses. * Entre les deux, se trouvent des '''adresses non-canoniques''', qui ne sont pas accessibles. Y accéder déclenche la levée d'une exception matérielle. {| |[[File:AMD64-canonical--48-bit.png|vignette|Espace d'adressage x86-64 bits, avec des adresses physiques de 48 bits.]] |[[File:AMD64-canonical--56-bit.png|vignette|Espace d'adressage x86-64 bits, avec des adresses physiques de 57 bits.]] |} : Les futurs systèmes x86 devraient passer à des adresses de 57 bits, les trois sections auront donc des frontières différentes. Les adresses non-canoniques sont censées être inutilisables. Mais les programmeurs aimeraient bien pouvoir les utiliser pour des ''pointeurs tagués'', à savoir des pointeurs/adresses associées à des informations. L'idée serait d'utiliser les 16 bits de poids fort pour stocker des informations liées au pointeur, seuls les 48 bits restant codant l'adresse. Les 16 bits peuvent être utilisés de manières très diverses. Un exemple est la technique du '''''memory tagging''''', qui crée une somme de contrôle au pointeur. La somme de contrôle est générée par un algorithme cryptographique, à partir de l'adresse du pointeur. Elle est vérifiée à chaque utilisation du pointeur. Si la somme de contrôle ne correspond pas au pointeur, une erreur est levée. Si jamais un virus ou un code malveillant modifie un pointeur, il ne saura pas comment calculer la somme de contrôle, la somme de contrôle a énormément de chances d'être incorrecte. Quelques fonctionnalités des processeurs visent à autoriser l'utilisation des adresses non-canoniques. L'idée est que les 16 bits de poids fort sont ignorées lors des accès mémoire, ce qui permet d'utiliser les 16 bits de poids fort à volonté. Il s'agit des techniques ''Top Byte Ignore'' d'ARM, ''Upper Address Ignore'' d'AMD, et ''Linear Address Masking'' d'Intel. ==La z/architecture d'IBM== IBM a sa propre gamme de ''mainframes'', qui utilisent des processeurs fait maison par IBM lui-même. Les CPU IBM ont leur propre jeu d'instruction CISC, appelé '''''z/architecture'''''. Et ce jeu d'instruction a des possibilités en termes d'adressage qui sont extrêmement intéressantes, en plus d'être assez inédites. Pour ce chapitre, nous allons nous concentrer sur une fonctionnalité : les programmes ont accès à plusieurs espaces d'adressage naturellement. Les CPU ''z/architecture'' peuvent fonctionner en quatre modes, dont l'un permet à un programme d'utiliser plusieurs espaces d'adressage. Les CPU ''z/architecture'' disposent de 16 registres généraux de 64 bits, de 16 registres flottants eux aussi de 64 bits, de 16 registres de contrôle eux aussi de 64 bits, et de '''registres d'accès''' qui gèrent les espaces d'adressage. Chaque registre entier est associé à un espace d'adressage particulier, qui est indiqué par son registre d'accès. Les registres d'accès sont utilisés lors des lectures/écritures indirectes. L'adresse est lue depuis un registre général, et son registre d'accès est consulté pour déterminer l'espace d'adressage. Une implémentation simple numéroterait les espaces d'adressage et mettrait les numéros dans les registres d'accès. Les registres d'accès seraient idéalement protégés en écriture et seulement accessibles par le système d'exploitation. Mais ce n'est pas ce qui est fait, pour des raisons de sécurité. Les registres d'accès mémorisent bien un identifiant, mais celui-ci ne sélectionne pas l'espace d'adressage directement. L'identifiant est envoyée à une table de correspondance matérielle, qui mémorise les correspondances entre un identifiant et un espace d'adressage. Il y a une table de correspondance par programme, ce qui fait qu'un identifiant ne correspondra pas au même espace d'adressage dans deux programmes différents. La table de correspondance mémorise aussi des droits d'accès, qui sont différents pour chaque programme. Par exemple, tel programme aura accès en lecture seule à tel espace d'adressage, alors qu'un autre programme y aura accès en lecture et écriture, etc. Un espace d'adressage peut ainsi être partagé entre deux programmes plus facilement, tout en ajoutant des garanties de sécurité. L'usage des registres d'accès est aussi plus flexible qu'on ne pourrait le croire. Le programme peut lire et écrire dans les registres d'accès, copier un registre d'accès dans un autre, etc. Par contre, la table de correspondance empêche à un programme d’accéder à un espace d'adressage arbitraire. Le système d'exploitation remplit la table de manière à n'autoriser l'accès qu'aux espaces d'adressage adéquats, il y enregistre seulement les correspondances utiles. Si un programme forge un identifiant arbitraire, et l'envoie à la table, celle-ci n'aura pas la correspondance associée et l'accès mémoire sera invalidé. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs 8 bits et moins | prevText=Les processeurs 8 bits et moins | next=L'abstraction mémoire et la mémoire virtuelle | nextText=L'abstraction mémoire et la mémoire virtuelle }} </noinclude> hgio47f08fwoqg8gpxfyrgr2pfte34x 774359 774358 2026-10-07T13:59:29Z Mewtow 31375 /* La z/architecture d'IBM */ 774359 wikitext text/x-wiki L''''espace d'adressage''' du processeur est l'ensemble des adresses utilisables par le processeur. Par exemple, un processeur 16 bits peut adresser 2^16 = 65536 adresses, l'ensemble de ces adresses forme son espace d'adressage. L'espace d'adressage n'est pas toujours égal à la mémoire réellement installée. S'il n'y a pas assez de RAM installée, des adresses seront inoccupées. De plus, une partie de l'espace d'adressage peut être détourné pour communiquer avec les périphériques, comme nous le verrons plus bas. Nous verrons aussi qu'un processeur peut avoir plusieurs espaces d'adressages séparés. ==L'adressage de la RAM et de la ROM== Avoir plusieurs espaces d'adressage spécialisés est quelque chose que nous avons déjà rencontré dans les chapitres précédents, mais sans le dire ouvertement. Aussi, nous allons faire quelques rappels sur les cas déjà rencontrés. En premier lieu, nous allons rappeler la différence entre architectures Von Neumann et Hardvard. La différence entre les deux tient dans l'adressage des mémoires RAM et ROM : est-ce qu'elles sont dans un seul espace d'adressage, ou dans des espaces d'adressage séparés. L''''architecture Von Neumann''' a un seul espace d'adressage, découpé entre la mémoire RAM d'un côté et la mémoire ROM de l'autre. Une adresse correspond soit à la mémoire RAM, soit à la mémoire ROM, mais pas aux deux. Typiquement, la mémoire ROM est placée dans les adresses hautes, les plus élevées, alors que la RAM est placée dans les adresses basses en commençant par l'adresse 0. C'est une convention qui n'est pas toujours respectée, aussi mieux vaut éviter de la tenir pour acquise. [[File:Vision de la mémoire par un processeur sur une architecture Von Neumann.png|centre|vignette|upright=2|Vision de la mémoire par un processeur sur une architecture Von Neumann.]] L''''architecture Harvard''' utilise des espaces d'adressage séparés pour la RAM et la ROM. Une même adresse peut correspondre soit à la ROM, soit à la RAM. Le processeur voit bien deux mémoires séparées, chacune dans son propre espace d'adressage. Les deux espaces d'adressage n'ont pas forcément la même taille. Il est possible d'avoir un plus gros espace d'adressage pour la RAM que pour la ROM. Cela implique que les adresses des instructions et des données soient de taille différentes. C'est peu pratique et c'est rarement implémenté, ce qui fait que le cas le plus courant est celui où les deux espaces d'adressages ont la même taille. [[File:Vision de la mémoire par un processeur sur une architecture Harvard.png|centre|vignette|upright=2|Vision de la mémoire par un processeur sur une architecture Harvard.]] Sur une '''architecture Harvard modifiée''', le programme est placé dans un espace d'adressage à part du reste. Le problème, c'est que cet espace d'adressage ne contient pas que le code machine à exécuter. Il contient aussi des constantes, à savoir des données qui gardent la même valeur lors de l'exécution du programme. Elles peuvent être lues, mais pas modifiées durant l'exécution du programme. L'accès à ces constantes demande d'aller lire celles-ci dans l'autre espace d'adressage, pour les copier dans les registres généraux. Le processeur doit pour cela avoir des instructions de lecture dédiées. En clair, il y a une instruction LOAD pour lire dans la mémoire ROM, une autre pour lire dans la RAM. [[File:Espaces d'adressage sur une archi harvard modifiée.png|centre|vignette|upright=3|Espaces d'adressage sur une archi harvard modifiée]] Une autre solution utilise des instructions de copie, qui copient les constantes en mémoire RAM, donc d'un espace d'adressage à l'autre. [[File:Organisation des espaces d'adressage sur une archi harvard modifiée.png|centre|vignette|upright=3|Organisation des espaces d'adressage sur une archi harvard modifiée]] ==L'adressage des périphériques== Passons maintenant à l'adressage des périphériques. La communication avec les périphériques se fait par l'intermédiaire de registres d’interfaçage. Et ces registres peuvent soit avoir un espace d'adressage séparé, soit être inclus dans l'espace d'adressage des mémoires. Dans ce qui suit, nous allons supposer que l'architecture des de type Von Neumann pour simplifier les explications. ===Les entrées-sorties mappées en mémoire=== Une partie de l'espace d'adressage peut être détourné pour communiquer avec les périphériques. Le périphérique se retrouve inclus dans l'ensemble des adresses utilisées pour manipuler la mémoire : on dit qu'il est mappé en mémoire. Les adresses mémoires associées à un périphérique sont redirigées automatiquement vers les registres du périphérique en question, voire vers sa mémoire intégrée. On parle alors d''''entrées-sorties mappées en mémoire'''. [[File:IO mappées en mémoire.png|centre|vignette|upright=2.0|IO mappées en mémoire]] On remarque un défaut inhérent à cette technique : les adresses utilisées pour les périphériques ne sont plus disponibles pour la RAM. On ne peut plus adresser autant de RAM qu'avant. La perte dépend des périphériques installés et de leur gourmandise en adresses mémoires. C'est ce qui causait un problème assez connu sur les ordinateurs 32 bits, capables d'adresser 4 gibioctets de mémoire. Certaines personnes installaient 4 gigaoctets de mémoire sur leur ordinateur 32 bits et se retrouvaient avec « seulement » 3,5 à 3,8 gigaoctets de mémoire, les périphériques prenant le reste. Il est possible que la RAM d'un périphérique soit mappée en RAM. Un exemple classique est celui des cartes graphiques qui incorporent une mémoire RAM appelée la mémoire vidéo. La mémoire vidéo est mappée en mémoire, ce qui permet au processeur d'écrire directement dedans. Toute lecture ou écriture dans les adresses associées est redirigée vers le bus PCI/AGP/PCI-Express. Nous verrons plus bas des exemples d'ordinateurs où la mémoire vidéo est mappée en mémoire, que ce soit totalement ou partiellement. Si je dis totalement ou partiellement, c'est parce que les cartes graphiques modernes disposent de tellement de mémoire qu'on ne peut pas la mapper totalement dans l'espace d'adressage. Sur les systèmes d'avant 2008, seuls 256 mégaoctets de mémoire vidéo sont mappés en mémoire RAM. Le reste de la mémoire vidéo est invisible du point de vue du processeur, mais manipulable par le GPU à sa guise. Après 2008, la spécification du PCI-Express ajouta un support de la technologie ''Resizable Bar'', qui permet au processeur d’accéder directement à plus de 256 mégaoctets de mémoire vidéo, voire à la totalité de la mémoire vidéo. ===L'espace d'adressage séparé pour les entrées-sorties=== Les entrées-sorties et périphériques peuvent avoir leur propre espace d'adressage dédié, séparé de celui utilisé pour les mémoires RAM et ROM. [[File:Espaces_d'adressages_séparés_entre_mémoire_et_périphérique.png|centre|vignette|upright=3|Bit IO.]] Une même adresse peut donc adresser soit une entrée-sortie, soit une case mémoire. Et pour faire la différence, le processeur doit avoir des instructions séparées pour adresser les périphériques et la mémoire. Il a des instructions de lecture/écriture pour lire/écrire en mémoire, et d'autres pour lire/écrire les registres d’interfaçage. Sans cela, le processeur ne saurait pas si une adresse est destinée à un périphérique ou à la mémoire. Cela élimine aussi les problèmes avec les caches : les accès à l'espace d'adressage de la RAM passent par l'intermédiaire de la mémoire cache, alors les accès dans l'espace d'adressage des périphériques le contournent totalement. Là encore, les deux espaces d'adressage n'ont pas forcément la même taille. Il arrive que les deux espaces d'adressage aient la même taille, le plus souvent sur des ordinateurs complexes avec beaucoup de périphériques. Mais les systèmes embarqués ont souvent des espaces d'adressage plus petits pour les périphériques que pour la ou les mémoires. L'implémentation varie grandement suivant le cas, la première méthode imposant d'avoir deux bus séparés pour les mémoires et les périphériques, l'autre permettant un certain partage du bus d'adresse. Nous reviendrons dessus plus en détail dans le chapitre sur l'adressage des périphériques. ==La ''memory map'' d'un ordinateur== Les deux sections précédentes nous ont appris que l'on peut utiliser un espace d'adressage séparé pour la ROM et un autre pour les périphériques. En tout, cela donne quatre possibilités distinctes. {|class="wikitable" |- ! ! IO mappées en mémoire ! IO séparées |- ! ROM mappée en mémoire (architecture Von Neumann) | Un seul espace d'adressage | Deux espaces d'adressage : * un pour les mémoires RAM/ROM ; * un pour les IO. |- ! ROM séparée (architecture Harvard) | Deux espaces d'adressage : * un pour la RAM et les IO ; * un pour la ROM. | Trois espaces d'adressages : * un pour la RAM ; * un pour la ROM ; * un pour les IO. |} Les quatre solutions ont des avantages et inconvénients divers, mais il est intéressant de contraster un espace d'adressage unique avec plusieurs espaces d'adressages. ===L'espace d'adressage unique=== Avec un espace d'adressage unique, la ROM est au sommet de l'espace d'adressage, les périphériques sont juste en-dessous, la RAM commence à l'adresse 0 et prend les adresses basses. Faire simplifie grandement l'implémentation matérielle de l'adressage. Notons que d'autres composants que les périphériques ou les mémoires peuvent se trouver dans l'espace d'adressage. On peut y trouver les horloges temps réels, des timers, des senseurs de température, ou d'autres composants placés sur la carte mère. Un exemple un peu original est le suivant : la console de jeu Nintendo DS incorporait une unité de calcul spécialisée dans les divisions et racines carrées, séparée du processeur, qui était justement mappée en mémoire ! [[File:Espace d'adressage classique avec entrées-sorties mappées en mémoire.png|centre|vignette|upright=2|Espace d'adressage classique avec entrées-sorties mappées en mémoire]] L'espace d'adressage unique est plus simple pour les programmeurs, malgré un désavantage dont il faut parler. Le problème est que des adresses censées être disponibles pour la RAM sont détournées vers la ROM ou les périphériques, ce qui limite la quantité de RAM qui peut être réellement adressée en pratique. C'est un défaut qui se manifeste seulement pour les petits espaces d'adressages, de 8 à 16 bits, qui ne permettent pas d'adresser beaucoup de RAM. Avec des adresses codées sur 16 bits, un espace d'adressage de 64 kibioctets de RAM est grignoté par des entrées-sorties assez rapidement. Par contre, pour des adresses de 32 à 64 bits, une bonne partie de l'espace d'adressage est inutilisé, car il n'y a pas assez de RAM. L'espace d'adressage unique n'a donc pas de désavantages. Les vielles consoles de jeux et certains vieux ordinateurs (Commodores et Amiga), utilisaient un espace d'adressage unique. Elles n'avaient pas une variété de cartes graphiques ou de cartes sons différentes, comme sur les PCs modernes. Au contraire, elles avaient la même configuration matérielle, le matériel ne pouvait pas être changé ni upgradé. Le système d'exploitation était rudimentaire et ne contrôlait pas vraiment l'accès au matériel. Les programmeurs avaient donc totalement accès au matériel et mapper les entrées/sorties en mémoire rendait la programmation des périphériques très simple. <gallery widths=200px heights=500px> N5200mk2 memory map.svg|Adressage mémoire (carte mémoire) du N5200mk2. PC-9801VM memory map.svg|Adressage mémoire (carte mémoire) du PC-9801VM. </gallery> ===La commutation de banques (''bank switching'')=== Le '''''bank switching''''', aussi appelé '''commutation de banque''', permet d'utiliser plusieurs espaces d'adressage sur un même processeur, sans attribuer chaque espace d'adressage pour une raison précise. L'espace d'adressage est présent en plusieurs exemplaires appelés des '''banques'''. Les banques sont numérotées, chaque numéro de banque permettant de l'identifier et de le sélectionner. Le but de cette technique est d'augmenter la mémoire disponible pour l'ordinateur. Par exemple, supposons que j'ai besoin d'adresser une mémoire ROM de 4 kibioctets, une RAM de 8 kibioctets, et divers périphériques. Le processeur a un bus d'adresse de 12 bits, ce qui limite l'espace d'adressage à 4 kibioctets. Dans ce cas, je peux réserver 4 banques : une pour la ROM, une pour les périphériques, et deux banques qui contiennent chacune la moitié de la RAM. La simplicité et l'efficacité de cette technique font qu'elle est beaucoup utilisée dans l'informatique embarquée. [[File:PC-8801MemoryMap.gif|centre|vignette|upright=2|exemple de Bank switching.]] Cette technique demande d'utiliser un bus d'adresse plus grand que les adresses du processeur. L'adresse réelle se calcule en concaténant le numéro de banque avec l'adresse accédée. Le numéro de la banque actuellement en cours d'utilisation est mémorisé dans un registre appelé le '''registre de banque'''. On peut changer de banque en changeant le contenu de ce registre. Le processeur dispose souvent d'instructions spécialisées qui en sont capables. {| class="wikitable flexible" |[[File:Banque mémoire.png|Banque mémoire.]] |[[File:Registre de banque.png|Registre de banque.]] |} ==Le placement des programmes dans l'espace d'adressage== Les ordinateurs modernes sont des architectures Von Neumann, avec un seul espace d'adressage, qui est partagé suivant les besoins. Et un point important est que le système d'exploitation et les programmes sont copiés en mémoire RAM avant d'être exécutés. Ils ne sont pas exécutés depuis une mémoire ROM, cela est réservé aux systèmes embarqués simples. Et cela a une influence sur l'espace d'adressage. Les programmes et le système d'exploitation doivent se partager l'espace d'adressage. Voyons voir comment ce partage se fait. Dans ce chapitre, nous allons volontairement mettre de côté les ordinateurs qui supportent la mémoire virtuelle. Nous en parlerons dans le prochain chapitre. Le principe est de voir comment un processeur avec un seul espace d'adressage gèrent la présence d'un ou de plusieurs programmes. ===Un seul espace d'adressage, non-partagé=== Le cas le plus simple est celui où il n'y a pas de système d'exploitation et où un seul programme s'exécute sur l'ordinateur. Le programme a alors accès à tout l'espace d'adressage, sauf pour ce qui est réservé aux périphériques et à la mémoire ROM. Le programme organise la mémoire en plusieurs sections, dans lesquelles le programme range des données différentes. Typiquement, on trouve quatre blocs de mémoire, appelés des segments, chacun étant spécialisé pour les données, le code, la pile, etc. Voici ces trois sections : * Le segment '''''text''''' 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. * Le segment pour la '''pile''', de taille variable. * le reste est appelé le '''tas''', de taille variable. [[File:Organisation d'un espace d'adressage unique utilisé par un programme unique.png|centre|vignette|upright=2|Organisation d'un espace d'adressage unique utilisé par un programme unique]] [[File:Typical computer data memory arrangement.png|vignette|upright=0.5|Typical computer data memory arrangement]] La pile et le tas sont de taille variable, ce qui veut dire qu'ils peuvent grandir ou diminuer à volonté, contrairement au reste. Entre le tas et la pile, on trouve un espace de mémoire inutilisée, qui peut être réquisitionné selon les besoins. La pile commence généralement à l'adresse la plus haute et grandit en descendant, alors que le tas grandit en remontant vers les adresses hautes. Il s'agit là d'une convention, rien de plus. Il est possible d'inverser la pile et le tas sans problème, c'est juste que cette organisation est rentrée dans les usages. Avant de poursuivre, précisons qu'il est possible de regrouper plusieurs programmes distincts dans un seul, afin qu'ils partagent le même morceau de mémoire. Les programmes portent alors le nom de '''threads'''. Les ''threads'' d'un même processus partagent le même espace d'adressage. Ils partagent généralement certains segments : ils se partagent le code, le tas et les données statiques. Par contre, chaque thread dispose de sa propre pile d'appel. [[File:Single vs multithreaded processes.jpg|centre|vignette|upright=2.0|Distinction entre processus mono et multi-thread.]] Il va de soi que cette vision de l'espace d'adressage ne tient pas compte des périphériques. C'est-à-dire que les schémas précédents partent du principe qu'on a un espace d'adressage séparé pour les périphériques. Dans le cas où les entrées-sorties sont mappées en mémoire, l'organisation est plus compliquée. Généralement, les adresses associées aux périphériques sont placées juste au-dessus de la pile, dans les adresses hautes. ===Un seul espace d'adressage, partagé avec le système d'exploitation=== [[File:Gestion de la mémoire sur les OS monoprogrammés.png|vignette|upright=1.0|Gestion de la mémoire sur les OS monoprogrammés.]] Maintenant, étudions le cas où le programme partage la mémoire avec le système d'exploitation. Sur les systèmes d'exploitation les plus simples, on ne peut lancer qu'un seul programme à la fois. Le système d'exploitation réserve une portion de taille fixe réservée au système d'exploitation, alors que le reste de la mémoire est utilisé pour le programme à exécuter. Le programme est placé à un endroit en RAM qui est toujours le même. Le système d'exploitation peut être soit placé dans une ROM, soit copié en mémoire RAM depuis une mémoire de masse, comme le programme à lancer. Si le système d'exploitation est copié en mémoire RAM, il est généralement placé dans les premières adresses, les adresses basses. A l'inverse, un OS en mémoire ROM est généralement placé à la fin de la mémoire, dans les adresses hautes. Mais tout cela n'est qu'une convention, et les exceptions sont monnaie courante. [[File:Méthodes d'allocation de la mémoire avec un espace d'adressage unique.png|centre|vignette|upright=2.0|Méthodes d'allocation de la mémoire avec un espace d'adressage unique]] Sur de tels systèmes, il faut protéger l'OS contre une erreur ou malveillance d'un programme utilisateur. Notons qu'il s'agit d'une protection en écriture, pas en lecture. Le programme peut parfaitement lire les données de l'OS sans problèmes, au moins en partie. Dans ce qui suit, on part du principe que l'OS est dans les adresses basses. La solution la plus courante interdit au programme d'écrire au-delà d'une limite au-delà de laquelle se trouve le système d’exploitation. Pour cela, le processeur incorpore un '''registre limite''', qui contient l'adresse limite au-delà de laquelle un programme peut pas écrire. Pour toute écriture en espace utilisateur, l'adresse écrite est comparée au registre limite. Si l'adresse est inférieure au registre limite, le programme cherche écrire dans la mémoire réservée à l'OS. L’accès mémoire est interdit, une exception matérielle est levée et l'OS affiche un message d'erreur. [[File:Circuit total.png|centre|vignette|upright=2.0|Protection mémoire avec un registre limite.]] ===Un seul espace d'adressage, partagé entre plusieurs programmes=== Les systèmes d’exploitation modernes implémentent la '''multiprogrammation''', le fait de pouvoir lancer 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. Et il faut alors résoudre un problème : le partage de la RAM entre les différents programmes. Les différents programmes et leurs données sont placés en mémoire RAM, et il faut trouver un moyen pour répartir les différents programmes en RAM. [[File:Partitions mémoire.png|vignette|upright=1|Partitions mémoire]] Il y a plusieurs solutions pour cela, mais l'une d'entre elle est l'usage de segments mémoire. Avec elle, chaque programme se voit attribuer un ou plusieurs blocs de mémoire. Ils sont appelés des '''partitions mémoire''', ou encore des '''segments'''. Dans ce qui va suivre, nous partons du principe qu'un programme est égal à un segment, pour simplifier les explications. Mais sachez qu'un programme peut être éclaté en plusieurs segments dispersés dans la mémoire, et même être conçu pour ! Nous en reparlerons dans le chapitre sur le mémoire virtuelle. Le système d'exploitation mémorise une liste des segments importants en mémoire RAM, appelée la ''table des segments''. Il s'agit d'un tableau dont chaque entrée mémorise les informations pour un segment. Une entrée de ce tableau est appelée un '''descripteur de segment''' et contient son adresse de base, sa taille, et des autorisations liées à la protection mémoire. Précisément, l'entrée numéro N mémorise les informations du segment numéro N. A partir de l'adresse de base de la table des segments et du numéro de segment, on peut lire ou écrire un descripteur de segment. [[File:Global Descriptor table.png|centre|vignette|upright=2|Global Descriptor table]] Toutefois, l'usage de segments amène un paquet de problèmes qu'il faut résoudre au mieux. Le premier problème est tout simplement de placer les segments dans l'espace d'adressage, mais c'est quelque chose qui est du ressort du système d'exploitation. Par contre, cela implique qu'un segment peut être placé n'importe où en RAM et sa position en RAM change à chaque exécution. En conséquence, les adresses des branchements et des données ne sont jamais les mêmes d'une exécution à l'autre. Pour résoudre ce problème, le compilateur considère que le segment commence à l'adresse zéro, puis les adresses sont corrigées avant ou pendant l'exécution du programme. Cette correction est en général réalisée par l'OS lors de la copie du programme en mémoire, mais il existe aussi des techniques matérielles, que nous verrons dans le chapitre suivant. Il faut aussi prendre en compte le phénomène de l''''allocation mémoire'''. Derrière ce nom barbare, se cache quelque chose de simple : les programmes peuvent réclamer de la mémoire au système d'exploitation, pour y placer des données. Les langages de programmation bas niveau supportent des fonctions comme malloc(), qui permettent de demander un bloc de mémoire de N octets à l'OS, qui doit alors accommoder la demande. S'il n'y a pas assez de mémoire, l'appel échoue et le programme doit gérer la situation. De même, un programme peut libérer de la mémoire qu'il n'utilise plus avec des fonctions comme free(). Avec des segments, cela revient à changer la taille d'un segment, plus précisément à la fin du segment. Et tout cela est géré par le système d'exploitation, aussi on laisse cela pour le prochain chapitre. Enfin, sans protection particulière, les programmes peuvent techniquement lire ou écrire les données des autres. Pour éviter cela, il suffit de vérifier qu'un programme accède bien à son segment et pas ailleurs. Il suffit de vérifier si l'adresse lue/écrite est dans le bon intervalle. Il suffit alors d'utiliser deux registres limites, rien de plus. Mais au-delà de cette simple vérification, si un programme pouvait modifier les données d'un autre programme, on se retrouverait rapidement avec une situation non prévue par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses. Il faut donc introduire des mécanismes de protection mémoire, pour isoler les programmes les uns des autres, et éviter toute modification problématique. La protection mémoire regroupe plusieurs techniques assez variées, qui ont des objectifs différents. Elles ont pour point commun de faire intervenir à des niveaux divers le système d'exploitation et le processeur. ==La ''memory map'' des PC IBM x86== Dans ce qui suit, nous allons étudier l'espace d'adressage des processeurs x86. L'organisation de leur espace d'adressage a évolué dans le temps et l'espace d'adressage s'est adapté. Mais il s'est adapté sans modifier l'existant, pour des raisons de compatibilité. Aussi, les processeurs x86 modernes peuvent fonctionner dans plusieurs modes, appelés mode réel, protégé, virtuel 8086, long, ''system management mode'', et compatibilité 32 bits. [[File:AMD64StateDiagram.svg|centre|vignette|upright=2|AMD64StateDiagram]] Le ''system management mode'' a déjà été abordé dans le chapitre sur les interruptions. Pour simplifier, il ne s'agit pas d'un véritable mode au sens "adressage". Dans ce mode, le système d'exploitation est mis en pause et le BIOS ou un autre firmware s'exécute sans restriction. On y rentre grâce à une interruption dédiée, lancée par le noyau de l'OS. Le système d'exploitation exécute cette interruption afin de laisser la main au BIOS, pour lui déléguer certaines tâches. Par contre, les autres modes sont bien plus intéressant, car ils ont chacun un espace d'adressage différent. Voyons-les dans le détail. ===Le mode réel : l'adressage sur 20 bits=== Le tout premier mode était le '''mode réel''', qui utilisait des adresses de 20 bits, ce qui fait 1 mébioctet de mémoire adressable. Il était utilisé sur des processeurs 16 bits, qui géraient donc des adresses de 16 bits capables d'adresser 64 kibioctets de mémoire. Pour contourner cette limitation, le processeur utilisait la segmentation, que nous verrons dans le prochain chapitre. Pour le moment, nous allons dire que la mémoire en mode réel était composée de 16 blocs de 64 kibioctets. C'est une simplification qui ne fera pas de mal. Le processeur démarre systématiquement en mode réel, y compris sur les ordinateurs modernes. Le BIOS s'exécute dans ce mode, avant de passer en mode protégé et de laisser la main à l'UEFI et au système d'exploitation. Autrefois, le système d'exploitation s'exécutait aussi en mode réel, c'est le cas du DOS. En mode réel, l'espace d'adressage est peuplé par de la RAM, la ROM du BIOS et des périphériques. La RAM prenait les adresses basses, la ROM était au sommet de l'espace d'adressage, et les périphériques entre les deux. Les premiers 640 kibioctets sont réservés à la RAM et sont appelés la '''mémoire conventionnelle'''. Les octets restants forment la '''mémoire haute''', réservée aux ROM et aux périphériques. Les deux premiers kibioctets de la mémoire conventionnelle sont initialisés au démarrage de l'ordinateur. Ils sont utilisés pour stocker le vecteur d'interruption (on expliquera cela dans quelques chapitres) et servent aussi au BIOS. La portion réservée au BIOS, la ''BIOS Data Area'', mémorise des informations en RAM. Elle commence à l'adresse 0040:0000h, a une taille de 255 octets, et est initialisée lors du démarrage de l'ordinateur. Le reste de la mémoire conventionnelle est réservée au le système d'exploitation (MS-DOS, avant sa version 5.0) et au programme en cours d’exécution. La mémoire haute est sectionnée en deux portions. Le sommet de la mémoire haute est réservé au BIOS, alors que le bas de la mémoire haute est réservé aux périphériques. Dans cette dernière, on trouve les BIOS des périphériques (dont celui de la carte vidéo, s'il existe), qui sont nécessaires pour les initialiser et pour communiquer avec eux. De plus, on y trouve la mémoire de la carte vidéo et éventuellement la mémoire d'autres périphériques comme la carte son. {| class="wikitable" style="text-align:center;" |+ Espace d'adressage en mode réel |- ! Numéro du bloc !! Contenu du bloc |- | 1024 - 960 Kio || class="f_jaune" | BIOS principal (ROM) |- | 960 Kio - 640 Kio | class="f_vert" | ROM d'extension des périphériques (XT, EGA, 3270 PC) </br> Mémoire vidéo des cartes EGA, MDA ou CGA |- | 640 Kio - 0 || class="f_rouge" | Mémoire RAM, mémoire conventionnelle |} Pour détailler un peu plus, il faut tenir compte du découpage de l'espace d'adressage en blocs de 64 kibioctets. Le découpage en blocs de 64 kibioctets est particulièrement important pour la mémoire haute. Des blocs sont attribués à un périphérique spécifique. Par exemple, le 16ème bloc est réservé au BIOS, le 12ème aux ROM des périphériques, le 10ème et le 11ème à la mémoire vidéo, etc. {| class="wikitable" style="text-align:center;" |+ Espace d'adressage en mode réel |- ! Numéro du bloc !! Contenu du bloc |- | 0 || rowspan="10" class="f_rouge" | Mémoire RAM, mémoire conventionnelle |- | 1 |- | 2 |- | 3 |- | 4 |- | 5 |- | 6 |- | 7 |- | 8 |- | 9 |- | 10 || class="f_vert" | Mémoire vidéo des cartes EGA |- | 11 || class="f_vert" | Mémoire vidéo des cartes MDA ou CGA |- | 12 || class="f_vert" | ROM d'extension des périphériques (XT, EGA, 3270 PC) |- | 13 || class="f_gris" | Autre, non-réservé |- | 14 || class="f_gris" | Autre, non-réservé |- | 15 || class="f_jaune" | ROM du BIOS |} Vous remarquerez que les blocs 13 et 14 sont non-réservés. Ils peuvent être utilisés pour mapper des périphériques en mémoire. Mais si aucun périphérique n'est mappé dedans, les blocs sont laissés libres. Les OS n'hésitaient pas à les utiliser pour adresser de la RAM. Par exemple, si l'ordinateur n'a pas de carte vidéo EGA, le bloc qui leur était réservé était réutilisé comme mémoire RAM. La mémoire conventionnelle passait alors de 640 à 704 kibioctets de RAM disponibles. L'espace d'adressage correspond à la ROM du BIOS. Mais avec le temps, la ROM est devenue plus lente au point qu'exécuter du code directement depuis la ROM du BIOS était un problème niveau performance. Je rappelle que le BIOS contient de nombreuses routines d'interruptions qui étaient appelées par les systèmes d'exploitation de l'époque, notamment le DOS. Heureusement, diverses optimisations permettaient de copier le BIOS dans la mémoire RAM, plus rapide que la ROM du BIOS. Il s'agit des techniques de '''''BIOS Shadowing'''''. Elles s'appliquaient au BIOS proprement dit, mais le BIOS vidéo pouvait aussi être optimisé ainsi, mais les ROM d'extension ne le sont généralement pas. Le ''BIOS Shadowing'' peut s'implémenter de plusieurs manières, mais la plus courante mappait temporairement de la RAM dans le même espace d'adressage que la ROM du BIOS. Le code du BIOS copiait le BIOS avec une boucle, dont chaque itération lisait une adresse du BIOS et écrivait dans cette même adresse. Juste avant la boucle, le ''chipset'' était configuré par le BIOS pour que les lectures atterrissent dans la ROM du BIOS, mais que les écritures tombaient dans une mémoire RAM mappée. Une fois la copie/boucle terminée, l'adressage tombait dans la RAM aussi bien pour les lectures et écritures. Elle demandait un support du ''chipset'' et du logiciel, pour détourner les adresses temporairement. ===Interlude : les technologies d'expanded memory=== Déjà à l'époque, 1 mébioctet était une limite assez faible, même si cela n'a pas empêché à un grand dirigeant de l'industrie informatique de dire que "640K ought to be enough for anybody". Aussi, diverses solutions matérielles ont vu le jour. Elles étaient peu utilisées et ont concrètement servi à des applications spécifiques. La principale est l''''''expanded memory''''' (EMS), une solution utilisant une carte d'extension avec plusieurs modules de RAM installés dessus. Les modules étaient accédés via la technique de la commutation de banque. [[File:Expanded memory.svg|centre|vignette|upright=2|L'espace d'adressage avec ''Expanded memory''. La carte d'extension et le matériel requis ne sont pas représentés.]] Le terme ''expanded memory'' regroupe plusieurs technologies propriétaires semblables sur le principe mais différentes dans l'exécution. Les premières utilisaient des banques de 64 kibioctets, qui étaient mappées dans un bloc de 64 kibioctets libre en mémoire haute. L'usage de banques de 64 kibioctets collait bien avec l'adressage particulier du mode réel, où la mémoire était gérée par blocs de 64 kibioctets. Les technologies ultérieures utilisaient 2 à 4 pages de 16 kibioctets, qui pouvaient être placée n'importe où en mémoire conventionnelle. [[File:EmulexPersyst 4M ISA.jpeg|centre|vignette|upright=2|Carte d'extension ''Expanded memory'' de marque Emulex Persyst, de 4 mébioctets.]] L'''expanded memory'' demandait que les programmes soient codés pour utiliser cette fonctionnalité. Pour cela, ils devaient utiliser une technique de programmation nommée ''Overlay programming''. L'idée est de découper le programme en plusieurs blocs séparés, appelés des '''''overlays'''''. Certains blocs sont en permanence en RAM, mais d'autres sont soit chargés en RAM, soit stockés ailleurs. Le ailleurs est souvent le disque dur, ou dans la RAM ''expanded memory'' si elle est disponible. Le va-et-vient des ''overlays'' entre RAM et disque dur était réalisé en logiciel, par le programme lui-même. [[File:Overlay Programming.svg|centre|vignette|upright=1.5|''Overlay Programming''.]] L'utilisation de l'''expanded memory'' utilisait l'interruption logicielle 67h, une interruption peu utilisée à l'époque, qui était libre. La carte d'extension était utilisée à travers un pilote de périphérique dédié, qui était appelé via l'interruption 67h. ===Le mode protégé : l'adressage sur 24 et 32 bits=== Par la suite, le mode réel a été remplacé par le '''mode protégé'''. Il utilise la mémoire virtuelle, ce qui fait qu'on devrait le détailler dans le chapitre suivant. Il permettait aussi de dépasser la limite du mébioctet de mémoire. L'espace d'adressage en mode protégé est passé de 24 bits sur le CPU 286, à 32 bits sur le 386 et les CPU suivants. Les programmes conçus pour le mode réel ne pouvaient pas s'exécuter en mode protégé. Pour corriger cela, le 286 a introduit le '''mode virtuel 8086''', aussi appelé mode V86. Il s'agit d'une technique de virtualisation, ce qui fait qu'on en parlera en détail dans le chapitre sur la virtualisation assistée en matériel. Tout ce que l'on peut dire ici est que le mode V86 a été détourné pour que les applications DOS utilisent la mémoire au-delà du mébioctet, appelée la '''mémoire étendue'''. Les logiciels DOS accédaient à la mémoire étendue à travers un driver DOS dédié (HIMEM.SYS). Les logiciels DOS déplaçaient des données dans la mémoire étendue, puis les rapatriaient en mémoire conventionnelle quand ils en avaient besoin. Il ne faut pas confondre mémoire étendue et ''expanded memory''. La technique de ''expanded memory'' repose sur de la commutation de banque, demande une carte d'extension dédiée, fonctionne sans mode V86. A l'inverse, la mémoire étendue ne gère pas de commutation de banque, ne demande pas de carte d'extension, mais utilise le mode V86. Par contre, il est possible d'émuler l'''expanded memory'' sans carte d'extension, avec la mémoire étendue. Quelques cartes mères intégraient des techniques pour, mais une émulation logicielle était aussi possible en réécrivant l'interruption 67h. ===Le mode long : l'adressage 64 bits=== Par la suite, les processeurs ont introduit le '''mode long''', dans lequel les adresses font 64 bits. Le CPU en mode long a aussi un jeu d'instruction totalement différent, des registres en plus et d'autres fonctionnalités actives uniquement dans ce mode. Les programmes compilés pour le 64 bits s'exécutent nativement dans ce mode, alors que les programmes 32 et 16 bits s'exécutent dans un mode de compatibilité dédié. Sur les systèmes 64 bits, le bus d'adresse fait seulement 48 bits afin d'économiser des interconnexions. Avec 48 bits, il manque 64 - 48 = 16 bits d'adresses, qui ne peuvent pas être utilisés pour adresser quoi que ce soit. La règle est que ces 16 bits doivent être tous égaux : soit ils valent tous 0, soient ils sont tous à 1. Les adresses qui respectent cette contrainte sont appelées des '''adresses canoniques'''. Le résultat est que l'espace d'adressage est coupé en trois sections, comme illustré ci-dessous. * Les '''adresses canoniques basses''' ont leurs 16 bits de poids fort à 0 et sont situées en bas de l'espace d'adressage, dans les premières adresses. * Les '''adresses canoniques hautes''' ont leurs 16 bits de poids fort à 1 et sont situées au sommet de l'espace d'adressage, dans les dernières adresses. * Entre les deux, se trouvent des '''adresses non-canoniques''', qui ne sont pas accessibles. Y accéder déclenche la levée d'une exception matérielle. {| |[[File:AMD64-canonical--48-bit.png|vignette|Espace d'adressage x86-64 bits, avec des adresses physiques de 48 bits.]] |[[File:AMD64-canonical--56-bit.png|vignette|Espace d'adressage x86-64 bits, avec des adresses physiques de 57 bits.]] |} : Les futurs systèmes x86 devraient passer à des adresses de 57 bits, les trois sections auront donc des frontières différentes. Les adresses non-canoniques sont censées être inutilisables. Mais les programmeurs aimeraient bien pouvoir les utiliser pour des ''pointeurs tagués'', à savoir des pointeurs/adresses associées à des informations. L'idée serait d'utiliser les 16 bits de poids fort pour stocker des informations liées au pointeur, seuls les 48 bits restant codant l'adresse. Les 16 bits peuvent être utilisés de manières très diverses. Un exemple est la technique du '''''memory tagging''''', qui crée une somme de contrôle au pointeur. La somme de contrôle est générée par un algorithme cryptographique, à partir de l'adresse du pointeur. Elle est vérifiée à chaque utilisation du pointeur. Si la somme de contrôle ne correspond pas au pointeur, une erreur est levée. Si jamais un virus ou un code malveillant modifie un pointeur, il ne saura pas comment calculer la somme de contrôle, la somme de contrôle a énormément de chances d'être incorrecte. Quelques fonctionnalités des processeurs visent à autoriser l'utilisation des adresses non-canoniques. L'idée est que les 16 bits de poids fort sont ignorées lors des accès mémoire, ce qui permet d'utiliser les 16 bits de poids fort à volonté. Il s'agit des techniques ''Top Byte Ignore'' d'ARM, ''Upper Address Ignore'' d'AMD, et ''Linear Address Masking'' d'Intel. ==La z/architecture d'IBM== IBM a sa propre gamme de ''mainframes'', qui utilisent des processeurs fait maison par IBM lui-même. Les CPU IBM ont leur propre jeu d'instruction CISC, appelé '''''z/architecture'''''. Et ce jeu d'instruction a des possibilités en termes d'adressage qui sont extrêmement intéressantes, en plus d'être assez inédites. Pour ce chapitre, nous allons nous concentrer sur une fonctionnalité : les programmes ont accès à plusieurs espaces d'adressage naturellement. ===L'isolation des processus=== Les CPU ''z/architecture'' peuvent fonctionner en quatre modes, dont l'un permet à un programme d'utiliser plusieurs espaces d'adressage. L'avantage pour les programmes utilisateur n'est pas très évident, mais il l'est nettement plus pour les systèmes d'exploitation. Le système d'exploitation de ces ''mainframes'', le z/OS, profite de cette possibilité, idem pour l'hyperviseur z/VM. Il utilise cette possibilité pour donner un espace d'adressage séparé pour chaque programme en cours d'exécution. Les programmes sont alors isolés les uns des autres. Cette '''isolation des processus''' n'est pas spécifique à IBM : tous les systèmes d'exploitation modernes l'utilisent. Mais les processeurs x86 doivent se débrouiller seulement avec des techniques dites de mémoire virtuelle, alors que les ''mainframes'' IBM complètent la mémoire virtuelle avec des facilités en plus. Pour les programmes utilisateurs, un intérêt est que cela permet de partager des données entre plusieurs programmes. Ils ont un espace d'adressage rien qu'à eux, mais les données partagées sont placées dans un espace d'adressage commun, auquel les deux programmes ont accès. L'espace d'adressage partagé peu même être configuré de manière à ne contenir que des données, il est impossible d'exécuter du code depuis cet espace d'adressage. Ce qui empêche certains ''malwares'' de fonctionner. ===Les registres d'accès de la ''z/architecture''=== Les CPU ''z/architecture'' disposent de 16 registres généraux de 64 bits, de 16 registres flottants eux aussi de 64 bits, de 16 registres de contrôle eux aussi de 64 bits, et de '''registres d'accès''' qui gèrent les espaces d'adressage. Chaque registre entier est associé à un espace d'adressage particulier, qui est indiqué par son registre d'accès. Les registres d'accès sont utilisés lors des lectures/écritures indirectes. L'adresse est lue depuis un registre général, et son registre d'accès est consulté pour déterminer l'espace d'adressage. Une implémentation simple numéroterait les espaces d'adressage et mettrait les numéros dans les registres d'accès. Les registres d'accès seraient idéalement protégés en écriture et seulement accessibles par le système d'exploitation. Mais ce n'est pas ce qui est fait, pour des raisons de sécurité. Les registres d'accès mémorisent bien un identifiant, mais celui-ci ne sélectionne pas l'espace d'adressage directement. L'identifiant est envoyée à une table de correspondance matérielle, qui mémorise les correspondances entre un identifiant et un espace d'adressage. Il y a une table de correspondance par programme, ce qui fait qu'un identifiant ne correspondra pas au même espace d'adressage dans deux programmes différents. La table de correspondance mémorise aussi des droits d'accès, qui sont différents pour chaque programme. Par exemple, tel programme aura accès en lecture seule à tel espace d'adressage, alors qu'un autre programme y aura accès en lecture et écriture, etc. Un espace d'adressage peut ainsi être partagé entre deux programmes plus facilement, tout en ajoutant des garanties de sécurité. L'usage des registres d'accès est aussi plus flexible qu'on ne pourrait le croire. Le programme peut lire et écrire dans les registres d'accès, copier un registre d'accès dans un autre, etc. Par contre, la table de correspondance empêche à un programme d’accéder à un espace d'adressage arbitraire. Le système d'exploitation remplit la table de manière à n'autoriser l'accès qu'aux espaces d'adressage adéquats, il y enregistre seulement les correspondances utiles. Si un programme forge un identifiant arbitraire, et l'envoie à la table, celle-ci n'aura pas la correspondance associée et l'accès mémoire sera invalidé. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs 8 bits et moins | prevText=Les processeurs 8 bits et moins | next=L'abstraction mémoire et la mémoire virtuelle | nextText=L'abstraction mémoire et la mémoire virtuelle }} </noinclude> btuf6q35d8h58sol60nvvcx79yhfksa 774360 774359 2026-10-07T14:29:10Z Mewtow 31375 /* Les registres d'accès de la z/architecture */ 774360 wikitext text/x-wiki L''''espace d'adressage''' du processeur est l'ensemble des adresses utilisables par le processeur. Par exemple, un processeur 16 bits peut adresser 2^16 = 65536 adresses, l'ensemble de ces adresses forme son espace d'adressage. L'espace d'adressage n'est pas toujours égal à la mémoire réellement installée. S'il n'y a pas assez de RAM installée, des adresses seront inoccupées. De plus, une partie de l'espace d'adressage peut être détourné pour communiquer avec les périphériques, comme nous le verrons plus bas. Nous verrons aussi qu'un processeur peut avoir plusieurs espaces d'adressages séparés. ==L'adressage de la RAM et de la ROM== Avoir plusieurs espaces d'adressage spécialisés est quelque chose que nous avons déjà rencontré dans les chapitres précédents, mais sans le dire ouvertement. Aussi, nous allons faire quelques rappels sur les cas déjà rencontrés. En premier lieu, nous allons rappeler la différence entre architectures Von Neumann et Hardvard. La différence entre les deux tient dans l'adressage des mémoires RAM et ROM : est-ce qu'elles sont dans un seul espace d'adressage, ou dans des espaces d'adressage séparés. L''''architecture Von Neumann''' a un seul espace d'adressage, découpé entre la mémoire RAM d'un côté et la mémoire ROM de l'autre. Une adresse correspond soit à la mémoire RAM, soit à la mémoire ROM, mais pas aux deux. Typiquement, la mémoire ROM est placée dans les adresses hautes, les plus élevées, alors que la RAM est placée dans les adresses basses en commençant par l'adresse 0. C'est une convention qui n'est pas toujours respectée, aussi mieux vaut éviter de la tenir pour acquise. [[File:Vision de la mémoire par un processeur sur une architecture Von Neumann.png|centre|vignette|upright=2|Vision de la mémoire par un processeur sur une architecture Von Neumann.]] L''''architecture Harvard''' utilise des espaces d'adressage séparés pour la RAM et la ROM. Une même adresse peut correspondre soit à la ROM, soit à la RAM. Le processeur voit bien deux mémoires séparées, chacune dans son propre espace d'adressage. Les deux espaces d'adressage n'ont pas forcément la même taille. Il est possible d'avoir un plus gros espace d'adressage pour la RAM que pour la ROM. Cela implique que les adresses des instructions et des données soient de taille différentes. C'est peu pratique et c'est rarement implémenté, ce qui fait que le cas le plus courant est celui où les deux espaces d'adressages ont la même taille. [[File:Vision de la mémoire par un processeur sur une architecture Harvard.png|centre|vignette|upright=2|Vision de la mémoire par un processeur sur une architecture Harvard.]] Sur une '''architecture Harvard modifiée''', le programme est placé dans un espace d'adressage à part du reste. Le problème, c'est que cet espace d'adressage ne contient pas que le code machine à exécuter. Il contient aussi des constantes, à savoir des données qui gardent la même valeur lors de l'exécution du programme. Elles peuvent être lues, mais pas modifiées durant l'exécution du programme. L'accès à ces constantes demande d'aller lire celles-ci dans l'autre espace d'adressage, pour les copier dans les registres généraux. Le processeur doit pour cela avoir des instructions de lecture dédiées. En clair, il y a une instruction LOAD pour lire dans la mémoire ROM, une autre pour lire dans la RAM. [[File:Espaces d'adressage sur une archi harvard modifiée.png|centre|vignette|upright=3|Espaces d'adressage sur une archi harvard modifiée]] Une autre solution utilise des instructions de copie, qui copient les constantes en mémoire RAM, donc d'un espace d'adressage à l'autre. [[File:Organisation des espaces d'adressage sur une archi harvard modifiée.png|centre|vignette|upright=3|Organisation des espaces d'adressage sur une archi harvard modifiée]] ==L'adressage des périphériques== Passons maintenant à l'adressage des périphériques. La communication avec les périphériques se fait par l'intermédiaire de registres d’interfaçage. Et ces registres peuvent soit avoir un espace d'adressage séparé, soit être inclus dans l'espace d'adressage des mémoires. Dans ce qui suit, nous allons supposer que l'architecture des de type Von Neumann pour simplifier les explications. ===Les entrées-sorties mappées en mémoire=== Une partie de l'espace d'adressage peut être détourné pour communiquer avec les périphériques. Le périphérique se retrouve inclus dans l'ensemble des adresses utilisées pour manipuler la mémoire : on dit qu'il est mappé en mémoire. Les adresses mémoires associées à un périphérique sont redirigées automatiquement vers les registres du périphérique en question, voire vers sa mémoire intégrée. On parle alors d''''entrées-sorties mappées en mémoire'''. [[File:IO mappées en mémoire.png|centre|vignette|upright=2.0|IO mappées en mémoire]] On remarque un défaut inhérent à cette technique : les adresses utilisées pour les périphériques ne sont plus disponibles pour la RAM. On ne peut plus adresser autant de RAM qu'avant. La perte dépend des périphériques installés et de leur gourmandise en adresses mémoires. C'est ce qui causait un problème assez connu sur les ordinateurs 32 bits, capables d'adresser 4 gibioctets de mémoire. Certaines personnes installaient 4 gigaoctets de mémoire sur leur ordinateur 32 bits et se retrouvaient avec « seulement » 3,5 à 3,8 gigaoctets de mémoire, les périphériques prenant le reste. Il est possible que la RAM d'un périphérique soit mappée en RAM. Un exemple classique est celui des cartes graphiques qui incorporent une mémoire RAM appelée la mémoire vidéo. La mémoire vidéo est mappée en mémoire, ce qui permet au processeur d'écrire directement dedans. Toute lecture ou écriture dans les adresses associées est redirigée vers le bus PCI/AGP/PCI-Express. Nous verrons plus bas des exemples d'ordinateurs où la mémoire vidéo est mappée en mémoire, que ce soit totalement ou partiellement. Si je dis totalement ou partiellement, c'est parce que les cartes graphiques modernes disposent de tellement de mémoire qu'on ne peut pas la mapper totalement dans l'espace d'adressage. Sur les systèmes d'avant 2008, seuls 256 mégaoctets de mémoire vidéo sont mappés en mémoire RAM. Le reste de la mémoire vidéo est invisible du point de vue du processeur, mais manipulable par le GPU à sa guise. Après 2008, la spécification du PCI-Express ajouta un support de la technologie ''Resizable Bar'', qui permet au processeur d’accéder directement à plus de 256 mégaoctets de mémoire vidéo, voire à la totalité de la mémoire vidéo. ===L'espace d'adressage séparé pour les entrées-sorties=== Les entrées-sorties et périphériques peuvent avoir leur propre espace d'adressage dédié, séparé de celui utilisé pour les mémoires RAM et ROM. [[File:Espaces_d'adressages_séparés_entre_mémoire_et_périphérique.png|centre|vignette|upright=3|Bit IO.]] Une même adresse peut donc adresser soit une entrée-sortie, soit une case mémoire. Et pour faire la différence, le processeur doit avoir des instructions séparées pour adresser les périphériques et la mémoire. Il a des instructions de lecture/écriture pour lire/écrire en mémoire, et d'autres pour lire/écrire les registres d’interfaçage. Sans cela, le processeur ne saurait pas si une adresse est destinée à un périphérique ou à la mémoire. Cela élimine aussi les problèmes avec les caches : les accès à l'espace d'adressage de la RAM passent par l'intermédiaire de la mémoire cache, alors les accès dans l'espace d'adressage des périphériques le contournent totalement. Là encore, les deux espaces d'adressage n'ont pas forcément la même taille. Il arrive que les deux espaces d'adressage aient la même taille, le plus souvent sur des ordinateurs complexes avec beaucoup de périphériques. Mais les systèmes embarqués ont souvent des espaces d'adressage plus petits pour les périphériques que pour la ou les mémoires. L'implémentation varie grandement suivant le cas, la première méthode imposant d'avoir deux bus séparés pour les mémoires et les périphériques, l'autre permettant un certain partage du bus d'adresse. Nous reviendrons dessus plus en détail dans le chapitre sur l'adressage des périphériques. ==La ''memory map'' d'un ordinateur== Les deux sections précédentes nous ont appris que l'on peut utiliser un espace d'adressage séparé pour la ROM et un autre pour les périphériques. En tout, cela donne quatre possibilités distinctes. {|class="wikitable" |- ! ! IO mappées en mémoire ! IO séparées |- ! ROM mappée en mémoire (architecture Von Neumann) | Un seul espace d'adressage | Deux espaces d'adressage : * un pour les mémoires RAM/ROM ; * un pour les IO. |- ! ROM séparée (architecture Harvard) | Deux espaces d'adressage : * un pour la RAM et les IO ; * un pour la ROM. | Trois espaces d'adressages : * un pour la RAM ; * un pour la ROM ; * un pour les IO. |} Les quatre solutions ont des avantages et inconvénients divers, mais il est intéressant de contraster un espace d'adressage unique avec plusieurs espaces d'adressages. ===L'espace d'adressage unique=== Avec un espace d'adressage unique, la ROM est au sommet de l'espace d'adressage, les périphériques sont juste en-dessous, la RAM commence à l'adresse 0 et prend les adresses basses. Faire simplifie grandement l'implémentation matérielle de l'adressage. Notons que d'autres composants que les périphériques ou les mémoires peuvent se trouver dans l'espace d'adressage. On peut y trouver les horloges temps réels, des timers, des senseurs de température, ou d'autres composants placés sur la carte mère. Un exemple un peu original est le suivant : la console de jeu Nintendo DS incorporait une unité de calcul spécialisée dans les divisions et racines carrées, séparée du processeur, qui était justement mappée en mémoire ! [[File:Espace d'adressage classique avec entrées-sorties mappées en mémoire.png|centre|vignette|upright=2|Espace d'adressage classique avec entrées-sorties mappées en mémoire]] L'espace d'adressage unique est plus simple pour les programmeurs, malgré un désavantage dont il faut parler. Le problème est que des adresses censées être disponibles pour la RAM sont détournées vers la ROM ou les périphériques, ce qui limite la quantité de RAM qui peut être réellement adressée en pratique. C'est un défaut qui se manifeste seulement pour les petits espaces d'adressages, de 8 à 16 bits, qui ne permettent pas d'adresser beaucoup de RAM. Avec des adresses codées sur 16 bits, un espace d'adressage de 64 kibioctets de RAM est grignoté par des entrées-sorties assez rapidement. Par contre, pour des adresses de 32 à 64 bits, une bonne partie de l'espace d'adressage est inutilisé, car il n'y a pas assez de RAM. L'espace d'adressage unique n'a donc pas de désavantages. Les vielles consoles de jeux et certains vieux ordinateurs (Commodores et Amiga), utilisaient un espace d'adressage unique. Elles n'avaient pas une variété de cartes graphiques ou de cartes sons différentes, comme sur les PCs modernes. Au contraire, elles avaient la même configuration matérielle, le matériel ne pouvait pas être changé ni upgradé. Le système d'exploitation était rudimentaire et ne contrôlait pas vraiment l'accès au matériel. Les programmeurs avaient donc totalement accès au matériel et mapper les entrées/sorties en mémoire rendait la programmation des périphériques très simple. <gallery widths=200px heights=500px> N5200mk2 memory map.svg|Adressage mémoire (carte mémoire) du N5200mk2. PC-9801VM memory map.svg|Adressage mémoire (carte mémoire) du PC-9801VM. </gallery> ===La commutation de banques (''bank switching'')=== Le '''''bank switching''''', aussi appelé '''commutation de banque''', permet d'utiliser plusieurs espaces d'adressage sur un même processeur, sans attribuer chaque espace d'adressage pour une raison précise. L'espace d'adressage est présent en plusieurs exemplaires appelés des '''banques'''. Les banques sont numérotées, chaque numéro de banque permettant de l'identifier et de le sélectionner. Le but de cette technique est d'augmenter la mémoire disponible pour l'ordinateur. Par exemple, supposons que j'ai besoin d'adresser une mémoire ROM de 4 kibioctets, une RAM de 8 kibioctets, et divers périphériques. Le processeur a un bus d'adresse de 12 bits, ce qui limite l'espace d'adressage à 4 kibioctets. Dans ce cas, je peux réserver 4 banques : une pour la ROM, une pour les périphériques, et deux banques qui contiennent chacune la moitié de la RAM. La simplicité et l'efficacité de cette technique font qu'elle est beaucoup utilisée dans l'informatique embarquée. [[File:PC-8801MemoryMap.gif|centre|vignette|upright=2|exemple de Bank switching.]] Cette technique demande d'utiliser un bus d'adresse plus grand que les adresses du processeur. L'adresse réelle se calcule en concaténant le numéro de banque avec l'adresse accédée. Le numéro de la banque actuellement en cours d'utilisation est mémorisé dans un registre appelé le '''registre de banque'''. On peut changer de banque en changeant le contenu de ce registre. Le processeur dispose souvent d'instructions spécialisées qui en sont capables. {| class="wikitable flexible" |[[File:Banque mémoire.png|Banque mémoire.]] |[[File:Registre de banque.png|Registre de banque.]] |} ==Le placement des programmes dans l'espace d'adressage== Les ordinateurs modernes sont des architectures Von Neumann, avec un seul espace d'adressage, qui est partagé suivant les besoins. Et un point important est que le système d'exploitation et les programmes sont copiés en mémoire RAM avant d'être exécutés. Ils ne sont pas exécutés depuis une mémoire ROM, cela est réservé aux systèmes embarqués simples. Et cela a une influence sur l'espace d'adressage. Les programmes et le système d'exploitation doivent se partager l'espace d'adressage. Voyons voir comment ce partage se fait. Dans ce chapitre, nous allons volontairement mettre de côté les ordinateurs qui supportent la mémoire virtuelle. Nous en parlerons dans le prochain chapitre. Le principe est de voir comment un processeur avec un seul espace d'adressage gèrent la présence d'un ou de plusieurs programmes. ===Un seul espace d'adressage, non-partagé=== Le cas le plus simple est celui où il n'y a pas de système d'exploitation et où un seul programme s'exécute sur l'ordinateur. Le programme a alors accès à tout l'espace d'adressage, sauf pour ce qui est réservé aux périphériques et à la mémoire ROM. Le programme organise la mémoire en plusieurs sections, dans lesquelles le programme range des données différentes. Typiquement, on trouve quatre blocs de mémoire, appelés des segments, chacun étant spécialisé pour les données, le code, la pile, etc. Voici ces trois sections : * Le segment '''''text''''' 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. * Le segment pour la '''pile''', de taille variable. * le reste est appelé le '''tas''', de taille variable. [[File:Organisation d'un espace d'adressage unique utilisé par un programme unique.png|centre|vignette|upright=2|Organisation d'un espace d'adressage unique utilisé par un programme unique]] [[File:Typical computer data memory arrangement.png|vignette|upright=0.5|Typical computer data memory arrangement]] La pile et le tas sont de taille variable, ce qui veut dire qu'ils peuvent grandir ou diminuer à volonté, contrairement au reste. Entre le tas et la pile, on trouve un espace de mémoire inutilisée, qui peut être réquisitionné selon les besoins. La pile commence généralement à l'adresse la plus haute et grandit en descendant, alors que le tas grandit en remontant vers les adresses hautes. Il s'agit là d'une convention, rien de plus. Il est possible d'inverser la pile et le tas sans problème, c'est juste que cette organisation est rentrée dans les usages. Avant de poursuivre, précisons qu'il est possible de regrouper plusieurs programmes distincts dans un seul, afin qu'ils partagent le même morceau de mémoire. Les programmes portent alors le nom de '''threads'''. Les ''threads'' d'un même processus partagent le même espace d'adressage. Ils partagent généralement certains segments : ils se partagent le code, le tas et les données statiques. Par contre, chaque thread dispose de sa propre pile d'appel. [[File:Single vs multithreaded processes.jpg|centre|vignette|upright=2.0|Distinction entre processus mono et multi-thread.]] Il va de soi que cette vision de l'espace d'adressage ne tient pas compte des périphériques. C'est-à-dire que les schémas précédents partent du principe qu'on a un espace d'adressage séparé pour les périphériques. Dans le cas où les entrées-sorties sont mappées en mémoire, l'organisation est plus compliquée. Généralement, les adresses associées aux périphériques sont placées juste au-dessus de la pile, dans les adresses hautes. ===Un seul espace d'adressage, partagé avec le système d'exploitation=== [[File:Gestion de la mémoire sur les OS monoprogrammés.png|vignette|upright=1.0|Gestion de la mémoire sur les OS monoprogrammés.]] Maintenant, étudions le cas où le programme partage la mémoire avec le système d'exploitation. Sur les systèmes d'exploitation les plus simples, on ne peut lancer qu'un seul programme à la fois. Le système d'exploitation réserve une portion de taille fixe réservée au système d'exploitation, alors que le reste de la mémoire est utilisé pour le programme à exécuter. Le programme est placé à un endroit en RAM qui est toujours le même. Le système d'exploitation peut être soit placé dans une ROM, soit copié en mémoire RAM depuis une mémoire de masse, comme le programme à lancer. Si le système d'exploitation est copié en mémoire RAM, il est généralement placé dans les premières adresses, les adresses basses. A l'inverse, un OS en mémoire ROM est généralement placé à la fin de la mémoire, dans les adresses hautes. Mais tout cela n'est qu'une convention, et les exceptions sont monnaie courante. [[File:Méthodes d'allocation de la mémoire avec un espace d'adressage unique.png|centre|vignette|upright=2.0|Méthodes d'allocation de la mémoire avec un espace d'adressage unique]] Sur de tels systèmes, il faut protéger l'OS contre une erreur ou malveillance d'un programme utilisateur. Notons qu'il s'agit d'une protection en écriture, pas en lecture. Le programme peut parfaitement lire les données de l'OS sans problèmes, au moins en partie. Dans ce qui suit, on part du principe que l'OS est dans les adresses basses. La solution la plus courante interdit au programme d'écrire au-delà d'une limite au-delà de laquelle se trouve le système d’exploitation. Pour cela, le processeur incorpore un '''registre limite''', qui contient l'adresse limite au-delà de laquelle un programme peut pas écrire. Pour toute écriture en espace utilisateur, l'adresse écrite est comparée au registre limite. Si l'adresse est inférieure au registre limite, le programme cherche écrire dans la mémoire réservée à l'OS. L’accès mémoire est interdit, une exception matérielle est levée et l'OS affiche un message d'erreur. [[File:Circuit total.png|centre|vignette|upright=2.0|Protection mémoire avec un registre limite.]] ===Un seul espace d'adressage, partagé entre plusieurs programmes=== Les systèmes d’exploitation modernes implémentent la '''multiprogrammation''', le fait de pouvoir lancer 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. Et il faut alors résoudre un problème : le partage de la RAM entre les différents programmes. Les différents programmes et leurs données sont placés en mémoire RAM, et il faut trouver un moyen pour répartir les différents programmes en RAM. [[File:Partitions mémoire.png|vignette|upright=1|Partitions mémoire]] Il y a plusieurs solutions pour cela, mais l'une d'entre elle est l'usage de segments mémoire. Avec elle, chaque programme se voit attribuer un ou plusieurs blocs de mémoire. Ils sont appelés des '''partitions mémoire''', ou encore des '''segments'''. Dans ce qui va suivre, nous partons du principe qu'un programme est égal à un segment, pour simplifier les explications. Mais sachez qu'un programme peut être éclaté en plusieurs segments dispersés dans la mémoire, et même être conçu pour ! Nous en reparlerons dans le chapitre sur le mémoire virtuelle. Le système d'exploitation mémorise une liste des segments importants en mémoire RAM, appelée la ''table des segments''. Il s'agit d'un tableau dont chaque entrée mémorise les informations pour un segment. Une entrée de ce tableau est appelée un '''descripteur de segment''' et contient son adresse de base, sa taille, et des autorisations liées à la protection mémoire. Précisément, l'entrée numéro N mémorise les informations du segment numéro N. A partir de l'adresse de base de la table des segments et du numéro de segment, on peut lire ou écrire un descripteur de segment. [[File:Global Descriptor table.png|centre|vignette|upright=2|Global Descriptor table]] Toutefois, l'usage de segments amène un paquet de problèmes qu'il faut résoudre au mieux. Le premier problème est tout simplement de placer les segments dans l'espace d'adressage, mais c'est quelque chose qui est du ressort du système d'exploitation. Par contre, cela implique qu'un segment peut être placé n'importe où en RAM et sa position en RAM change à chaque exécution. En conséquence, les adresses des branchements et des données ne sont jamais les mêmes d'une exécution à l'autre. Pour résoudre ce problème, le compilateur considère que le segment commence à l'adresse zéro, puis les adresses sont corrigées avant ou pendant l'exécution du programme. Cette correction est en général réalisée par l'OS lors de la copie du programme en mémoire, mais il existe aussi des techniques matérielles, que nous verrons dans le chapitre suivant. Il faut aussi prendre en compte le phénomène de l''''allocation mémoire'''. Derrière ce nom barbare, se cache quelque chose de simple : les programmes peuvent réclamer de la mémoire au système d'exploitation, pour y placer des données. Les langages de programmation bas niveau supportent des fonctions comme malloc(), qui permettent de demander un bloc de mémoire de N octets à l'OS, qui doit alors accommoder la demande. S'il n'y a pas assez de mémoire, l'appel échoue et le programme doit gérer la situation. De même, un programme peut libérer de la mémoire qu'il n'utilise plus avec des fonctions comme free(). Avec des segments, cela revient à changer la taille d'un segment, plus précisément à la fin du segment. Et tout cela est géré par le système d'exploitation, aussi on laisse cela pour le prochain chapitre. Enfin, sans protection particulière, les programmes peuvent techniquement lire ou écrire les données des autres. Pour éviter cela, il suffit de vérifier qu'un programme accède bien à son segment et pas ailleurs. Il suffit de vérifier si l'adresse lue/écrite est dans le bon intervalle. Il suffit alors d'utiliser deux registres limites, rien de plus. Mais au-delà de cette simple vérification, si un programme pouvait modifier les données d'un autre programme, on se retrouverait rapidement avec une situation non prévue par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses. Il faut donc introduire des mécanismes de protection mémoire, pour isoler les programmes les uns des autres, et éviter toute modification problématique. La protection mémoire regroupe plusieurs techniques assez variées, qui ont des objectifs différents. Elles ont pour point commun de faire intervenir à des niveaux divers le système d'exploitation et le processeur. ==La ''memory map'' des PC IBM x86== Dans ce qui suit, nous allons étudier l'espace d'adressage des processeurs x86. L'organisation de leur espace d'adressage a évolué dans le temps et l'espace d'adressage s'est adapté. Mais il s'est adapté sans modifier l'existant, pour des raisons de compatibilité. Aussi, les processeurs x86 modernes peuvent fonctionner dans plusieurs modes, appelés mode réel, protégé, virtuel 8086, long, ''system management mode'', et compatibilité 32 bits. [[File:AMD64StateDiagram.svg|centre|vignette|upright=2|AMD64StateDiagram]] Le ''system management mode'' a déjà été abordé dans le chapitre sur les interruptions. Pour simplifier, il ne s'agit pas d'un véritable mode au sens "adressage". Dans ce mode, le système d'exploitation est mis en pause et le BIOS ou un autre firmware s'exécute sans restriction. On y rentre grâce à une interruption dédiée, lancée par le noyau de l'OS. Le système d'exploitation exécute cette interruption afin de laisser la main au BIOS, pour lui déléguer certaines tâches. Par contre, les autres modes sont bien plus intéressant, car ils ont chacun un espace d'adressage différent. Voyons-les dans le détail. ===Le mode réel : l'adressage sur 20 bits=== Le tout premier mode était le '''mode réel''', qui utilisait des adresses de 20 bits, ce qui fait 1 mébioctet de mémoire adressable. Il était utilisé sur des processeurs 16 bits, qui géraient donc des adresses de 16 bits capables d'adresser 64 kibioctets de mémoire. Pour contourner cette limitation, le processeur utilisait la segmentation, que nous verrons dans le prochain chapitre. Pour le moment, nous allons dire que la mémoire en mode réel était composée de 16 blocs de 64 kibioctets. C'est une simplification qui ne fera pas de mal. Le processeur démarre systématiquement en mode réel, y compris sur les ordinateurs modernes. Le BIOS s'exécute dans ce mode, avant de passer en mode protégé et de laisser la main à l'UEFI et au système d'exploitation. Autrefois, le système d'exploitation s'exécutait aussi en mode réel, c'est le cas du DOS. En mode réel, l'espace d'adressage est peuplé par de la RAM, la ROM du BIOS et des périphériques. La RAM prenait les adresses basses, la ROM était au sommet de l'espace d'adressage, et les périphériques entre les deux. Les premiers 640 kibioctets sont réservés à la RAM et sont appelés la '''mémoire conventionnelle'''. Les octets restants forment la '''mémoire haute''', réservée aux ROM et aux périphériques. Les deux premiers kibioctets de la mémoire conventionnelle sont initialisés au démarrage de l'ordinateur. Ils sont utilisés pour stocker le vecteur d'interruption (on expliquera cela dans quelques chapitres) et servent aussi au BIOS. La portion réservée au BIOS, la ''BIOS Data Area'', mémorise des informations en RAM. Elle commence à l'adresse 0040:0000h, a une taille de 255 octets, et est initialisée lors du démarrage de l'ordinateur. Le reste de la mémoire conventionnelle est réservée au le système d'exploitation (MS-DOS, avant sa version 5.0) et au programme en cours d’exécution. La mémoire haute est sectionnée en deux portions. Le sommet de la mémoire haute est réservé au BIOS, alors que le bas de la mémoire haute est réservé aux périphériques. Dans cette dernière, on trouve les BIOS des périphériques (dont celui de la carte vidéo, s'il existe), qui sont nécessaires pour les initialiser et pour communiquer avec eux. De plus, on y trouve la mémoire de la carte vidéo et éventuellement la mémoire d'autres périphériques comme la carte son. {| class="wikitable" style="text-align:center;" |+ Espace d'adressage en mode réel |- ! Numéro du bloc !! Contenu du bloc |- | 1024 - 960 Kio || class="f_jaune" | BIOS principal (ROM) |- | 960 Kio - 640 Kio | class="f_vert" | ROM d'extension des périphériques (XT, EGA, 3270 PC) </br> Mémoire vidéo des cartes EGA, MDA ou CGA |- | 640 Kio - 0 || class="f_rouge" | Mémoire RAM, mémoire conventionnelle |} Pour détailler un peu plus, il faut tenir compte du découpage de l'espace d'adressage en blocs de 64 kibioctets. Le découpage en blocs de 64 kibioctets est particulièrement important pour la mémoire haute. Des blocs sont attribués à un périphérique spécifique. Par exemple, le 16ème bloc est réservé au BIOS, le 12ème aux ROM des périphériques, le 10ème et le 11ème à la mémoire vidéo, etc. {| class="wikitable" style="text-align:center;" |+ Espace d'adressage en mode réel |- ! Numéro du bloc !! Contenu du bloc |- | 0 || rowspan="10" class="f_rouge" | Mémoire RAM, mémoire conventionnelle |- | 1 |- | 2 |- | 3 |- | 4 |- | 5 |- | 6 |- | 7 |- | 8 |- | 9 |- | 10 || class="f_vert" | Mémoire vidéo des cartes EGA |- | 11 || class="f_vert" | Mémoire vidéo des cartes MDA ou CGA |- | 12 || class="f_vert" | ROM d'extension des périphériques (XT, EGA, 3270 PC) |- | 13 || class="f_gris" | Autre, non-réservé |- | 14 || class="f_gris" | Autre, non-réservé |- | 15 || class="f_jaune" | ROM du BIOS |} Vous remarquerez que les blocs 13 et 14 sont non-réservés. Ils peuvent être utilisés pour mapper des périphériques en mémoire. Mais si aucun périphérique n'est mappé dedans, les blocs sont laissés libres. Les OS n'hésitaient pas à les utiliser pour adresser de la RAM. Par exemple, si l'ordinateur n'a pas de carte vidéo EGA, le bloc qui leur était réservé était réutilisé comme mémoire RAM. La mémoire conventionnelle passait alors de 640 à 704 kibioctets de RAM disponibles. L'espace d'adressage correspond à la ROM du BIOS. Mais avec le temps, la ROM est devenue plus lente au point qu'exécuter du code directement depuis la ROM du BIOS était un problème niveau performance. Je rappelle que le BIOS contient de nombreuses routines d'interruptions qui étaient appelées par les systèmes d'exploitation de l'époque, notamment le DOS. Heureusement, diverses optimisations permettaient de copier le BIOS dans la mémoire RAM, plus rapide que la ROM du BIOS. Il s'agit des techniques de '''''BIOS Shadowing'''''. Elles s'appliquaient au BIOS proprement dit, mais le BIOS vidéo pouvait aussi être optimisé ainsi, mais les ROM d'extension ne le sont généralement pas. Le ''BIOS Shadowing'' peut s'implémenter de plusieurs manières, mais la plus courante mappait temporairement de la RAM dans le même espace d'adressage que la ROM du BIOS. Le code du BIOS copiait le BIOS avec une boucle, dont chaque itération lisait une adresse du BIOS et écrivait dans cette même adresse. Juste avant la boucle, le ''chipset'' était configuré par le BIOS pour que les lectures atterrissent dans la ROM du BIOS, mais que les écritures tombaient dans une mémoire RAM mappée. Une fois la copie/boucle terminée, l'adressage tombait dans la RAM aussi bien pour les lectures et écritures. Elle demandait un support du ''chipset'' et du logiciel, pour détourner les adresses temporairement. ===Interlude : les technologies d'expanded memory=== Déjà à l'époque, 1 mébioctet était une limite assez faible, même si cela n'a pas empêché à un grand dirigeant de l'industrie informatique de dire que "640K ought to be enough for anybody". Aussi, diverses solutions matérielles ont vu le jour. Elles étaient peu utilisées et ont concrètement servi à des applications spécifiques. La principale est l''''''expanded memory''''' (EMS), une solution utilisant une carte d'extension avec plusieurs modules de RAM installés dessus. Les modules étaient accédés via la technique de la commutation de banque. [[File:Expanded memory.svg|centre|vignette|upright=2|L'espace d'adressage avec ''Expanded memory''. La carte d'extension et le matériel requis ne sont pas représentés.]] Le terme ''expanded memory'' regroupe plusieurs technologies propriétaires semblables sur le principe mais différentes dans l'exécution. Les premières utilisaient des banques de 64 kibioctets, qui étaient mappées dans un bloc de 64 kibioctets libre en mémoire haute. L'usage de banques de 64 kibioctets collait bien avec l'adressage particulier du mode réel, où la mémoire était gérée par blocs de 64 kibioctets. Les technologies ultérieures utilisaient 2 à 4 pages de 16 kibioctets, qui pouvaient être placée n'importe où en mémoire conventionnelle. [[File:EmulexPersyst 4M ISA.jpeg|centre|vignette|upright=2|Carte d'extension ''Expanded memory'' de marque Emulex Persyst, de 4 mébioctets.]] L'''expanded memory'' demandait que les programmes soient codés pour utiliser cette fonctionnalité. Pour cela, ils devaient utiliser une technique de programmation nommée ''Overlay programming''. L'idée est de découper le programme en plusieurs blocs séparés, appelés des '''''overlays'''''. Certains blocs sont en permanence en RAM, mais d'autres sont soit chargés en RAM, soit stockés ailleurs. Le ailleurs est souvent le disque dur, ou dans la RAM ''expanded memory'' si elle est disponible. Le va-et-vient des ''overlays'' entre RAM et disque dur était réalisé en logiciel, par le programme lui-même. [[File:Overlay Programming.svg|centre|vignette|upright=1.5|''Overlay Programming''.]] L'utilisation de l'''expanded memory'' utilisait l'interruption logicielle 67h, une interruption peu utilisée à l'époque, qui était libre. La carte d'extension était utilisée à travers un pilote de périphérique dédié, qui était appelé via l'interruption 67h. ===Le mode protégé : l'adressage sur 24 et 32 bits=== Par la suite, le mode réel a été remplacé par le '''mode protégé'''. Il utilise la mémoire virtuelle, ce qui fait qu'on devrait le détailler dans le chapitre suivant. Il permettait aussi de dépasser la limite du mébioctet de mémoire. L'espace d'adressage en mode protégé est passé de 24 bits sur le CPU 286, à 32 bits sur le 386 et les CPU suivants. Les programmes conçus pour le mode réel ne pouvaient pas s'exécuter en mode protégé. Pour corriger cela, le 286 a introduit le '''mode virtuel 8086''', aussi appelé mode V86. Il s'agit d'une technique de virtualisation, ce qui fait qu'on en parlera en détail dans le chapitre sur la virtualisation assistée en matériel. Tout ce que l'on peut dire ici est que le mode V86 a été détourné pour que les applications DOS utilisent la mémoire au-delà du mébioctet, appelée la '''mémoire étendue'''. Les logiciels DOS accédaient à la mémoire étendue à travers un driver DOS dédié (HIMEM.SYS). Les logiciels DOS déplaçaient des données dans la mémoire étendue, puis les rapatriaient en mémoire conventionnelle quand ils en avaient besoin. Il ne faut pas confondre mémoire étendue et ''expanded memory''. La technique de ''expanded memory'' repose sur de la commutation de banque, demande une carte d'extension dédiée, fonctionne sans mode V86. A l'inverse, la mémoire étendue ne gère pas de commutation de banque, ne demande pas de carte d'extension, mais utilise le mode V86. Par contre, il est possible d'émuler l'''expanded memory'' sans carte d'extension, avec la mémoire étendue. Quelques cartes mères intégraient des techniques pour, mais une émulation logicielle était aussi possible en réécrivant l'interruption 67h. ===Le mode long : l'adressage 64 bits=== Par la suite, les processeurs ont introduit le '''mode long''', dans lequel les adresses font 64 bits. Le CPU en mode long a aussi un jeu d'instruction totalement différent, des registres en plus et d'autres fonctionnalités actives uniquement dans ce mode. Les programmes compilés pour le 64 bits s'exécutent nativement dans ce mode, alors que les programmes 32 et 16 bits s'exécutent dans un mode de compatibilité dédié. Sur les systèmes 64 bits, le bus d'adresse fait seulement 48 bits afin d'économiser des interconnexions. Avec 48 bits, il manque 64 - 48 = 16 bits d'adresses, qui ne peuvent pas être utilisés pour adresser quoi que ce soit. La règle est que ces 16 bits doivent être tous égaux : soit ils valent tous 0, soient ils sont tous à 1. Les adresses qui respectent cette contrainte sont appelées des '''adresses canoniques'''. Le résultat est que l'espace d'adressage est coupé en trois sections, comme illustré ci-dessous. * Les '''adresses canoniques basses''' ont leurs 16 bits de poids fort à 0 et sont situées en bas de l'espace d'adressage, dans les premières adresses. * Les '''adresses canoniques hautes''' ont leurs 16 bits de poids fort à 1 et sont situées au sommet de l'espace d'adressage, dans les dernières adresses. * Entre les deux, se trouvent des '''adresses non-canoniques''', qui ne sont pas accessibles. Y accéder déclenche la levée d'une exception matérielle. {| |[[File:AMD64-canonical--48-bit.png|vignette|Espace d'adressage x86-64 bits, avec des adresses physiques de 48 bits.]] |[[File:AMD64-canonical--56-bit.png|vignette|Espace d'adressage x86-64 bits, avec des adresses physiques de 57 bits.]] |} : Les futurs systèmes x86 devraient passer à des adresses de 57 bits, les trois sections auront donc des frontières différentes. Les adresses non-canoniques sont censées être inutilisables. Mais les programmeurs aimeraient bien pouvoir les utiliser pour des ''pointeurs tagués'', à savoir des pointeurs/adresses associées à des informations. L'idée serait d'utiliser les 16 bits de poids fort pour stocker des informations liées au pointeur, seuls les 48 bits restant codant l'adresse. Les 16 bits peuvent être utilisés de manières très diverses. Un exemple est la technique du '''''memory tagging''''', qui crée une somme de contrôle au pointeur. La somme de contrôle est générée par un algorithme cryptographique, à partir de l'adresse du pointeur. Elle est vérifiée à chaque utilisation du pointeur. Si la somme de contrôle ne correspond pas au pointeur, une erreur est levée. Si jamais un virus ou un code malveillant modifie un pointeur, il ne saura pas comment calculer la somme de contrôle, la somme de contrôle a énormément de chances d'être incorrecte. Quelques fonctionnalités des processeurs visent à autoriser l'utilisation des adresses non-canoniques. L'idée est que les 16 bits de poids fort sont ignorées lors des accès mémoire, ce qui permet d'utiliser les 16 bits de poids fort à volonté. Il s'agit des techniques ''Top Byte Ignore'' d'ARM, ''Upper Address Ignore'' d'AMD, et ''Linear Address Masking'' d'Intel. ==La z/architecture d'IBM== IBM a sa propre gamme de ''mainframes'', qui utilisent des processeurs fait maison par IBM lui-même. Les CPU IBM ont leur propre jeu d'instruction CISC, appelé '''''z/architecture'''''. Et ce jeu d'instruction a des possibilités en termes d'adressage qui sont extrêmement intéressantes, en plus d'être assez inédites. Pour ce chapitre, nous allons nous concentrer sur une fonctionnalité : les programmes ont accès à plusieurs espaces d'adressage naturellement. ===L'isolation des processus=== Les CPU ''z/architecture'' peuvent fonctionner en quatre modes, dont l'un permet à un programme d'utiliser plusieurs espaces d'adressage. L'avantage pour les programmes utilisateur n'est pas très évident, mais il l'est nettement plus pour les systèmes d'exploitation. Le système d'exploitation de ces ''mainframes'', le z/OS, profite de cette possibilité, idem pour l'hyperviseur z/VM. Il utilise cette possibilité pour donner un espace d'adressage séparé pour chaque programme en cours d'exécution. Les programmes sont alors isolés les uns des autres. Cette '''isolation des processus''' n'est pas spécifique à IBM : tous les systèmes d'exploitation modernes l'utilisent. Mais les processeurs x86 doivent se débrouiller seulement avec des techniques dites de mémoire virtuelle, alors que les ''mainframes'' IBM complètent la mémoire virtuelle avec des facilités en plus. Pour les programmes utilisateurs, un intérêt est que cela permet de partager des données entre plusieurs programmes. Ils ont un espace d'adressage rien qu'à eux, mais les données partagées sont placées dans un espace d'adressage commun, auquel les deux programmes ont accès. L'espace d'adressage partagé peu même être configuré de manière à ne contenir que des données, il est impossible d'exécuter du code depuis cet espace d'adressage. Ce qui empêche certains ''malwares'' de fonctionner. ====Les espaces d'adressage par défaut de la ''z/architecture''=== La ''z/architecture'' supporte 65536 espaces d'adressage simultanés. Les espaces d'adressage sont numérotés, ils ont chacun un identifiant codé sur 16 bits, appelé un '''''Address-Space Number'''''. Nous utiliserons l’abréviation ASN dans ce qui suit. Par contre, un programme ne peut pas utiliser tout ces espaces d'adressages, il ne peut en utiliser que 16 en même temps, maximum. De plus, ils ne sont accessibles que quand le processeur est dans un mode de fonctionnement bien précis. Pour utiliser un espace d'adressage, un programme doit connaitre l'ASN associé. Par défaut, un programme exécuté sur la ''z/architecture'' utilise deux espaces d'adressages : l''''espace d'adressage primaire''' où se situent le programme exécuté et les données importantes, un '''espace d'adressage secondaire''' utilisé pour des tâches/données annexes. L'ASN de ces deux espaces d'adressage est mémorisé dans les registres de contrôle du processeur. L'ASN de l'espace primaire est dans le registre de contrôle n°3, l'ASN de l'espace secondaire est dans le registre de contrôle n°4 (ils sont dans les 16 bits de poids faible, dans les deux cas). Les deux registres de controle sont initialisés au démarrage de l'application, par le système d'exploitation, ce qui fait que ce sont des espaces d'adressage par défaut. Le programme a donc accès à ces deux espaces d'adressage sans rien faire. : Ils ont aussi accès à un '''espace d'adressage ''home''''' unique qui est surtout utilisé par le système d'exploitation, qui a pour particularité de ne pas avoir d'ASN. Mais comment le processeur pour utiliser ces espaces d'adressages ? Par exemple, s'il veut faire une lecture, comment précise-t-il l'espace d'adressage courant ? La réponse est que le processeur peut être dans deux états : soit primaire, soit secondaire. Dans le premier état, toutes les lectures/écritures se font dans l'espace d'adressage primaire. Dans le second, elles se font toutes dans l'espace d'adressage secondaire. Il peut passer de l'un à l'autre en utilisant l'instruction ''Set Address Space Control''. ===Les registres d'accès de la ''z/architecture''=== Les CPU ''z/architecture'' disposent de 16 registres généraux de 64 bits, de 16 registres flottants eux aussi de 64 bits, de 16 registres de contrôle eux aussi de 64 bits, et de '''registres d'accès''' qui gèrent les espaces d'adressage. Chaque registre entier est associé à un espace d'adressage particulier, qui est indiqué par son registre d'accès. Les registres d'accès sont utilisés lors des lectures/écritures indirectes. L'adresse est lue depuis un registre général, et son registre d'accès est consulté pour déterminer l'espace d'adressage. Une implémentation simple numéroterait les espaces d'adressage et mettrait les numéros dans les registres d'accès. Les registres d'accès seraient idéalement protégés en écriture et seulement accessibles par le système d'exploitation. Mais ce n'est pas ce qui est fait, pour des raisons de sécurité. Les registres d'accès mémorisent bien un identifiant, mais celui-ci ne sélectionne pas l'espace d'adressage directement. L'identifiant est envoyée à une table de correspondance matérielle, qui mémorise les correspondances entre un identifiant et un espace d'adressage. Il y a une table de correspondance par programme, ce qui fait qu'un identifiant ne correspondra pas au même espace d'adressage dans deux programmes différents. La table de correspondance mémorise aussi des droits d'accès, qui sont différents pour chaque programme. Par exemple, tel programme aura accès en lecture seule à tel espace d'adressage, alors qu'un autre programme y aura accès en lecture et écriture, etc. Un espace d'adressage peut ainsi être partagé entre deux programmes plus facilement, tout en ajoutant des garanties de sécurité. L'usage des registres d'accès est aussi plus flexible qu'on ne pourrait le croire. Le programme peut lire et écrire dans les registres d'accès, copier un registre d'accès dans un autre, etc. Par contre, la table de correspondance empêche à un programme d’accéder à un espace d'adressage arbitraire. Le système d'exploitation remplit la table de manière à n'autoriser l'accès qu'aux espaces d'adressage adéquats, il y enregistre seulement les correspondances utiles. Si un programme forge un identifiant arbitraire, et l'envoie à la table, celle-ci n'aura pas la correspondance associée et l'accès mémoire sera invalidé. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs 8 bits et moins | prevText=Les processeurs 8 bits et moins | next=L'abstraction mémoire et la mémoire virtuelle | nextText=L'abstraction mémoire et la mémoire virtuelle }} </noinclude> 9w5pp965ei2if7bhff1kfkshieijnpi 774361 774360 2026-10-07T14:29:23Z Mewtow 31375 /* =Les espaces d'adressage par défaut de la z/architecture */ 774361 wikitext text/x-wiki L''''espace d'adressage''' du processeur est l'ensemble des adresses utilisables par le processeur. Par exemple, un processeur 16 bits peut adresser 2^16 = 65536 adresses, l'ensemble de ces adresses forme son espace d'adressage. L'espace d'adressage n'est pas toujours égal à la mémoire réellement installée. S'il n'y a pas assez de RAM installée, des adresses seront inoccupées. De plus, une partie de l'espace d'adressage peut être détourné pour communiquer avec les périphériques, comme nous le verrons plus bas. Nous verrons aussi qu'un processeur peut avoir plusieurs espaces d'adressages séparés. ==L'adressage de la RAM et de la ROM== Avoir plusieurs espaces d'adressage spécialisés est quelque chose que nous avons déjà rencontré dans les chapitres précédents, mais sans le dire ouvertement. Aussi, nous allons faire quelques rappels sur les cas déjà rencontrés. En premier lieu, nous allons rappeler la différence entre architectures Von Neumann et Hardvard. La différence entre les deux tient dans l'adressage des mémoires RAM et ROM : est-ce qu'elles sont dans un seul espace d'adressage, ou dans des espaces d'adressage séparés. L''''architecture Von Neumann''' a un seul espace d'adressage, découpé entre la mémoire RAM d'un côté et la mémoire ROM de l'autre. Une adresse correspond soit à la mémoire RAM, soit à la mémoire ROM, mais pas aux deux. Typiquement, la mémoire ROM est placée dans les adresses hautes, les plus élevées, alors que la RAM est placée dans les adresses basses en commençant par l'adresse 0. C'est une convention qui n'est pas toujours respectée, aussi mieux vaut éviter de la tenir pour acquise. [[File:Vision de la mémoire par un processeur sur une architecture Von Neumann.png|centre|vignette|upright=2|Vision de la mémoire par un processeur sur une architecture Von Neumann.]] L''''architecture Harvard''' utilise des espaces d'adressage séparés pour la RAM et la ROM. Une même adresse peut correspondre soit à la ROM, soit à la RAM. Le processeur voit bien deux mémoires séparées, chacune dans son propre espace d'adressage. Les deux espaces d'adressage n'ont pas forcément la même taille. Il est possible d'avoir un plus gros espace d'adressage pour la RAM que pour la ROM. Cela implique que les adresses des instructions et des données soient de taille différentes. C'est peu pratique et c'est rarement implémenté, ce qui fait que le cas le plus courant est celui où les deux espaces d'adressages ont la même taille. [[File:Vision de la mémoire par un processeur sur une architecture Harvard.png|centre|vignette|upright=2|Vision de la mémoire par un processeur sur une architecture Harvard.]] Sur une '''architecture Harvard modifiée''', le programme est placé dans un espace d'adressage à part du reste. Le problème, c'est que cet espace d'adressage ne contient pas que le code machine à exécuter. Il contient aussi des constantes, à savoir des données qui gardent la même valeur lors de l'exécution du programme. Elles peuvent être lues, mais pas modifiées durant l'exécution du programme. L'accès à ces constantes demande d'aller lire celles-ci dans l'autre espace d'adressage, pour les copier dans les registres généraux. Le processeur doit pour cela avoir des instructions de lecture dédiées. En clair, il y a une instruction LOAD pour lire dans la mémoire ROM, une autre pour lire dans la RAM. [[File:Espaces d'adressage sur une archi harvard modifiée.png|centre|vignette|upright=3|Espaces d'adressage sur une archi harvard modifiée]] Une autre solution utilise des instructions de copie, qui copient les constantes en mémoire RAM, donc d'un espace d'adressage à l'autre. [[File:Organisation des espaces d'adressage sur une archi harvard modifiée.png|centre|vignette|upright=3|Organisation des espaces d'adressage sur une archi harvard modifiée]] ==L'adressage des périphériques== Passons maintenant à l'adressage des périphériques. La communication avec les périphériques se fait par l'intermédiaire de registres d’interfaçage. Et ces registres peuvent soit avoir un espace d'adressage séparé, soit être inclus dans l'espace d'adressage des mémoires. Dans ce qui suit, nous allons supposer que l'architecture des de type Von Neumann pour simplifier les explications. ===Les entrées-sorties mappées en mémoire=== Une partie de l'espace d'adressage peut être détourné pour communiquer avec les périphériques. Le périphérique se retrouve inclus dans l'ensemble des adresses utilisées pour manipuler la mémoire : on dit qu'il est mappé en mémoire. Les adresses mémoires associées à un périphérique sont redirigées automatiquement vers les registres du périphérique en question, voire vers sa mémoire intégrée. On parle alors d''''entrées-sorties mappées en mémoire'''. [[File:IO mappées en mémoire.png|centre|vignette|upright=2.0|IO mappées en mémoire]] On remarque un défaut inhérent à cette technique : les adresses utilisées pour les périphériques ne sont plus disponibles pour la RAM. On ne peut plus adresser autant de RAM qu'avant. La perte dépend des périphériques installés et de leur gourmandise en adresses mémoires. C'est ce qui causait un problème assez connu sur les ordinateurs 32 bits, capables d'adresser 4 gibioctets de mémoire. Certaines personnes installaient 4 gigaoctets de mémoire sur leur ordinateur 32 bits et se retrouvaient avec « seulement » 3,5 à 3,8 gigaoctets de mémoire, les périphériques prenant le reste. Il est possible que la RAM d'un périphérique soit mappée en RAM. Un exemple classique est celui des cartes graphiques qui incorporent une mémoire RAM appelée la mémoire vidéo. La mémoire vidéo est mappée en mémoire, ce qui permet au processeur d'écrire directement dedans. Toute lecture ou écriture dans les adresses associées est redirigée vers le bus PCI/AGP/PCI-Express. Nous verrons plus bas des exemples d'ordinateurs où la mémoire vidéo est mappée en mémoire, que ce soit totalement ou partiellement. Si je dis totalement ou partiellement, c'est parce que les cartes graphiques modernes disposent de tellement de mémoire qu'on ne peut pas la mapper totalement dans l'espace d'adressage. Sur les systèmes d'avant 2008, seuls 256 mégaoctets de mémoire vidéo sont mappés en mémoire RAM. Le reste de la mémoire vidéo est invisible du point de vue du processeur, mais manipulable par le GPU à sa guise. Après 2008, la spécification du PCI-Express ajouta un support de la technologie ''Resizable Bar'', qui permet au processeur d’accéder directement à plus de 256 mégaoctets de mémoire vidéo, voire à la totalité de la mémoire vidéo. ===L'espace d'adressage séparé pour les entrées-sorties=== Les entrées-sorties et périphériques peuvent avoir leur propre espace d'adressage dédié, séparé de celui utilisé pour les mémoires RAM et ROM. [[File:Espaces_d'adressages_séparés_entre_mémoire_et_périphérique.png|centre|vignette|upright=3|Bit IO.]] Une même adresse peut donc adresser soit une entrée-sortie, soit une case mémoire. Et pour faire la différence, le processeur doit avoir des instructions séparées pour adresser les périphériques et la mémoire. Il a des instructions de lecture/écriture pour lire/écrire en mémoire, et d'autres pour lire/écrire les registres d’interfaçage. Sans cela, le processeur ne saurait pas si une adresse est destinée à un périphérique ou à la mémoire. Cela élimine aussi les problèmes avec les caches : les accès à l'espace d'adressage de la RAM passent par l'intermédiaire de la mémoire cache, alors les accès dans l'espace d'adressage des périphériques le contournent totalement. Là encore, les deux espaces d'adressage n'ont pas forcément la même taille. Il arrive que les deux espaces d'adressage aient la même taille, le plus souvent sur des ordinateurs complexes avec beaucoup de périphériques. Mais les systèmes embarqués ont souvent des espaces d'adressage plus petits pour les périphériques que pour la ou les mémoires. L'implémentation varie grandement suivant le cas, la première méthode imposant d'avoir deux bus séparés pour les mémoires et les périphériques, l'autre permettant un certain partage du bus d'adresse. Nous reviendrons dessus plus en détail dans le chapitre sur l'adressage des périphériques. ==La ''memory map'' d'un ordinateur== Les deux sections précédentes nous ont appris que l'on peut utiliser un espace d'adressage séparé pour la ROM et un autre pour les périphériques. En tout, cela donne quatre possibilités distinctes. {|class="wikitable" |- ! ! IO mappées en mémoire ! IO séparées |- ! ROM mappée en mémoire (architecture Von Neumann) | Un seul espace d'adressage | Deux espaces d'adressage : * un pour les mémoires RAM/ROM ; * un pour les IO. |- ! ROM séparée (architecture Harvard) | Deux espaces d'adressage : * un pour la RAM et les IO ; * un pour la ROM. | Trois espaces d'adressages : * un pour la RAM ; * un pour la ROM ; * un pour les IO. |} Les quatre solutions ont des avantages et inconvénients divers, mais il est intéressant de contraster un espace d'adressage unique avec plusieurs espaces d'adressages. ===L'espace d'adressage unique=== Avec un espace d'adressage unique, la ROM est au sommet de l'espace d'adressage, les périphériques sont juste en-dessous, la RAM commence à l'adresse 0 et prend les adresses basses. Faire simplifie grandement l'implémentation matérielle de l'adressage. Notons que d'autres composants que les périphériques ou les mémoires peuvent se trouver dans l'espace d'adressage. On peut y trouver les horloges temps réels, des timers, des senseurs de température, ou d'autres composants placés sur la carte mère. Un exemple un peu original est le suivant : la console de jeu Nintendo DS incorporait une unité de calcul spécialisée dans les divisions et racines carrées, séparée du processeur, qui était justement mappée en mémoire ! [[File:Espace d'adressage classique avec entrées-sorties mappées en mémoire.png|centre|vignette|upright=2|Espace d'adressage classique avec entrées-sorties mappées en mémoire]] L'espace d'adressage unique est plus simple pour les programmeurs, malgré un désavantage dont il faut parler. Le problème est que des adresses censées être disponibles pour la RAM sont détournées vers la ROM ou les périphériques, ce qui limite la quantité de RAM qui peut être réellement adressée en pratique. C'est un défaut qui se manifeste seulement pour les petits espaces d'adressages, de 8 à 16 bits, qui ne permettent pas d'adresser beaucoup de RAM. Avec des adresses codées sur 16 bits, un espace d'adressage de 64 kibioctets de RAM est grignoté par des entrées-sorties assez rapidement. Par contre, pour des adresses de 32 à 64 bits, une bonne partie de l'espace d'adressage est inutilisé, car il n'y a pas assez de RAM. L'espace d'adressage unique n'a donc pas de désavantages. Les vielles consoles de jeux et certains vieux ordinateurs (Commodores et Amiga), utilisaient un espace d'adressage unique. Elles n'avaient pas une variété de cartes graphiques ou de cartes sons différentes, comme sur les PCs modernes. Au contraire, elles avaient la même configuration matérielle, le matériel ne pouvait pas être changé ni upgradé. Le système d'exploitation était rudimentaire et ne contrôlait pas vraiment l'accès au matériel. Les programmeurs avaient donc totalement accès au matériel et mapper les entrées/sorties en mémoire rendait la programmation des périphériques très simple. <gallery widths=200px heights=500px> N5200mk2 memory map.svg|Adressage mémoire (carte mémoire) du N5200mk2. PC-9801VM memory map.svg|Adressage mémoire (carte mémoire) du PC-9801VM. </gallery> ===La commutation de banques (''bank switching'')=== Le '''''bank switching''''', aussi appelé '''commutation de banque''', permet d'utiliser plusieurs espaces d'adressage sur un même processeur, sans attribuer chaque espace d'adressage pour une raison précise. L'espace d'adressage est présent en plusieurs exemplaires appelés des '''banques'''. Les banques sont numérotées, chaque numéro de banque permettant de l'identifier et de le sélectionner. Le but de cette technique est d'augmenter la mémoire disponible pour l'ordinateur. Par exemple, supposons que j'ai besoin d'adresser une mémoire ROM de 4 kibioctets, une RAM de 8 kibioctets, et divers périphériques. Le processeur a un bus d'adresse de 12 bits, ce qui limite l'espace d'adressage à 4 kibioctets. Dans ce cas, je peux réserver 4 banques : une pour la ROM, une pour les périphériques, et deux banques qui contiennent chacune la moitié de la RAM. La simplicité et l'efficacité de cette technique font qu'elle est beaucoup utilisée dans l'informatique embarquée. [[File:PC-8801MemoryMap.gif|centre|vignette|upright=2|exemple de Bank switching.]] Cette technique demande d'utiliser un bus d'adresse plus grand que les adresses du processeur. L'adresse réelle se calcule en concaténant le numéro de banque avec l'adresse accédée. Le numéro de la banque actuellement en cours d'utilisation est mémorisé dans un registre appelé le '''registre de banque'''. On peut changer de banque en changeant le contenu de ce registre. Le processeur dispose souvent d'instructions spécialisées qui en sont capables. {| class="wikitable flexible" |[[File:Banque mémoire.png|Banque mémoire.]] |[[File:Registre de banque.png|Registre de banque.]] |} ==Le placement des programmes dans l'espace d'adressage== Les ordinateurs modernes sont des architectures Von Neumann, avec un seul espace d'adressage, qui est partagé suivant les besoins. Et un point important est que le système d'exploitation et les programmes sont copiés en mémoire RAM avant d'être exécutés. Ils ne sont pas exécutés depuis une mémoire ROM, cela est réservé aux systèmes embarqués simples. Et cela a une influence sur l'espace d'adressage. Les programmes et le système d'exploitation doivent se partager l'espace d'adressage. Voyons voir comment ce partage se fait. Dans ce chapitre, nous allons volontairement mettre de côté les ordinateurs qui supportent la mémoire virtuelle. Nous en parlerons dans le prochain chapitre. Le principe est de voir comment un processeur avec un seul espace d'adressage gèrent la présence d'un ou de plusieurs programmes. ===Un seul espace d'adressage, non-partagé=== Le cas le plus simple est celui où il n'y a pas de système d'exploitation et où un seul programme s'exécute sur l'ordinateur. Le programme a alors accès à tout l'espace d'adressage, sauf pour ce qui est réservé aux périphériques et à la mémoire ROM. Le programme organise la mémoire en plusieurs sections, dans lesquelles le programme range des données différentes. Typiquement, on trouve quatre blocs de mémoire, appelés des segments, chacun étant spécialisé pour les données, le code, la pile, etc. Voici ces trois sections : * Le segment '''''text''''' 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. * Le segment pour la '''pile''', de taille variable. * le reste est appelé le '''tas''', de taille variable. [[File:Organisation d'un espace d'adressage unique utilisé par un programme unique.png|centre|vignette|upright=2|Organisation d'un espace d'adressage unique utilisé par un programme unique]] [[File:Typical computer data memory arrangement.png|vignette|upright=0.5|Typical computer data memory arrangement]] La pile et le tas sont de taille variable, ce qui veut dire qu'ils peuvent grandir ou diminuer à volonté, contrairement au reste. Entre le tas et la pile, on trouve un espace de mémoire inutilisée, qui peut être réquisitionné selon les besoins. La pile commence généralement à l'adresse la plus haute et grandit en descendant, alors que le tas grandit en remontant vers les adresses hautes. Il s'agit là d'une convention, rien de plus. Il est possible d'inverser la pile et le tas sans problème, c'est juste que cette organisation est rentrée dans les usages. Avant de poursuivre, précisons qu'il est possible de regrouper plusieurs programmes distincts dans un seul, afin qu'ils partagent le même morceau de mémoire. Les programmes portent alors le nom de '''threads'''. Les ''threads'' d'un même processus partagent le même espace d'adressage. Ils partagent généralement certains segments : ils se partagent le code, le tas et les données statiques. Par contre, chaque thread dispose de sa propre pile d'appel. [[File:Single vs multithreaded processes.jpg|centre|vignette|upright=2.0|Distinction entre processus mono et multi-thread.]] Il va de soi que cette vision de l'espace d'adressage ne tient pas compte des périphériques. C'est-à-dire que les schémas précédents partent du principe qu'on a un espace d'adressage séparé pour les périphériques. Dans le cas où les entrées-sorties sont mappées en mémoire, l'organisation est plus compliquée. Généralement, les adresses associées aux périphériques sont placées juste au-dessus de la pile, dans les adresses hautes. ===Un seul espace d'adressage, partagé avec le système d'exploitation=== [[File:Gestion de la mémoire sur les OS monoprogrammés.png|vignette|upright=1.0|Gestion de la mémoire sur les OS monoprogrammés.]] Maintenant, étudions le cas où le programme partage la mémoire avec le système d'exploitation. Sur les systèmes d'exploitation les plus simples, on ne peut lancer qu'un seul programme à la fois. Le système d'exploitation réserve une portion de taille fixe réservée au système d'exploitation, alors que le reste de la mémoire est utilisé pour le programme à exécuter. Le programme est placé à un endroit en RAM qui est toujours le même. Le système d'exploitation peut être soit placé dans une ROM, soit copié en mémoire RAM depuis une mémoire de masse, comme le programme à lancer. Si le système d'exploitation est copié en mémoire RAM, il est généralement placé dans les premières adresses, les adresses basses. A l'inverse, un OS en mémoire ROM est généralement placé à la fin de la mémoire, dans les adresses hautes. Mais tout cela n'est qu'une convention, et les exceptions sont monnaie courante. [[File:Méthodes d'allocation de la mémoire avec un espace d'adressage unique.png|centre|vignette|upright=2.0|Méthodes d'allocation de la mémoire avec un espace d'adressage unique]] Sur de tels systèmes, il faut protéger l'OS contre une erreur ou malveillance d'un programme utilisateur. Notons qu'il s'agit d'une protection en écriture, pas en lecture. Le programme peut parfaitement lire les données de l'OS sans problèmes, au moins en partie. Dans ce qui suit, on part du principe que l'OS est dans les adresses basses. La solution la plus courante interdit au programme d'écrire au-delà d'une limite au-delà de laquelle se trouve le système d’exploitation. Pour cela, le processeur incorpore un '''registre limite''', qui contient l'adresse limite au-delà de laquelle un programme peut pas écrire. Pour toute écriture en espace utilisateur, l'adresse écrite est comparée au registre limite. Si l'adresse est inférieure au registre limite, le programme cherche écrire dans la mémoire réservée à l'OS. L’accès mémoire est interdit, une exception matérielle est levée et l'OS affiche un message d'erreur. [[File:Circuit total.png|centre|vignette|upright=2.0|Protection mémoire avec un registre limite.]] ===Un seul espace d'adressage, partagé entre plusieurs programmes=== Les systèmes d’exploitation modernes implémentent la '''multiprogrammation''', le fait de pouvoir lancer 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. Et il faut alors résoudre un problème : le partage de la RAM entre les différents programmes. Les différents programmes et leurs données sont placés en mémoire RAM, et il faut trouver un moyen pour répartir les différents programmes en RAM. [[File:Partitions mémoire.png|vignette|upright=1|Partitions mémoire]] Il y a plusieurs solutions pour cela, mais l'une d'entre elle est l'usage de segments mémoire. Avec elle, chaque programme se voit attribuer un ou plusieurs blocs de mémoire. Ils sont appelés des '''partitions mémoire''', ou encore des '''segments'''. Dans ce qui va suivre, nous partons du principe qu'un programme est égal à un segment, pour simplifier les explications. Mais sachez qu'un programme peut être éclaté en plusieurs segments dispersés dans la mémoire, et même être conçu pour ! Nous en reparlerons dans le chapitre sur le mémoire virtuelle. Le système d'exploitation mémorise une liste des segments importants en mémoire RAM, appelée la ''table des segments''. Il s'agit d'un tableau dont chaque entrée mémorise les informations pour un segment. Une entrée de ce tableau est appelée un '''descripteur de segment''' et contient son adresse de base, sa taille, et des autorisations liées à la protection mémoire. Précisément, l'entrée numéro N mémorise les informations du segment numéro N. A partir de l'adresse de base de la table des segments et du numéro de segment, on peut lire ou écrire un descripteur de segment. [[File:Global Descriptor table.png|centre|vignette|upright=2|Global Descriptor table]] Toutefois, l'usage de segments amène un paquet de problèmes qu'il faut résoudre au mieux. Le premier problème est tout simplement de placer les segments dans l'espace d'adressage, mais c'est quelque chose qui est du ressort du système d'exploitation. Par contre, cela implique qu'un segment peut être placé n'importe où en RAM et sa position en RAM change à chaque exécution. En conséquence, les adresses des branchements et des données ne sont jamais les mêmes d'une exécution à l'autre. Pour résoudre ce problème, le compilateur considère que le segment commence à l'adresse zéro, puis les adresses sont corrigées avant ou pendant l'exécution du programme. Cette correction est en général réalisée par l'OS lors de la copie du programme en mémoire, mais il existe aussi des techniques matérielles, que nous verrons dans le chapitre suivant. Il faut aussi prendre en compte le phénomène de l''''allocation mémoire'''. Derrière ce nom barbare, se cache quelque chose de simple : les programmes peuvent réclamer de la mémoire au système d'exploitation, pour y placer des données. Les langages de programmation bas niveau supportent des fonctions comme malloc(), qui permettent de demander un bloc de mémoire de N octets à l'OS, qui doit alors accommoder la demande. S'il n'y a pas assez de mémoire, l'appel échoue et le programme doit gérer la situation. De même, un programme peut libérer de la mémoire qu'il n'utilise plus avec des fonctions comme free(). Avec des segments, cela revient à changer la taille d'un segment, plus précisément à la fin du segment. Et tout cela est géré par le système d'exploitation, aussi on laisse cela pour le prochain chapitre. Enfin, sans protection particulière, les programmes peuvent techniquement lire ou écrire les données des autres. Pour éviter cela, il suffit de vérifier qu'un programme accède bien à son segment et pas ailleurs. Il suffit de vérifier si l'adresse lue/écrite est dans le bon intervalle. Il suffit alors d'utiliser deux registres limites, rien de plus. Mais au-delà de cette simple vérification, si un programme pouvait modifier les données d'un autre programme, on se retrouverait rapidement avec une situation non prévue par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses. Il faut donc introduire des mécanismes de protection mémoire, pour isoler les programmes les uns des autres, et éviter toute modification problématique. La protection mémoire regroupe plusieurs techniques assez variées, qui ont des objectifs différents. Elles ont pour point commun de faire intervenir à des niveaux divers le système d'exploitation et le processeur. ==La ''memory map'' des PC IBM x86== Dans ce qui suit, nous allons étudier l'espace d'adressage des processeurs x86. L'organisation de leur espace d'adressage a évolué dans le temps et l'espace d'adressage s'est adapté. Mais il s'est adapté sans modifier l'existant, pour des raisons de compatibilité. Aussi, les processeurs x86 modernes peuvent fonctionner dans plusieurs modes, appelés mode réel, protégé, virtuel 8086, long, ''system management mode'', et compatibilité 32 bits. [[File:AMD64StateDiagram.svg|centre|vignette|upright=2|AMD64StateDiagram]] Le ''system management mode'' a déjà été abordé dans le chapitre sur les interruptions. Pour simplifier, il ne s'agit pas d'un véritable mode au sens "adressage". Dans ce mode, le système d'exploitation est mis en pause et le BIOS ou un autre firmware s'exécute sans restriction. On y rentre grâce à une interruption dédiée, lancée par le noyau de l'OS. Le système d'exploitation exécute cette interruption afin de laisser la main au BIOS, pour lui déléguer certaines tâches. Par contre, les autres modes sont bien plus intéressant, car ils ont chacun un espace d'adressage différent. Voyons-les dans le détail. ===Le mode réel : l'adressage sur 20 bits=== Le tout premier mode était le '''mode réel''', qui utilisait des adresses de 20 bits, ce qui fait 1 mébioctet de mémoire adressable. Il était utilisé sur des processeurs 16 bits, qui géraient donc des adresses de 16 bits capables d'adresser 64 kibioctets de mémoire. Pour contourner cette limitation, le processeur utilisait la segmentation, que nous verrons dans le prochain chapitre. Pour le moment, nous allons dire que la mémoire en mode réel était composée de 16 blocs de 64 kibioctets. C'est une simplification qui ne fera pas de mal. Le processeur démarre systématiquement en mode réel, y compris sur les ordinateurs modernes. Le BIOS s'exécute dans ce mode, avant de passer en mode protégé et de laisser la main à l'UEFI et au système d'exploitation. Autrefois, le système d'exploitation s'exécutait aussi en mode réel, c'est le cas du DOS. En mode réel, l'espace d'adressage est peuplé par de la RAM, la ROM du BIOS et des périphériques. La RAM prenait les adresses basses, la ROM était au sommet de l'espace d'adressage, et les périphériques entre les deux. Les premiers 640 kibioctets sont réservés à la RAM et sont appelés la '''mémoire conventionnelle'''. Les octets restants forment la '''mémoire haute''', réservée aux ROM et aux périphériques. Les deux premiers kibioctets de la mémoire conventionnelle sont initialisés au démarrage de l'ordinateur. Ils sont utilisés pour stocker le vecteur d'interruption (on expliquera cela dans quelques chapitres) et servent aussi au BIOS. La portion réservée au BIOS, la ''BIOS Data Area'', mémorise des informations en RAM. Elle commence à l'adresse 0040:0000h, a une taille de 255 octets, et est initialisée lors du démarrage de l'ordinateur. Le reste de la mémoire conventionnelle est réservée au le système d'exploitation (MS-DOS, avant sa version 5.0) et au programme en cours d’exécution. La mémoire haute est sectionnée en deux portions. Le sommet de la mémoire haute est réservé au BIOS, alors que le bas de la mémoire haute est réservé aux périphériques. Dans cette dernière, on trouve les BIOS des périphériques (dont celui de la carte vidéo, s'il existe), qui sont nécessaires pour les initialiser et pour communiquer avec eux. De plus, on y trouve la mémoire de la carte vidéo et éventuellement la mémoire d'autres périphériques comme la carte son. {| class="wikitable" style="text-align:center;" |+ Espace d'adressage en mode réel |- ! Numéro du bloc !! Contenu du bloc |- | 1024 - 960 Kio || class="f_jaune" | BIOS principal (ROM) |- | 960 Kio - 640 Kio | class="f_vert" | ROM d'extension des périphériques (XT, EGA, 3270 PC) </br> Mémoire vidéo des cartes EGA, MDA ou CGA |- | 640 Kio - 0 || class="f_rouge" | Mémoire RAM, mémoire conventionnelle |} Pour détailler un peu plus, il faut tenir compte du découpage de l'espace d'adressage en blocs de 64 kibioctets. Le découpage en blocs de 64 kibioctets est particulièrement important pour la mémoire haute. Des blocs sont attribués à un périphérique spécifique. Par exemple, le 16ème bloc est réservé au BIOS, le 12ème aux ROM des périphériques, le 10ème et le 11ème à la mémoire vidéo, etc. {| class="wikitable" style="text-align:center;" |+ Espace d'adressage en mode réel |- ! Numéro du bloc !! Contenu du bloc |- | 0 || rowspan="10" class="f_rouge" | Mémoire RAM, mémoire conventionnelle |- | 1 |- | 2 |- | 3 |- | 4 |- | 5 |- | 6 |- | 7 |- | 8 |- | 9 |- | 10 || class="f_vert" | Mémoire vidéo des cartes EGA |- | 11 || class="f_vert" | Mémoire vidéo des cartes MDA ou CGA |- | 12 || class="f_vert" | ROM d'extension des périphériques (XT, EGA, 3270 PC) |- | 13 || class="f_gris" | Autre, non-réservé |- | 14 || class="f_gris" | Autre, non-réservé |- | 15 || class="f_jaune" | ROM du BIOS |} Vous remarquerez que les blocs 13 et 14 sont non-réservés. Ils peuvent être utilisés pour mapper des périphériques en mémoire. Mais si aucun périphérique n'est mappé dedans, les blocs sont laissés libres. Les OS n'hésitaient pas à les utiliser pour adresser de la RAM. Par exemple, si l'ordinateur n'a pas de carte vidéo EGA, le bloc qui leur était réservé était réutilisé comme mémoire RAM. La mémoire conventionnelle passait alors de 640 à 704 kibioctets de RAM disponibles. L'espace d'adressage correspond à la ROM du BIOS. Mais avec le temps, la ROM est devenue plus lente au point qu'exécuter du code directement depuis la ROM du BIOS était un problème niveau performance. Je rappelle que le BIOS contient de nombreuses routines d'interruptions qui étaient appelées par les systèmes d'exploitation de l'époque, notamment le DOS. Heureusement, diverses optimisations permettaient de copier le BIOS dans la mémoire RAM, plus rapide que la ROM du BIOS. Il s'agit des techniques de '''''BIOS Shadowing'''''. Elles s'appliquaient au BIOS proprement dit, mais le BIOS vidéo pouvait aussi être optimisé ainsi, mais les ROM d'extension ne le sont généralement pas. Le ''BIOS Shadowing'' peut s'implémenter de plusieurs manières, mais la plus courante mappait temporairement de la RAM dans le même espace d'adressage que la ROM du BIOS. Le code du BIOS copiait le BIOS avec une boucle, dont chaque itération lisait une adresse du BIOS et écrivait dans cette même adresse. Juste avant la boucle, le ''chipset'' était configuré par le BIOS pour que les lectures atterrissent dans la ROM du BIOS, mais que les écritures tombaient dans une mémoire RAM mappée. Une fois la copie/boucle terminée, l'adressage tombait dans la RAM aussi bien pour les lectures et écritures. Elle demandait un support du ''chipset'' et du logiciel, pour détourner les adresses temporairement. ===Interlude : les technologies d'expanded memory=== Déjà à l'époque, 1 mébioctet était une limite assez faible, même si cela n'a pas empêché à un grand dirigeant de l'industrie informatique de dire que "640K ought to be enough for anybody". Aussi, diverses solutions matérielles ont vu le jour. Elles étaient peu utilisées et ont concrètement servi à des applications spécifiques. La principale est l''''''expanded memory''''' (EMS), une solution utilisant une carte d'extension avec plusieurs modules de RAM installés dessus. Les modules étaient accédés via la technique de la commutation de banque. [[File:Expanded memory.svg|centre|vignette|upright=2|L'espace d'adressage avec ''Expanded memory''. La carte d'extension et le matériel requis ne sont pas représentés.]] Le terme ''expanded memory'' regroupe plusieurs technologies propriétaires semblables sur le principe mais différentes dans l'exécution. Les premières utilisaient des banques de 64 kibioctets, qui étaient mappées dans un bloc de 64 kibioctets libre en mémoire haute. L'usage de banques de 64 kibioctets collait bien avec l'adressage particulier du mode réel, où la mémoire était gérée par blocs de 64 kibioctets. Les technologies ultérieures utilisaient 2 à 4 pages de 16 kibioctets, qui pouvaient être placée n'importe où en mémoire conventionnelle. [[File:EmulexPersyst 4M ISA.jpeg|centre|vignette|upright=2|Carte d'extension ''Expanded memory'' de marque Emulex Persyst, de 4 mébioctets.]] L'''expanded memory'' demandait que les programmes soient codés pour utiliser cette fonctionnalité. Pour cela, ils devaient utiliser une technique de programmation nommée ''Overlay programming''. L'idée est de découper le programme en plusieurs blocs séparés, appelés des '''''overlays'''''. Certains blocs sont en permanence en RAM, mais d'autres sont soit chargés en RAM, soit stockés ailleurs. Le ailleurs est souvent le disque dur, ou dans la RAM ''expanded memory'' si elle est disponible. Le va-et-vient des ''overlays'' entre RAM et disque dur était réalisé en logiciel, par le programme lui-même. [[File:Overlay Programming.svg|centre|vignette|upright=1.5|''Overlay Programming''.]] L'utilisation de l'''expanded memory'' utilisait l'interruption logicielle 67h, une interruption peu utilisée à l'époque, qui était libre. La carte d'extension était utilisée à travers un pilote de périphérique dédié, qui était appelé via l'interruption 67h. ===Le mode protégé : l'adressage sur 24 et 32 bits=== Par la suite, le mode réel a été remplacé par le '''mode protégé'''. Il utilise la mémoire virtuelle, ce qui fait qu'on devrait le détailler dans le chapitre suivant. Il permettait aussi de dépasser la limite du mébioctet de mémoire. L'espace d'adressage en mode protégé est passé de 24 bits sur le CPU 286, à 32 bits sur le 386 et les CPU suivants. Les programmes conçus pour le mode réel ne pouvaient pas s'exécuter en mode protégé. Pour corriger cela, le 286 a introduit le '''mode virtuel 8086''', aussi appelé mode V86. Il s'agit d'une technique de virtualisation, ce qui fait qu'on en parlera en détail dans le chapitre sur la virtualisation assistée en matériel. Tout ce que l'on peut dire ici est que le mode V86 a été détourné pour que les applications DOS utilisent la mémoire au-delà du mébioctet, appelée la '''mémoire étendue'''. Les logiciels DOS accédaient à la mémoire étendue à travers un driver DOS dédié (HIMEM.SYS). Les logiciels DOS déplaçaient des données dans la mémoire étendue, puis les rapatriaient en mémoire conventionnelle quand ils en avaient besoin. Il ne faut pas confondre mémoire étendue et ''expanded memory''. La technique de ''expanded memory'' repose sur de la commutation de banque, demande une carte d'extension dédiée, fonctionne sans mode V86. A l'inverse, la mémoire étendue ne gère pas de commutation de banque, ne demande pas de carte d'extension, mais utilise le mode V86. Par contre, il est possible d'émuler l'''expanded memory'' sans carte d'extension, avec la mémoire étendue. Quelques cartes mères intégraient des techniques pour, mais une émulation logicielle était aussi possible en réécrivant l'interruption 67h. ===Le mode long : l'adressage 64 bits=== Par la suite, les processeurs ont introduit le '''mode long''', dans lequel les adresses font 64 bits. Le CPU en mode long a aussi un jeu d'instruction totalement différent, des registres en plus et d'autres fonctionnalités actives uniquement dans ce mode. Les programmes compilés pour le 64 bits s'exécutent nativement dans ce mode, alors que les programmes 32 et 16 bits s'exécutent dans un mode de compatibilité dédié. Sur les systèmes 64 bits, le bus d'adresse fait seulement 48 bits afin d'économiser des interconnexions. Avec 48 bits, il manque 64 - 48 = 16 bits d'adresses, qui ne peuvent pas être utilisés pour adresser quoi que ce soit. La règle est que ces 16 bits doivent être tous égaux : soit ils valent tous 0, soient ils sont tous à 1. Les adresses qui respectent cette contrainte sont appelées des '''adresses canoniques'''. Le résultat est que l'espace d'adressage est coupé en trois sections, comme illustré ci-dessous. * Les '''adresses canoniques basses''' ont leurs 16 bits de poids fort à 0 et sont situées en bas de l'espace d'adressage, dans les premières adresses. * Les '''adresses canoniques hautes''' ont leurs 16 bits de poids fort à 1 et sont situées au sommet de l'espace d'adressage, dans les dernières adresses. * Entre les deux, se trouvent des '''adresses non-canoniques''', qui ne sont pas accessibles. Y accéder déclenche la levée d'une exception matérielle. {| |[[File:AMD64-canonical--48-bit.png|vignette|Espace d'adressage x86-64 bits, avec des adresses physiques de 48 bits.]] |[[File:AMD64-canonical--56-bit.png|vignette|Espace d'adressage x86-64 bits, avec des adresses physiques de 57 bits.]] |} : Les futurs systèmes x86 devraient passer à des adresses de 57 bits, les trois sections auront donc des frontières différentes. Les adresses non-canoniques sont censées être inutilisables. Mais les programmeurs aimeraient bien pouvoir les utiliser pour des ''pointeurs tagués'', à savoir des pointeurs/adresses associées à des informations. L'idée serait d'utiliser les 16 bits de poids fort pour stocker des informations liées au pointeur, seuls les 48 bits restant codant l'adresse. Les 16 bits peuvent être utilisés de manières très diverses. Un exemple est la technique du '''''memory tagging''''', qui crée une somme de contrôle au pointeur. La somme de contrôle est générée par un algorithme cryptographique, à partir de l'adresse du pointeur. Elle est vérifiée à chaque utilisation du pointeur. Si la somme de contrôle ne correspond pas au pointeur, une erreur est levée. Si jamais un virus ou un code malveillant modifie un pointeur, il ne saura pas comment calculer la somme de contrôle, la somme de contrôle a énormément de chances d'être incorrecte. Quelques fonctionnalités des processeurs visent à autoriser l'utilisation des adresses non-canoniques. L'idée est que les 16 bits de poids fort sont ignorées lors des accès mémoire, ce qui permet d'utiliser les 16 bits de poids fort à volonté. Il s'agit des techniques ''Top Byte Ignore'' d'ARM, ''Upper Address Ignore'' d'AMD, et ''Linear Address Masking'' d'Intel. ==La z/architecture d'IBM== IBM a sa propre gamme de ''mainframes'', qui utilisent des processeurs fait maison par IBM lui-même. Les CPU IBM ont leur propre jeu d'instruction CISC, appelé '''''z/architecture'''''. Et ce jeu d'instruction a des possibilités en termes d'adressage qui sont extrêmement intéressantes, en plus d'être assez inédites. Pour ce chapitre, nous allons nous concentrer sur une fonctionnalité : les programmes ont accès à plusieurs espaces d'adressage naturellement. ===L'isolation des processus=== Les CPU ''z/architecture'' peuvent fonctionner en quatre modes, dont l'un permet à un programme d'utiliser plusieurs espaces d'adressage. L'avantage pour les programmes utilisateur n'est pas très évident, mais il l'est nettement plus pour les systèmes d'exploitation. Le système d'exploitation de ces ''mainframes'', le z/OS, profite de cette possibilité, idem pour l'hyperviseur z/VM. Il utilise cette possibilité pour donner un espace d'adressage séparé pour chaque programme en cours d'exécution. Les programmes sont alors isolés les uns des autres. Cette '''isolation des processus''' n'est pas spécifique à IBM : tous les systèmes d'exploitation modernes l'utilisent. Mais les processeurs x86 doivent se débrouiller seulement avec des techniques dites de mémoire virtuelle, alors que les ''mainframes'' IBM complètent la mémoire virtuelle avec des facilités en plus. Pour les programmes utilisateurs, un intérêt est que cela permet de partager des données entre plusieurs programmes. Ils ont un espace d'adressage rien qu'à eux, mais les données partagées sont placées dans un espace d'adressage commun, auquel les deux programmes ont accès. L'espace d'adressage partagé peu même être configuré de manière à ne contenir que des données, il est impossible d'exécuter du code depuis cet espace d'adressage. Ce qui empêche certains ''malwares'' de fonctionner. ===Les espaces d'adressage par défaut de la ''z/architecture''=== La ''z/architecture'' supporte 65536 espaces d'adressage simultanés. Les espaces d'adressage sont numérotés, ils ont chacun un identifiant codé sur 16 bits, appelé un '''''Address-Space Number'''''. Nous utiliserons l’abréviation ASN dans ce qui suit. Par contre, un programme ne peut pas utiliser tout ces espaces d'adressages, il ne peut en utiliser que 16 en même temps, maximum. De plus, ils ne sont accessibles que quand le processeur est dans un mode de fonctionnement bien précis. Pour utiliser un espace d'adressage, un programme doit connaitre l'ASN associé. Par défaut, un programme exécuté sur la ''z/architecture'' utilise deux espaces d'adressages : l''''espace d'adressage primaire''' où se situent le programme exécuté et les données importantes, un '''espace d'adressage secondaire''' utilisé pour des tâches/données annexes. L'ASN de ces deux espaces d'adressage est mémorisé dans les registres de contrôle du processeur. L'ASN de l'espace primaire est dans le registre de contrôle n°3, l'ASN de l'espace secondaire est dans le registre de contrôle n°4 (ils sont dans les 16 bits de poids faible, dans les deux cas). Les deux registres de controle sont initialisés au démarrage de l'application, par le système d'exploitation, ce qui fait que ce sont des espaces d'adressage par défaut. Le programme a donc accès à ces deux espaces d'adressage sans rien faire. : Ils ont aussi accès à un '''espace d'adressage ''home''''' unique qui est surtout utilisé par le système d'exploitation, qui a pour particularité de ne pas avoir d'ASN. Mais comment le processeur pour utiliser ces espaces d'adressages ? Par exemple, s'il veut faire une lecture, comment précise-t-il l'espace d'adressage courant ? La réponse est que le processeur peut être dans deux états : soit primaire, soit secondaire. Dans le premier état, toutes les lectures/écritures se font dans l'espace d'adressage primaire. Dans le second, elles se font toutes dans l'espace d'adressage secondaire. Il peut passer de l'un à l'autre en utilisant l'instruction ''Set Address Space Control''. ===Les registres d'accès de la ''z/architecture''=== Les CPU ''z/architecture'' disposent de 16 registres généraux de 64 bits, de 16 registres flottants eux aussi de 64 bits, de 16 registres de contrôle eux aussi de 64 bits, et de '''registres d'accès''' qui gèrent les espaces d'adressage. Chaque registre entier est associé à un espace d'adressage particulier, qui est indiqué par son registre d'accès. Les registres d'accès sont utilisés lors des lectures/écritures indirectes. L'adresse est lue depuis un registre général, et son registre d'accès est consulté pour déterminer l'espace d'adressage. Une implémentation simple numéroterait les espaces d'adressage et mettrait les numéros dans les registres d'accès. Les registres d'accès seraient idéalement protégés en écriture et seulement accessibles par le système d'exploitation. Mais ce n'est pas ce qui est fait, pour des raisons de sécurité. Les registres d'accès mémorisent bien un identifiant, mais celui-ci ne sélectionne pas l'espace d'adressage directement. L'identifiant est envoyée à une table de correspondance matérielle, qui mémorise les correspondances entre un identifiant et un espace d'adressage. Il y a une table de correspondance par programme, ce qui fait qu'un identifiant ne correspondra pas au même espace d'adressage dans deux programmes différents. La table de correspondance mémorise aussi des droits d'accès, qui sont différents pour chaque programme. Par exemple, tel programme aura accès en lecture seule à tel espace d'adressage, alors qu'un autre programme y aura accès en lecture et écriture, etc. Un espace d'adressage peut ainsi être partagé entre deux programmes plus facilement, tout en ajoutant des garanties de sécurité. L'usage des registres d'accès est aussi plus flexible qu'on ne pourrait le croire. Le programme peut lire et écrire dans les registres d'accès, copier un registre d'accès dans un autre, etc. Par contre, la table de correspondance empêche à un programme d’accéder à un espace d'adressage arbitraire. Le système d'exploitation remplit la table de manière à n'autoriser l'accès qu'aux espaces d'adressage adéquats, il y enregistre seulement les correspondances utiles. Si un programme forge un identifiant arbitraire, et l'envoie à la table, celle-ci n'aura pas la correspondance associée et l'accès mémoire sera invalidé. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs 8 bits et moins | prevText=Les processeurs 8 bits et moins | next=L'abstraction mémoire et la mémoire virtuelle | nextText=L'abstraction mémoire et la mémoire virtuelle }} </noinclude> 01fxx65oqw80oj9a6tw4z4l68yh69pc 774362 774361 2026-10-07T14:30:23Z Mewtow 31375 /* Les espaces d'adressage par défaut de la z/architecture */ 774362 wikitext text/x-wiki L''''espace d'adressage''' du processeur est l'ensemble des adresses utilisables par le processeur. Par exemple, un processeur 16 bits peut adresser 2^16 = 65536 adresses, l'ensemble de ces adresses forme son espace d'adressage. L'espace d'adressage n'est pas toujours égal à la mémoire réellement installée. S'il n'y a pas assez de RAM installée, des adresses seront inoccupées. De plus, une partie de l'espace d'adressage peut être détourné pour communiquer avec les périphériques, comme nous le verrons plus bas. Nous verrons aussi qu'un processeur peut avoir plusieurs espaces d'adressages séparés. ==L'adressage de la RAM et de la ROM== Avoir plusieurs espaces d'adressage spécialisés est quelque chose que nous avons déjà rencontré dans les chapitres précédents, mais sans le dire ouvertement. Aussi, nous allons faire quelques rappels sur les cas déjà rencontrés. En premier lieu, nous allons rappeler la différence entre architectures Von Neumann et Hardvard. La différence entre les deux tient dans l'adressage des mémoires RAM et ROM : est-ce qu'elles sont dans un seul espace d'adressage, ou dans des espaces d'adressage séparés. L''''architecture Von Neumann''' a un seul espace d'adressage, découpé entre la mémoire RAM d'un côté et la mémoire ROM de l'autre. Une adresse correspond soit à la mémoire RAM, soit à la mémoire ROM, mais pas aux deux. Typiquement, la mémoire ROM est placée dans les adresses hautes, les plus élevées, alors que la RAM est placée dans les adresses basses en commençant par l'adresse 0. C'est une convention qui n'est pas toujours respectée, aussi mieux vaut éviter de la tenir pour acquise. [[File:Vision de la mémoire par un processeur sur une architecture Von Neumann.png|centre|vignette|upright=2|Vision de la mémoire par un processeur sur une architecture Von Neumann.]] L''''architecture Harvard''' utilise des espaces d'adressage séparés pour la RAM et la ROM. Une même adresse peut correspondre soit à la ROM, soit à la RAM. Le processeur voit bien deux mémoires séparées, chacune dans son propre espace d'adressage. Les deux espaces d'adressage n'ont pas forcément la même taille. Il est possible d'avoir un plus gros espace d'adressage pour la RAM que pour la ROM. Cela implique que les adresses des instructions et des données soient de taille différentes. C'est peu pratique et c'est rarement implémenté, ce qui fait que le cas le plus courant est celui où les deux espaces d'adressages ont la même taille. [[File:Vision de la mémoire par un processeur sur une architecture Harvard.png|centre|vignette|upright=2|Vision de la mémoire par un processeur sur une architecture Harvard.]] Sur une '''architecture Harvard modifiée''', le programme est placé dans un espace d'adressage à part du reste. Le problème, c'est que cet espace d'adressage ne contient pas que le code machine à exécuter. Il contient aussi des constantes, à savoir des données qui gardent la même valeur lors de l'exécution du programme. Elles peuvent être lues, mais pas modifiées durant l'exécution du programme. L'accès à ces constantes demande d'aller lire celles-ci dans l'autre espace d'adressage, pour les copier dans les registres généraux. Le processeur doit pour cela avoir des instructions de lecture dédiées. En clair, il y a une instruction LOAD pour lire dans la mémoire ROM, une autre pour lire dans la RAM. [[File:Espaces d'adressage sur une archi harvard modifiée.png|centre|vignette|upright=3|Espaces d'adressage sur une archi harvard modifiée]] Une autre solution utilise des instructions de copie, qui copient les constantes en mémoire RAM, donc d'un espace d'adressage à l'autre. [[File:Organisation des espaces d'adressage sur une archi harvard modifiée.png|centre|vignette|upright=3|Organisation des espaces d'adressage sur une archi harvard modifiée]] ==L'adressage des périphériques== Passons maintenant à l'adressage des périphériques. La communication avec les périphériques se fait par l'intermédiaire de registres d’interfaçage. Et ces registres peuvent soit avoir un espace d'adressage séparé, soit être inclus dans l'espace d'adressage des mémoires. Dans ce qui suit, nous allons supposer que l'architecture des de type Von Neumann pour simplifier les explications. ===Les entrées-sorties mappées en mémoire=== Une partie de l'espace d'adressage peut être détourné pour communiquer avec les périphériques. Le périphérique se retrouve inclus dans l'ensemble des adresses utilisées pour manipuler la mémoire : on dit qu'il est mappé en mémoire. Les adresses mémoires associées à un périphérique sont redirigées automatiquement vers les registres du périphérique en question, voire vers sa mémoire intégrée. On parle alors d''''entrées-sorties mappées en mémoire'''. [[File:IO mappées en mémoire.png|centre|vignette|upright=2.0|IO mappées en mémoire]] On remarque un défaut inhérent à cette technique : les adresses utilisées pour les périphériques ne sont plus disponibles pour la RAM. On ne peut plus adresser autant de RAM qu'avant. La perte dépend des périphériques installés et de leur gourmandise en adresses mémoires. C'est ce qui causait un problème assez connu sur les ordinateurs 32 bits, capables d'adresser 4 gibioctets de mémoire. Certaines personnes installaient 4 gigaoctets de mémoire sur leur ordinateur 32 bits et se retrouvaient avec « seulement » 3,5 à 3,8 gigaoctets de mémoire, les périphériques prenant le reste. Il est possible que la RAM d'un périphérique soit mappée en RAM. Un exemple classique est celui des cartes graphiques qui incorporent une mémoire RAM appelée la mémoire vidéo. La mémoire vidéo est mappée en mémoire, ce qui permet au processeur d'écrire directement dedans. Toute lecture ou écriture dans les adresses associées est redirigée vers le bus PCI/AGP/PCI-Express. Nous verrons plus bas des exemples d'ordinateurs où la mémoire vidéo est mappée en mémoire, que ce soit totalement ou partiellement. Si je dis totalement ou partiellement, c'est parce que les cartes graphiques modernes disposent de tellement de mémoire qu'on ne peut pas la mapper totalement dans l'espace d'adressage. Sur les systèmes d'avant 2008, seuls 256 mégaoctets de mémoire vidéo sont mappés en mémoire RAM. Le reste de la mémoire vidéo est invisible du point de vue du processeur, mais manipulable par le GPU à sa guise. Après 2008, la spécification du PCI-Express ajouta un support de la technologie ''Resizable Bar'', qui permet au processeur d’accéder directement à plus de 256 mégaoctets de mémoire vidéo, voire à la totalité de la mémoire vidéo. ===L'espace d'adressage séparé pour les entrées-sorties=== Les entrées-sorties et périphériques peuvent avoir leur propre espace d'adressage dédié, séparé de celui utilisé pour les mémoires RAM et ROM. [[File:Espaces_d'adressages_séparés_entre_mémoire_et_périphérique.png|centre|vignette|upright=3|Bit IO.]] Une même adresse peut donc adresser soit une entrée-sortie, soit une case mémoire. Et pour faire la différence, le processeur doit avoir des instructions séparées pour adresser les périphériques et la mémoire. Il a des instructions de lecture/écriture pour lire/écrire en mémoire, et d'autres pour lire/écrire les registres d’interfaçage. Sans cela, le processeur ne saurait pas si une adresse est destinée à un périphérique ou à la mémoire. Cela élimine aussi les problèmes avec les caches : les accès à l'espace d'adressage de la RAM passent par l'intermédiaire de la mémoire cache, alors les accès dans l'espace d'adressage des périphériques le contournent totalement. Là encore, les deux espaces d'adressage n'ont pas forcément la même taille. Il arrive que les deux espaces d'adressage aient la même taille, le plus souvent sur des ordinateurs complexes avec beaucoup de périphériques. Mais les systèmes embarqués ont souvent des espaces d'adressage plus petits pour les périphériques que pour la ou les mémoires. L'implémentation varie grandement suivant le cas, la première méthode imposant d'avoir deux bus séparés pour les mémoires et les périphériques, l'autre permettant un certain partage du bus d'adresse. Nous reviendrons dessus plus en détail dans le chapitre sur l'adressage des périphériques. ==La ''memory map'' d'un ordinateur== Les deux sections précédentes nous ont appris que l'on peut utiliser un espace d'adressage séparé pour la ROM et un autre pour les périphériques. En tout, cela donne quatre possibilités distinctes. {|class="wikitable" |- ! ! IO mappées en mémoire ! IO séparées |- ! ROM mappée en mémoire (architecture Von Neumann) | Un seul espace d'adressage | Deux espaces d'adressage : * un pour les mémoires RAM/ROM ; * un pour les IO. |- ! ROM séparée (architecture Harvard) | Deux espaces d'adressage : * un pour la RAM et les IO ; * un pour la ROM. | Trois espaces d'adressages : * un pour la RAM ; * un pour la ROM ; * un pour les IO. |} Les quatre solutions ont des avantages et inconvénients divers, mais il est intéressant de contraster un espace d'adressage unique avec plusieurs espaces d'adressages. ===L'espace d'adressage unique=== Avec un espace d'adressage unique, la ROM est au sommet de l'espace d'adressage, les périphériques sont juste en-dessous, la RAM commence à l'adresse 0 et prend les adresses basses. Faire simplifie grandement l'implémentation matérielle de l'adressage. Notons que d'autres composants que les périphériques ou les mémoires peuvent se trouver dans l'espace d'adressage. On peut y trouver les horloges temps réels, des timers, des senseurs de température, ou d'autres composants placés sur la carte mère. Un exemple un peu original est le suivant : la console de jeu Nintendo DS incorporait une unité de calcul spécialisée dans les divisions et racines carrées, séparée du processeur, qui était justement mappée en mémoire ! [[File:Espace d'adressage classique avec entrées-sorties mappées en mémoire.png|centre|vignette|upright=2|Espace d'adressage classique avec entrées-sorties mappées en mémoire]] L'espace d'adressage unique est plus simple pour les programmeurs, malgré un désavantage dont il faut parler. Le problème est que des adresses censées être disponibles pour la RAM sont détournées vers la ROM ou les périphériques, ce qui limite la quantité de RAM qui peut être réellement adressée en pratique. C'est un défaut qui se manifeste seulement pour les petits espaces d'adressages, de 8 à 16 bits, qui ne permettent pas d'adresser beaucoup de RAM. Avec des adresses codées sur 16 bits, un espace d'adressage de 64 kibioctets de RAM est grignoté par des entrées-sorties assez rapidement. Par contre, pour des adresses de 32 à 64 bits, une bonne partie de l'espace d'adressage est inutilisé, car il n'y a pas assez de RAM. L'espace d'adressage unique n'a donc pas de désavantages. Les vielles consoles de jeux et certains vieux ordinateurs (Commodores et Amiga), utilisaient un espace d'adressage unique. Elles n'avaient pas une variété de cartes graphiques ou de cartes sons différentes, comme sur les PCs modernes. Au contraire, elles avaient la même configuration matérielle, le matériel ne pouvait pas être changé ni upgradé. Le système d'exploitation était rudimentaire et ne contrôlait pas vraiment l'accès au matériel. Les programmeurs avaient donc totalement accès au matériel et mapper les entrées/sorties en mémoire rendait la programmation des périphériques très simple. <gallery widths=200px heights=500px> N5200mk2 memory map.svg|Adressage mémoire (carte mémoire) du N5200mk2. PC-9801VM memory map.svg|Adressage mémoire (carte mémoire) du PC-9801VM. </gallery> ===La commutation de banques (''bank switching'')=== Le '''''bank switching''''', aussi appelé '''commutation de banque''', permet d'utiliser plusieurs espaces d'adressage sur un même processeur, sans attribuer chaque espace d'adressage pour une raison précise. L'espace d'adressage est présent en plusieurs exemplaires appelés des '''banques'''. Les banques sont numérotées, chaque numéro de banque permettant de l'identifier et de le sélectionner. Le but de cette technique est d'augmenter la mémoire disponible pour l'ordinateur. Par exemple, supposons que j'ai besoin d'adresser une mémoire ROM de 4 kibioctets, une RAM de 8 kibioctets, et divers périphériques. Le processeur a un bus d'adresse de 12 bits, ce qui limite l'espace d'adressage à 4 kibioctets. Dans ce cas, je peux réserver 4 banques : une pour la ROM, une pour les périphériques, et deux banques qui contiennent chacune la moitié de la RAM. La simplicité et l'efficacité de cette technique font qu'elle est beaucoup utilisée dans l'informatique embarquée. [[File:PC-8801MemoryMap.gif|centre|vignette|upright=2|exemple de Bank switching.]] Cette technique demande d'utiliser un bus d'adresse plus grand que les adresses du processeur. L'adresse réelle se calcule en concaténant le numéro de banque avec l'adresse accédée. Le numéro de la banque actuellement en cours d'utilisation est mémorisé dans un registre appelé le '''registre de banque'''. On peut changer de banque en changeant le contenu de ce registre. Le processeur dispose souvent d'instructions spécialisées qui en sont capables. {| class="wikitable flexible" |[[File:Banque mémoire.png|Banque mémoire.]] |[[File:Registre de banque.png|Registre de banque.]] |} ==Le placement des programmes dans l'espace d'adressage== Les ordinateurs modernes sont des architectures Von Neumann, avec un seul espace d'adressage, qui est partagé suivant les besoins. Et un point important est que le système d'exploitation et les programmes sont copiés en mémoire RAM avant d'être exécutés. Ils ne sont pas exécutés depuis une mémoire ROM, cela est réservé aux systèmes embarqués simples. Et cela a une influence sur l'espace d'adressage. Les programmes et le système d'exploitation doivent se partager l'espace d'adressage. Voyons voir comment ce partage se fait. Dans ce chapitre, nous allons volontairement mettre de côté les ordinateurs qui supportent la mémoire virtuelle. Nous en parlerons dans le prochain chapitre. Le principe est de voir comment un processeur avec un seul espace d'adressage gèrent la présence d'un ou de plusieurs programmes. ===Un seul espace d'adressage, non-partagé=== Le cas le plus simple est celui où il n'y a pas de système d'exploitation et où un seul programme s'exécute sur l'ordinateur. Le programme a alors accès à tout l'espace d'adressage, sauf pour ce qui est réservé aux périphériques et à la mémoire ROM. Le programme organise la mémoire en plusieurs sections, dans lesquelles le programme range des données différentes. Typiquement, on trouve quatre blocs de mémoire, appelés des segments, chacun étant spécialisé pour les données, le code, la pile, etc. Voici ces trois sections : * Le segment '''''text''''' 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. * Le segment pour la '''pile''', de taille variable. * le reste est appelé le '''tas''', de taille variable. [[File:Organisation d'un espace d'adressage unique utilisé par un programme unique.png|centre|vignette|upright=2|Organisation d'un espace d'adressage unique utilisé par un programme unique]] [[File:Typical computer data memory arrangement.png|vignette|upright=0.5|Typical computer data memory arrangement]] La pile et le tas sont de taille variable, ce qui veut dire qu'ils peuvent grandir ou diminuer à volonté, contrairement au reste. Entre le tas et la pile, on trouve un espace de mémoire inutilisée, qui peut être réquisitionné selon les besoins. La pile commence généralement à l'adresse la plus haute et grandit en descendant, alors que le tas grandit en remontant vers les adresses hautes. Il s'agit là d'une convention, rien de plus. Il est possible d'inverser la pile et le tas sans problème, c'est juste que cette organisation est rentrée dans les usages. Avant de poursuivre, précisons qu'il est possible de regrouper plusieurs programmes distincts dans un seul, afin qu'ils partagent le même morceau de mémoire. Les programmes portent alors le nom de '''threads'''. Les ''threads'' d'un même processus partagent le même espace d'adressage. Ils partagent généralement certains segments : ils se partagent le code, le tas et les données statiques. Par contre, chaque thread dispose de sa propre pile d'appel. [[File:Single vs multithreaded processes.jpg|centre|vignette|upright=2.0|Distinction entre processus mono et multi-thread.]] Il va de soi que cette vision de l'espace d'adressage ne tient pas compte des périphériques. C'est-à-dire que les schémas précédents partent du principe qu'on a un espace d'adressage séparé pour les périphériques. Dans le cas où les entrées-sorties sont mappées en mémoire, l'organisation est plus compliquée. Généralement, les adresses associées aux périphériques sont placées juste au-dessus de la pile, dans les adresses hautes. ===Un seul espace d'adressage, partagé avec le système d'exploitation=== [[File:Gestion de la mémoire sur les OS monoprogrammés.png|vignette|upright=1.0|Gestion de la mémoire sur les OS monoprogrammés.]] Maintenant, étudions le cas où le programme partage la mémoire avec le système d'exploitation. Sur les systèmes d'exploitation les plus simples, on ne peut lancer qu'un seul programme à la fois. Le système d'exploitation réserve une portion de taille fixe réservée au système d'exploitation, alors que le reste de la mémoire est utilisé pour le programme à exécuter. Le programme est placé à un endroit en RAM qui est toujours le même. Le système d'exploitation peut être soit placé dans une ROM, soit copié en mémoire RAM depuis une mémoire de masse, comme le programme à lancer. Si le système d'exploitation est copié en mémoire RAM, il est généralement placé dans les premières adresses, les adresses basses. A l'inverse, un OS en mémoire ROM est généralement placé à la fin de la mémoire, dans les adresses hautes. Mais tout cela n'est qu'une convention, et les exceptions sont monnaie courante. [[File:Méthodes d'allocation de la mémoire avec un espace d'adressage unique.png|centre|vignette|upright=2.0|Méthodes d'allocation de la mémoire avec un espace d'adressage unique]] Sur de tels systèmes, il faut protéger l'OS contre une erreur ou malveillance d'un programme utilisateur. Notons qu'il s'agit d'une protection en écriture, pas en lecture. Le programme peut parfaitement lire les données de l'OS sans problèmes, au moins en partie. Dans ce qui suit, on part du principe que l'OS est dans les adresses basses. La solution la plus courante interdit au programme d'écrire au-delà d'une limite au-delà de laquelle se trouve le système d’exploitation. Pour cela, le processeur incorpore un '''registre limite''', qui contient l'adresse limite au-delà de laquelle un programme peut pas écrire. Pour toute écriture en espace utilisateur, l'adresse écrite est comparée au registre limite. Si l'adresse est inférieure au registre limite, le programme cherche écrire dans la mémoire réservée à l'OS. L’accès mémoire est interdit, une exception matérielle est levée et l'OS affiche un message d'erreur. [[File:Circuit total.png|centre|vignette|upright=2.0|Protection mémoire avec un registre limite.]] ===Un seul espace d'adressage, partagé entre plusieurs programmes=== Les systèmes d’exploitation modernes implémentent la '''multiprogrammation''', le fait de pouvoir lancer 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. Et il faut alors résoudre un problème : le partage de la RAM entre les différents programmes. Les différents programmes et leurs données sont placés en mémoire RAM, et il faut trouver un moyen pour répartir les différents programmes en RAM. [[File:Partitions mémoire.png|vignette|upright=1|Partitions mémoire]] Il y a plusieurs solutions pour cela, mais l'une d'entre elle est l'usage de segments mémoire. Avec elle, chaque programme se voit attribuer un ou plusieurs blocs de mémoire. Ils sont appelés des '''partitions mémoire''', ou encore des '''segments'''. Dans ce qui va suivre, nous partons du principe qu'un programme est égal à un segment, pour simplifier les explications. Mais sachez qu'un programme peut être éclaté en plusieurs segments dispersés dans la mémoire, et même être conçu pour ! Nous en reparlerons dans le chapitre sur le mémoire virtuelle. Le système d'exploitation mémorise une liste des segments importants en mémoire RAM, appelée la ''table des segments''. Il s'agit d'un tableau dont chaque entrée mémorise les informations pour un segment. Une entrée de ce tableau est appelée un '''descripteur de segment''' et contient son adresse de base, sa taille, et des autorisations liées à la protection mémoire. Précisément, l'entrée numéro N mémorise les informations du segment numéro N. A partir de l'adresse de base de la table des segments et du numéro de segment, on peut lire ou écrire un descripteur de segment. [[File:Global Descriptor table.png|centre|vignette|upright=2|Global Descriptor table]] Toutefois, l'usage de segments amène un paquet de problèmes qu'il faut résoudre au mieux. Le premier problème est tout simplement de placer les segments dans l'espace d'adressage, mais c'est quelque chose qui est du ressort du système d'exploitation. Par contre, cela implique qu'un segment peut être placé n'importe où en RAM et sa position en RAM change à chaque exécution. En conséquence, les adresses des branchements et des données ne sont jamais les mêmes d'une exécution à l'autre. Pour résoudre ce problème, le compilateur considère que le segment commence à l'adresse zéro, puis les adresses sont corrigées avant ou pendant l'exécution du programme. Cette correction est en général réalisée par l'OS lors de la copie du programme en mémoire, mais il existe aussi des techniques matérielles, que nous verrons dans le chapitre suivant. Il faut aussi prendre en compte le phénomène de l''''allocation mémoire'''. Derrière ce nom barbare, se cache quelque chose de simple : les programmes peuvent réclamer de la mémoire au système d'exploitation, pour y placer des données. Les langages de programmation bas niveau supportent des fonctions comme malloc(), qui permettent de demander un bloc de mémoire de N octets à l'OS, qui doit alors accommoder la demande. S'il n'y a pas assez de mémoire, l'appel échoue et le programme doit gérer la situation. De même, un programme peut libérer de la mémoire qu'il n'utilise plus avec des fonctions comme free(). Avec des segments, cela revient à changer la taille d'un segment, plus précisément à la fin du segment. Et tout cela est géré par le système d'exploitation, aussi on laisse cela pour le prochain chapitre. Enfin, sans protection particulière, les programmes peuvent techniquement lire ou écrire les données des autres. Pour éviter cela, il suffit de vérifier qu'un programme accède bien à son segment et pas ailleurs. Il suffit de vérifier si l'adresse lue/écrite est dans le bon intervalle. Il suffit alors d'utiliser deux registres limites, rien de plus. Mais au-delà de cette simple vérification, si un programme pouvait modifier les données d'un autre programme, on se retrouverait rapidement avec une situation non prévue par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses. Il faut donc introduire des mécanismes de protection mémoire, pour isoler les programmes les uns des autres, et éviter toute modification problématique. La protection mémoire regroupe plusieurs techniques assez variées, qui ont des objectifs différents. Elles ont pour point commun de faire intervenir à des niveaux divers le système d'exploitation et le processeur. ==La ''memory map'' des PC IBM x86== Dans ce qui suit, nous allons étudier l'espace d'adressage des processeurs x86. L'organisation de leur espace d'adressage a évolué dans le temps et l'espace d'adressage s'est adapté. Mais il s'est adapté sans modifier l'existant, pour des raisons de compatibilité. Aussi, les processeurs x86 modernes peuvent fonctionner dans plusieurs modes, appelés mode réel, protégé, virtuel 8086, long, ''system management mode'', et compatibilité 32 bits. [[File:AMD64StateDiagram.svg|centre|vignette|upright=2|AMD64StateDiagram]] Le ''system management mode'' a déjà été abordé dans le chapitre sur les interruptions. Pour simplifier, il ne s'agit pas d'un véritable mode au sens "adressage". Dans ce mode, le système d'exploitation est mis en pause et le BIOS ou un autre firmware s'exécute sans restriction. On y rentre grâce à une interruption dédiée, lancée par le noyau de l'OS. Le système d'exploitation exécute cette interruption afin de laisser la main au BIOS, pour lui déléguer certaines tâches. Par contre, les autres modes sont bien plus intéressant, car ils ont chacun un espace d'adressage différent. Voyons-les dans le détail. ===Le mode réel : l'adressage sur 20 bits=== Le tout premier mode était le '''mode réel''', qui utilisait des adresses de 20 bits, ce qui fait 1 mébioctet de mémoire adressable. Il était utilisé sur des processeurs 16 bits, qui géraient donc des adresses de 16 bits capables d'adresser 64 kibioctets de mémoire. Pour contourner cette limitation, le processeur utilisait la segmentation, que nous verrons dans le prochain chapitre. Pour le moment, nous allons dire que la mémoire en mode réel était composée de 16 blocs de 64 kibioctets. C'est une simplification qui ne fera pas de mal. Le processeur démarre systématiquement en mode réel, y compris sur les ordinateurs modernes. Le BIOS s'exécute dans ce mode, avant de passer en mode protégé et de laisser la main à l'UEFI et au système d'exploitation. Autrefois, le système d'exploitation s'exécutait aussi en mode réel, c'est le cas du DOS. En mode réel, l'espace d'adressage est peuplé par de la RAM, la ROM du BIOS et des périphériques. La RAM prenait les adresses basses, la ROM était au sommet de l'espace d'adressage, et les périphériques entre les deux. Les premiers 640 kibioctets sont réservés à la RAM et sont appelés la '''mémoire conventionnelle'''. Les octets restants forment la '''mémoire haute''', réservée aux ROM et aux périphériques. Les deux premiers kibioctets de la mémoire conventionnelle sont initialisés au démarrage de l'ordinateur. Ils sont utilisés pour stocker le vecteur d'interruption (on expliquera cela dans quelques chapitres) et servent aussi au BIOS. La portion réservée au BIOS, la ''BIOS Data Area'', mémorise des informations en RAM. Elle commence à l'adresse 0040:0000h, a une taille de 255 octets, et est initialisée lors du démarrage de l'ordinateur. Le reste de la mémoire conventionnelle est réservée au le système d'exploitation (MS-DOS, avant sa version 5.0) et au programme en cours d’exécution. La mémoire haute est sectionnée en deux portions. Le sommet de la mémoire haute est réservé au BIOS, alors que le bas de la mémoire haute est réservé aux périphériques. Dans cette dernière, on trouve les BIOS des périphériques (dont celui de la carte vidéo, s'il existe), qui sont nécessaires pour les initialiser et pour communiquer avec eux. De plus, on y trouve la mémoire de la carte vidéo et éventuellement la mémoire d'autres périphériques comme la carte son. {| class="wikitable" style="text-align:center;" |+ Espace d'adressage en mode réel |- ! Numéro du bloc !! Contenu du bloc |- | 1024 - 960 Kio || class="f_jaune" | BIOS principal (ROM) |- | 960 Kio - 640 Kio | class="f_vert" | ROM d'extension des périphériques (XT, EGA, 3270 PC) </br> Mémoire vidéo des cartes EGA, MDA ou CGA |- | 640 Kio - 0 || class="f_rouge" | Mémoire RAM, mémoire conventionnelle |} Pour détailler un peu plus, il faut tenir compte du découpage de l'espace d'adressage en blocs de 64 kibioctets. Le découpage en blocs de 64 kibioctets est particulièrement important pour la mémoire haute. Des blocs sont attribués à un périphérique spécifique. Par exemple, le 16ème bloc est réservé au BIOS, le 12ème aux ROM des périphériques, le 10ème et le 11ème à la mémoire vidéo, etc. {| class="wikitable" style="text-align:center;" |+ Espace d'adressage en mode réel |- ! Numéro du bloc !! Contenu du bloc |- | 0 || rowspan="10" class="f_rouge" | Mémoire RAM, mémoire conventionnelle |- | 1 |- | 2 |- | 3 |- | 4 |- | 5 |- | 6 |- | 7 |- | 8 |- | 9 |- | 10 || class="f_vert" | Mémoire vidéo des cartes EGA |- | 11 || class="f_vert" | Mémoire vidéo des cartes MDA ou CGA |- | 12 || class="f_vert" | ROM d'extension des périphériques (XT, EGA, 3270 PC) |- | 13 || class="f_gris" | Autre, non-réservé |- | 14 || class="f_gris" | Autre, non-réservé |- | 15 || class="f_jaune" | ROM du BIOS |} Vous remarquerez que les blocs 13 et 14 sont non-réservés. Ils peuvent être utilisés pour mapper des périphériques en mémoire. Mais si aucun périphérique n'est mappé dedans, les blocs sont laissés libres. Les OS n'hésitaient pas à les utiliser pour adresser de la RAM. Par exemple, si l'ordinateur n'a pas de carte vidéo EGA, le bloc qui leur était réservé était réutilisé comme mémoire RAM. La mémoire conventionnelle passait alors de 640 à 704 kibioctets de RAM disponibles. L'espace d'adressage correspond à la ROM du BIOS. Mais avec le temps, la ROM est devenue plus lente au point qu'exécuter du code directement depuis la ROM du BIOS était un problème niveau performance. Je rappelle que le BIOS contient de nombreuses routines d'interruptions qui étaient appelées par les systèmes d'exploitation de l'époque, notamment le DOS. Heureusement, diverses optimisations permettaient de copier le BIOS dans la mémoire RAM, plus rapide que la ROM du BIOS. Il s'agit des techniques de '''''BIOS Shadowing'''''. Elles s'appliquaient au BIOS proprement dit, mais le BIOS vidéo pouvait aussi être optimisé ainsi, mais les ROM d'extension ne le sont généralement pas. Le ''BIOS Shadowing'' peut s'implémenter de plusieurs manières, mais la plus courante mappait temporairement de la RAM dans le même espace d'adressage que la ROM du BIOS. Le code du BIOS copiait le BIOS avec une boucle, dont chaque itération lisait une adresse du BIOS et écrivait dans cette même adresse. Juste avant la boucle, le ''chipset'' était configuré par le BIOS pour que les lectures atterrissent dans la ROM du BIOS, mais que les écritures tombaient dans une mémoire RAM mappée. Une fois la copie/boucle terminée, l'adressage tombait dans la RAM aussi bien pour les lectures et écritures. Elle demandait un support du ''chipset'' et du logiciel, pour détourner les adresses temporairement. ===Interlude : les technologies d'expanded memory=== Déjà à l'époque, 1 mébioctet était une limite assez faible, même si cela n'a pas empêché à un grand dirigeant de l'industrie informatique de dire que "640K ought to be enough for anybody". Aussi, diverses solutions matérielles ont vu le jour. Elles étaient peu utilisées et ont concrètement servi à des applications spécifiques. La principale est l''''''expanded memory''''' (EMS), une solution utilisant une carte d'extension avec plusieurs modules de RAM installés dessus. Les modules étaient accédés via la technique de la commutation de banque. [[File:Expanded memory.svg|centre|vignette|upright=2|L'espace d'adressage avec ''Expanded memory''. La carte d'extension et le matériel requis ne sont pas représentés.]] Le terme ''expanded memory'' regroupe plusieurs technologies propriétaires semblables sur le principe mais différentes dans l'exécution. Les premières utilisaient des banques de 64 kibioctets, qui étaient mappées dans un bloc de 64 kibioctets libre en mémoire haute. L'usage de banques de 64 kibioctets collait bien avec l'adressage particulier du mode réel, où la mémoire était gérée par blocs de 64 kibioctets. Les technologies ultérieures utilisaient 2 à 4 pages de 16 kibioctets, qui pouvaient être placée n'importe où en mémoire conventionnelle. [[File:EmulexPersyst 4M ISA.jpeg|centre|vignette|upright=2|Carte d'extension ''Expanded memory'' de marque Emulex Persyst, de 4 mébioctets.]] L'''expanded memory'' demandait que les programmes soient codés pour utiliser cette fonctionnalité. Pour cela, ils devaient utiliser une technique de programmation nommée ''Overlay programming''. L'idée est de découper le programme en plusieurs blocs séparés, appelés des '''''overlays'''''. Certains blocs sont en permanence en RAM, mais d'autres sont soit chargés en RAM, soit stockés ailleurs. Le ailleurs est souvent le disque dur, ou dans la RAM ''expanded memory'' si elle est disponible. Le va-et-vient des ''overlays'' entre RAM et disque dur était réalisé en logiciel, par le programme lui-même. [[File:Overlay Programming.svg|centre|vignette|upright=1.5|''Overlay Programming''.]] L'utilisation de l'''expanded memory'' utilisait l'interruption logicielle 67h, une interruption peu utilisée à l'époque, qui était libre. La carte d'extension était utilisée à travers un pilote de périphérique dédié, qui était appelé via l'interruption 67h. ===Le mode protégé : l'adressage sur 24 et 32 bits=== Par la suite, le mode réel a été remplacé par le '''mode protégé'''. Il utilise la mémoire virtuelle, ce qui fait qu'on devrait le détailler dans le chapitre suivant. Il permettait aussi de dépasser la limite du mébioctet de mémoire. L'espace d'adressage en mode protégé est passé de 24 bits sur le CPU 286, à 32 bits sur le 386 et les CPU suivants. Les programmes conçus pour le mode réel ne pouvaient pas s'exécuter en mode protégé. Pour corriger cela, le 286 a introduit le '''mode virtuel 8086''', aussi appelé mode V86. Il s'agit d'une technique de virtualisation, ce qui fait qu'on en parlera en détail dans le chapitre sur la virtualisation assistée en matériel. Tout ce que l'on peut dire ici est que le mode V86 a été détourné pour que les applications DOS utilisent la mémoire au-delà du mébioctet, appelée la '''mémoire étendue'''. Les logiciels DOS accédaient à la mémoire étendue à travers un driver DOS dédié (HIMEM.SYS). Les logiciels DOS déplaçaient des données dans la mémoire étendue, puis les rapatriaient en mémoire conventionnelle quand ils en avaient besoin. Il ne faut pas confondre mémoire étendue et ''expanded memory''. La technique de ''expanded memory'' repose sur de la commutation de banque, demande une carte d'extension dédiée, fonctionne sans mode V86. A l'inverse, la mémoire étendue ne gère pas de commutation de banque, ne demande pas de carte d'extension, mais utilise le mode V86. Par contre, il est possible d'émuler l'''expanded memory'' sans carte d'extension, avec la mémoire étendue. Quelques cartes mères intégraient des techniques pour, mais une émulation logicielle était aussi possible en réécrivant l'interruption 67h. ===Le mode long : l'adressage 64 bits=== Par la suite, les processeurs ont introduit le '''mode long''', dans lequel les adresses font 64 bits. Le CPU en mode long a aussi un jeu d'instruction totalement différent, des registres en plus et d'autres fonctionnalités actives uniquement dans ce mode. Les programmes compilés pour le 64 bits s'exécutent nativement dans ce mode, alors que les programmes 32 et 16 bits s'exécutent dans un mode de compatibilité dédié. Sur les systèmes 64 bits, le bus d'adresse fait seulement 48 bits afin d'économiser des interconnexions. Avec 48 bits, il manque 64 - 48 = 16 bits d'adresses, qui ne peuvent pas être utilisés pour adresser quoi que ce soit. La règle est que ces 16 bits doivent être tous égaux : soit ils valent tous 0, soient ils sont tous à 1. Les adresses qui respectent cette contrainte sont appelées des '''adresses canoniques'''. Le résultat est que l'espace d'adressage est coupé en trois sections, comme illustré ci-dessous. * Les '''adresses canoniques basses''' ont leurs 16 bits de poids fort à 0 et sont situées en bas de l'espace d'adressage, dans les premières adresses. * Les '''adresses canoniques hautes''' ont leurs 16 bits de poids fort à 1 et sont situées au sommet de l'espace d'adressage, dans les dernières adresses. * Entre les deux, se trouvent des '''adresses non-canoniques''', qui ne sont pas accessibles. Y accéder déclenche la levée d'une exception matérielle. {| |[[File:AMD64-canonical--48-bit.png|vignette|Espace d'adressage x86-64 bits, avec des adresses physiques de 48 bits.]] |[[File:AMD64-canonical--56-bit.png|vignette|Espace d'adressage x86-64 bits, avec des adresses physiques de 57 bits.]] |} : Les futurs systèmes x86 devraient passer à des adresses de 57 bits, les trois sections auront donc des frontières différentes. Les adresses non-canoniques sont censées être inutilisables. Mais les programmeurs aimeraient bien pouvoir les utiliser pour des ''pointeurs tagués'', à savoir des pointeurs/adresses associées à des informations. L'idée serait d'utiliser les 16 bits de poids fort pour stocker des informations liées au pointeur, seuls les 48 bits restant codant l'adresse. Les 16 bits peuvent être utilisés de manières très diverses. Un exemple est la technique du '''''memory tagging''''', qui crée une somme de contrôle au pointeur. La somme de contrôle est générée par un algorithme cryptographique, à partir de l'adresse du pointeur. Elle est vérifiée à chaque utilisation du pointeur. Si la somme de contrôle ne correspond pas au pointeur, une erreur est levée. Si jamais un virus ou un code malveillant modifie un pointeur, il ne saura pas comment calculer la somme de contrôle, la somme de contrôle a énormément de chances d'être incorrecte. Quelques fonctionnalités des processeurs visent à autoriser l'utilisation des adresses non-canoniques. L'idée est que les 16 bits de poids fort sont ignorées lors des accès mémoire, ce qui permet d'utiliser les 16 bits de poids fort à volonté. Il s'agit des techniques ''Top Byte Ignore'' d'ARM, ''Upper Address Ignore'' d'AMD, et ''Linear Address Masking'' d'Intel. ==La z/architecture d'IBM== IBM a sa propre gamme de ''mainframes'', qui utilisent des processeurs fait maison par IBM lui-même. Les CPU IBM ont leur propre jeu d'instruction CISC, appelé '''''z/architecture'''''. Et ce jeu d'instruction a des possibilités en termes d'adressage qui sont extrêmement intéressantes, en plus d'être assez inédites. Pour ce chapitre, nous allons nous concentrer sur une fonctionnalité : les programmes ont accès à plusieurs espaces d'adressage naturellement. ===L'isolation des processus=== Les CPU ''z/architecture'' peuvent fonctionner en quatre modes, dont l'un permet à un programme d'utiliser plusieurs espaces d'adressage. L'avantage pour les programmes utilisateur n'est pas très évident, mais il l'est nettement plus pour les systèmes d'exploitation. Le système d'exploitation de ces ''mainframes'', le z/OS, profite de cette possibilité, idem pour l'hyperviseur z/VM. Il utilise cette possibilité pour donner un espace d'adressage séparé pour chaque programme en cours d'exécution. Les programmes sont alors isolés les uns des autres. Cette '''isolation des processus''' n'est pas spécifique à IBM : tous les systèmes d'exploitation modernes l'utilisent. Mais les processeurs x86 doivent se débrouiller seulement avec des techniques dites de mémoire virtuelle, alors que les ''mainframes'' IBM complètent la mémoire virtuelle avec des facilités en plus. Pour les programmes utilisateurs, un intérêt est que cela permet de partager des données entre plusieurs programmes. Ils ont un espace d'adressage rien qu'à eux, mais les données partagées sont placées dans un espace d'adressage commun, auquel les deux programmes ont accès. L'espace d'adressage partagé peu même être configuré de manière à ne contenir que des données, il est impossible d'exécuter du code depuis cet espace d'adressage. Ce qui empêche certains ''malwares'' de fonctionner. ===Les espaces d'adressage par défaut de la ''z/architecture''=== La ''z/architecture'' supporte 65536 espaces d'adressage simultanés. Les espaces d'adressage sont numérotés, ils ont chacun un identifiant codé sur 16 bits, appelé un '''''Address-Space Number'''''. Nous utiliserons l’abréviation ASN dans ce qui suit. Par contre, un programme ne peut pas utiliser tout ces espaces d'adressages, il ne peut en utiliser que 16 en même temps, maximum. De plus, ils ne sont accessibles que quand le processeur est dans un mode de fonctionnement bien précis. Pour utiliser un espace d'adressage, un programme doit connaitre l'ASN associé. Par défaut, un programme exécuté sur la ''z/architecture'' utilise deux espaces d'adressages : l''''espace d'adressage primaire''' où se situent le programme exécuté et les données importantes, un '''espace d'adressage secondaire''' utilisé pour des tâches/données annexes. L'ASN de ces deux espaces d'adressage est mémorisé dans les registres de contrôle du processeur. L'ASN de l'espace primaire est dans le registre de contrôle n°3, l'ASN de l'espace secondaire est dans le registre de contrôle n°4 (ils sont dans les 16 bits de poids faible, dans les deux cas). Les deux registres de controle sont initialisés au démarrage de l'application, par le système d'exploitation, ce qui fait que ce sont des espaces d'adressage par défaut. Le programme a donc accès à ces deux espaces d'adressage sans rien faire. : Ils ont aussi accès à un '''espace d'adressage ''home''''' unique qui est surtout utilisé par le système d'exploitation, qui a pour particularité de ne pas avoir d'ASN. Il n'est concrètement accesible qu'en mode superviseur, donc par le système d'exploitation. Nous n'en parlerons pas dans ce qui suit. Mais comment le processeur pour utiliser ces espaces d'adressages ? Par exemple, s'il veut faire une lecture, comment précise-t-il l'espace d'adressage courant ? La réponse est que le processeur peut être dans deux états : soit primaire, soit secondaire. Dans le premier état, toutes les lectures/écritures se font dans l'espace d'adressage primaire. Dans le second, elles se font toutes dans l'espace d'adressage secondaire. Il peut passer de l'un à l'autre en utilisant l'instruction ''Set Address Space Control''. ===Les registres d'accès de la ''z/architecture''=== Les CPU ''z/architecture'' disposent de 16 registres généraux de 64 bits, de 16 registres flottants eux aussi de 64 bits, de 16 registres de contrôle eux aussi de 64 bits, et de '''registres d'accès''' qui gèrent les espaces d'adressage. Chaque registre entier est associé à un espace d'adressage particulier, qui est indiqué par son registre d'accès. Les registres d'accès sont utilisés lors des lectures/écritures indirectes. L'adresse est lue depuis un registre général, et son registre d'accès est consulté pour déterminer l'espace d'adressage. Une implémentation simple numéroterait les espaces d'adressage et mettrait les numéros dans les registres d'accès. Les registres d'accès seraient idéalement protégés en écriture et seulement accessibles par le système d'exploitation. Mais ce n'est pas ce qui est fait, pour des raisons de sécurité. Les registres d'accès mémorisent bien un identifiant, mais celui-ci ne sélectionne pas l'espace d'adressage directement. L'identifiant est envoyée à une table de correspondance matérielle, qui mémorise les correspondances entre un identifiant et un espace d'adressage. Il y a une table de correspondance par programme, ce qui fait qu'un identifiant ne correspondra pas au même espace d'adressage dans deux programmes différents. La table de correspondance mémorise aussi des droits d'accès, qui sont différents pour chaque programme. Par exemple, tel programme aura accès en lecture seule à tel espace d'adressage, alors qu'un autre programme y aura accès en lecture et écriture, etc. Un espace d'adressage peut ainsi être partagé entre deux programmes plus facilement, tout en ajoutant des garanties de sécurité. L'usage des registres d'accès est aussi plus flexible qu'on ne pourrait le croire. Le programme peut lire et écrire dans les registres d'accès, copier un registre d'accès dans un autre, etc. Par contre, la table de correspondance empêche à un programme d’accéder à un espace d'adressage arbitraire. Le système d'exploitation remplit la table de manière à n'autoriser l'accès qu'aux espaces d'adressage adéquats, il y enregistre seulement les correspondances utiles. Si un programme forge un identifiant arbitraire, et l'envoie à la table, celle-ci n'aura pas la correspondance associée et l'accès mémoire sera invalidé. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs 8 bits et moins | prevText=Les processeurs 8 bits et moins | next=L'abstraction mémoire et la mémoire virtuelle | nextText=L'abstraction mémoire et la mémoire virtuelle }} </noinclude> ca9gp5x9py30ixeisbfg4yu9hay0qg7 774363 774362 2026-10-07T14:38:09Z Mewtow 31375 /* Les registres d'accès de la z/architecture */ 774363 wikitext text/x-wiki L''''espace d'adressage''' du processeur est l'ensemble des adresses utilisables par le processeur. Par exemple, un processeur 16 bits peut adresser 2^16 = 65536 adresses, l'ensemble de ces adresses forme son espace d'adressage. L'espace d'adressage n'est pas toujours égal à la mémoire réellement installée. S'il n'y a pas assez de RAM installée, des adresses seront inoccupées. De plus, une partie de l'espace d'adressage peut être détourné pour communiquer avec les périphériques, comme nous le verrons plus bas. Nous verrons aussi qu'un processeur peut avoir plusieurs espaces d'adressages séparés. ==L'adressage de la RAM et de la ROM== Avoir plusieurs espaces d'adressage spécialisés est quelque chose que nous avons déjà rencontré dans les chapitres précédents, mais sans le dire ouvertement. Aussi, nous allons faire quelques rappels sur les cas déjà rencontrés. En premier lieu, nous allons rappeler la différence entre architectures Von Neumann et Hardvard. La différence entre les deux tient dans l'adressage des mémoires RAM et ROM : est-ce qu'elles sont dans un seul espace d'adressage, ou dans des espaces d'adressage séparés. L''''architecture Von Neumann''' a un seul espace d'adressage, découpé entre la mémoire RAM d'un côté et la mémoire ROM de l'autre. Une adresse correspond soit à la mémoire RAM, soit à la mémoire ROM, mais pas aux deux. Typiquement, la mémoire ROM est placée dans les adresses hautes, les plus élevées, alors que la RAM est placée dans les adresses basses en commençant par l'adresse 0. C'est une convention qui n'est pas toujours respectée, aussi mieux vaut éviter de la tenir pour acquise. [[File:Vision de la mémoire par un processeur sur une architecture Von Neumann.png|centre|vignette|upright=2|Vision de la mémoire par un processeur sur une architecture Von Neumann.]] L''''architecture Harvard''' utilise des espaces d'adressage séparés pour la RAM et la ROM. Une même adresse peut correspondre soit à la ROM, soit à la RAM. Le processeur voit bien deux mémoires séparées, chacune dans son propre espace d'adressage. Les deux espaces d'adressage n'ont pas forcément la même taille. Il est possible d'avoir un plus gros espace d'adressage pour la RAM que pour la ROM. Cela implique que les adresses des instructions et des données soient de taille différentes. C'est peu pratique et c'est rarement implémenté, ce qui fait que le cas le plus courant est celui où les deux espaces d'adressages ont la même taille. [[File:Vision de la mémoire par un processeur sur une architecture Harvard.png|centre|vignette|upright=2|Vision de la mémoire par un processeur sur une architecture Harvard.]] Sur une '''architecture Harvard modifiée''', le programme est placé dans un espace d'adressage à part du reste. Le problème, c'est que cet espace d'adressage ne contient pas que le code machine à exécuter. Il contient aussi des constantes, à savoir des données qui gardent la même valeur lors de l'exécution du programme. Elles peuvent être lues, mais pas modifiées durant l'exécution du programme. L'accès à ces constantes demande d'aller lire celles-ci dans l'autre espace d'adressage, pour les copier dans les registres généraux. Le processeur doit pour cela avoir des instructions de lecture dédiées. En clair, il y a une instruction LOAD pour lire dans la mémoire ROM, une autre pour lire dans la RAM. [[File:Espaces d'adressage sur une archi harvard modifiée.png|centre|vignette|upright=3|Espaces d'adressage sur une archi harvard modifiée]] Une autre solution utilise des instructions de copie, qui copient les constantes en mémoire RAM, donc d'un espace d'adressage à l'autre. [[File:Organisation des espaces d'adressage sur une archi harvard modifiée.png|centre|vignette|upright=3|Organisation des espaces d'adressage sur une archi harvard modifiée]] ==L'adressage des périphériques== Passons maintenant à l'adressage des périphériques. La communication avec les périphériques se fait par l'intermédiaire de registres d’interfaçage. Et ces registres peuvent soit avoir un espace d'adressage séparé, soit être inclus dans l'espace d'adressage des mémoires. Dans ce qui suit, nous allons supposer que l'architecture des de type Von Neumann pour simplifier les explications. ===Les entrées-sorties mappées en mémoire=== Une partie de l'espace d'adressage peut être détourné pour communiquer avec les périphériques. Le périphérique se retrouve inclus dans l'ensemble des adresses utilisées pour manipuler la mémoire : on dit qu'il est mappé en mémoire. Les adresses mémoires associées à un périphérique sont redirigées automatiquement vers les registres du périphérique en question, voire vers sa mémoire intégrée. On parle alors d''''entrées-sorties mappées en mémoire'''. [[File:IO mappées en mémoire.png|centre|vignette|upright=2.0|IO mappées en mémoire]] On remarque un défaut inhérent à cette technique : les adresses utilisées pour les périphériques ne sont plus disponibles pour la RAM. On ne peut plus adresser autant de RAM qu'avant. La perte dépend des périphériques installés et de leur gourmandise en adresses mémoires. C'est ce qui causait un problème assez connu sur les ordinateurs 32 bits, capables d'adresser 4 gibioctets de mémoire. Certaines personnes installaient 4 gigaoctets de mémoire sur leur ordinateur 32 bits et se retrouvaient avec « seulement » 3,5 à 3,8 gigaoctets de mémoire, les périphériques prenant le reste. Il est possible que la RAM d'un périphérique soit mappée en RAM. Un exemple classique est celui des cartes graphiques qui incorporent une mémoire RAM appelée la mémoire vidéo. La mémoire vidéo est mappée en mémoire, ce qui permet au processeur d'écrire directement dedans. Toute lecture ou écriture dans les adresses associées est redirigée vers le bus PCI/AGP/PCI-Express. Nous verrons plus bas des exemples d'ordinateurs où la mémoire vidéo est mappée en mémoire, que ce soit totalement ou partiellement. Si je dis totalement ou partiellement, c'est parce que les cartes graphiques modernes disposent de tellement de mémoire qu'on ne peut pas la mapper totalement dans l'espace d'adressage. Sur les systèmes d'avant 2008, seuls 256 mégaoctets de mémoire vidéo sont mappés en mémoire RAM. Le reste de la mémoire vidéo est invisible du point de vue du processeur, mais manipulable par le GPU à sa guise. Après 2008, la spécification du PCI-Express ajouta un support de la technologie ''Resizable Bar'', qui permet au processeur d’accéder directement à plus de 256 mégaoctets de mémoire vidéo, voire à la totalité de la mémoire vidéo. ===L'espace d'adressage séparé pour les entrées-sorties=== Les entrées-sorties et périphériques peuvent avoir leur propre espace d'adressage dédié, séparé de celui utilisé pour les mémoires RAM et ROM. [[File:Espaces_d'adressages_séparés_entre_mémoire_et_périphérique.png|centre|vignette|upright=3|Bit IO.]] Une même adresse peut donc adresser soit une entrée-sortie, soit une case mémoire. Et pour faire la différence, le processeur doit avoir des instructions séparées pour adresser les périphériques et la mémoire. Il a des instructions de lecture/écriture pour lire/écrire en mémoire, et d'autres pour lire/écrire les registres d’interfaçage. Sans cela, le processeur ne saurait pas si une adresse est destinée à un périphérique ou à la mémoire. Cela élimine aussi les problèmes avec les caches : les accès à l'espace d'adressage de la RAM passent par l'intermédiaire de la mémoire cache, alors les accès dans l'espace d'adressage des périphériques le contournent totalement. Là encore, les deux espaces d'adressage n'ont pas forcément la même taille. Il arrive que les deux espaces d'adressage aient la même taille, le plus souvent sur des ordinateurs complexes avec beaucoup de périphériques. Mais les systèmes embarqués ont souvent des espaces d'adressage plus petits pour les périphériques que pour la ou les mémoires. L'implémentation varie grandement suivant le cas, la première méthode imposant d'avoir deux bus séparés pour les mémoires et les périphériques, l'autre permettant un certain partage du bus d'adresse. Nous reviendrons dessus plus en détail dans le chapitre sur l'adressage des périphériques. ==La ''memory map'' d'un ordinateur== Les deux sections précédentes nous ont appris que l'on peut utiliser un espace d'adressage séparé pour la ROM et un autre pour les périphériques. En tout, cela donne quatre possibilités distinctes. {|class="wikitable" |- ! ! IO mappées en mémoire ! IO séparées |- ! ROM mappée en mémoire (architecture Von Neumann) | Un seul espace d'adressage | Deux espaces d'adressage : * un pour les mémoires RAM/ROM ; * un pour les IO. |- ! ROM séparée (architecture Harvard) | Deux espaces d'adressage : * un pour la RAM et les IO ; * un pour la ROM. | Trois espaces d'adressages : * un pour la RAM ; * un pour la ROM ; * un pour les IO. |} Les quatre solutions ont des avantages et inconvénients divers, mais il est intéressant de contraster un espace d'adressage unique avec plusieurs espaces d'adressages. ===L'espace d'adressage unique=== Avec un espace d'adressage unique, la ROM est au sommet de l'espace d'adressage, les périphériques sont juste en-dessous, la RAM commence à l'adresse 0 et prend les adresses basses. Faire simplifie grandement l'implémentation matérielle de l'adressage. Notons que d'autres composants que les périphériques ou les mémoires peuvent se trouver dans l'espace d'adressage. On peut y trouver les horloges temps réels, des timers, des senseurs de température, ou d'autres composants placés sur la carte mère. Un exemple un peu original est le suivant : la console de jeu Nintendo DS incorporait une unité de calcul spécialisée dans les divisions et racines carrées, séparée du processeur, qui était justement mappée en mémoire ! [[File:Espace d'adressage classique avec entrées-sorties mappées en mémoire.png|centre|vignette|upright=2|Espace d'adressage classique avec entrées-sorties mappées en mémoire]] L'espace d'adressage unique est plus simple pour les programmeurs, malgré un désavantage dont il faut parler. Le problème est que des adresses censées être disponibles pour la RAM sont détournées vers la ROM ou les périphériques, ce qui limite la quantité de RAM qui peut être réellement adressée en pratique. C'est un défaut qui se manifeste seulement pour les petits espaces d'adressages, de 8 à 16 bits, qui ne permettent pas d'adresser beaucoup de RAM. Avec des adresses codées sur 16 bits, un espace d'adressage de 64 kibioctets de RAM est grignoté par des entrées-sorties assez rapidement. Par contre, pour des adresses de 32 à 64 bits, une bonne partie de l'espace d'adressage est inutilisé, car il n'y a pas assez de RAM. L'espace d'adressage unique n'a donc pas de désavantages. Les vielles consoles de jeux et certains vieux ordinateurs (Commodores et Amiga), utilisaient un espace d'adressage unique. Elles n'avaient pas une variété de cartes graphiques ou de cartes sons différentes, comme sur les PCs modernes. Au contraire, elles avaient la même configuration matérielle, le matériel ne pouvait pas être changé ni upgradé. Le système d'exploitation était rudimentaire et ne contrôlait pas vraiment l'accès au matériel. Les programmeurs avaient donc totalement accès au matériel et mapper les entrées/sorties en mémoire rendait la programmation des périphériques très simple. <gallery widths=200px heights=500px> N5200mk2 memory map.svg|Adressage mémoire (carte mémoire) du N5200mk2. PC-9801VM memory map.svg|Adressage mémoire (carte mémoire) du PC-9801VM. </gallery> ===La commutation de banques (''bank switching'')=== Le '''''bank switching''''', aussi appelé '''commutation de banque''', permet d'utiliser plusieurs espaces d'adressage sur un même processeur, sans attribuer chaque espace d'adressage pour une raison précise. L'espace d'adressage est présent en plusieurs exemplaires appelés des '''banques'''. Les banques sont numérotées, chaque numéro de banque permettant de l'identifier et de le sélectionner. Le but de cette technique est d'augmenter la mémoire disponible pour l'ordinateur. Par exemple, supposons que j'ai besoin d'adresser une mémoire ROM de 4 kibioctets, une RAM de 8 kibioctets, et divers périphériques. Le processeur a un bus d'adresse de 12 bits, ce qui limite l'espace d'adressage à 4 kibioctets. Dans ce cas, je peux réserver 4 banques : une pour la ROM, une pour les périphériques, et deux banques qui contiennent chacune la moitié de la RAM. La simplicité et l'efficacité de cette technique font qu'elle est beaucoup utilisée dans l'informatique embarquée. [[File:PC-8801MemoryMap.gif|centre|vignette|upright=2|exemple de Bank switching.]] Cette technique demande d'utiliser un bus d'adresse plus grand que les adresses du processeur. L'adresse réelle se calcule en concaténant le numéro de banque avec l'adresse accédée. Le numéro de la banque actuellement en cours d'utilisation est mémorisé dans un registre appelé le '''registre de banque'''. On peut changer de banque en changeant le contenu de ce registre. Le processeur dispose souvent d'instructions spécialisées qui en sont capables. {| class="wikitable flexible" |[[File:Banque mémoire.png|Banque mémoire.]] |[[File:Registre de banque.png|Registre de banque.]] |} ==Le placement des programmes dans l'espace d'adressage== Les ordinateurs modernes sont des architectures Von Neumann, avec un seul espace d'adressage, qui est partagé suivant les besoins. Et un point important est que le système d'exploitation et les programmes sont copiés en mémoire RAM avant d'être exécutés. Ils ne sont pas exécutés depuis une mémoire ROM, cela est réservé aux systèmes embarqués simples. Et cela a une influence sur l'espace d'adressage. Les programmes et le système d'exploitation doivent se partager l'espace d'adressage. Voyons voir comment ce partage se fait. Dans ce chapitre, nous allons volontairement mettre de côté les ordinateurs qui supportent la mémoire virtuelle. Nous en parlerons dans le prochain chapitre. Le principe est de voir comment un processeur avec un seul espace d'adressage gèrent la présence d'un ou de plusieurs programmes. ===Un seul espace d'adressage, non-partagé=== Le cas le plus simple est celui où il n'y a pas de système d'exploitation et où un seul programme s'exécute sur l'ordinateur. Le programme a alors accès à tout l'espace d'adressage, sauf pour ce qui est réservé aux périphériques et à la mémoire ROM. Le programme organise la mémoire en plusieurs sections, dans lesquelles le programme range des données différentes. Typiquement, on trouve quatre blocs de mémoire, appelés des segments, chacun étant spécialisé pour les données, le code, la pile, etc. Voici ces trois sections : * Le segment '''''text''''' 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. * Le segment pour la '''pile''', de taille variable. * le reste est appelé le '''tas''', de taille variable. [[File:Organisation d'un espace d'adressage unique utilisé par un programme unique.png|centre|vignette|upright=2|Organisation d'un espace d'adressage unique utilisé par un programme unique]] [[File:Typical computer data memory arrangement.png|vignette|upright=0.5|Typical computer data memory arrangement]] La pile et le tas sont de taille variable, ce qui veut dire qu'ils peuvent grandir ou diminuer à volonté, contrairement au reste. Entre le tas et la pile, on trouve un espace de mémoire inutilisée, qui peut être réquisitionné selon les besoins. La pile commence généralement à l'adresse la plus haute et grandit en descendant, alors que le tas grandit en remontant vers les adresses hautes. Il s'agit là d'une convention, rien de plus. Il est possible d'inverser la pile et le tas sans problème, c'est juste que cette organisation est rentrée dans les usages. Avant de poursuivre, précisons qu'il est possible de regrouper plusieurs programmes distincts dans un seul, afin qu'ils partagent le même morceau de mémoire. Les programmes portent alors le nom de '''threads'''. Les ''threads'' d'un même processus partagent le même espace d'adressage. Ils partagent généralement certains segments : ils se partagent le code, le tas et les données statiques. Par contre, chaque thread dispose de sa propre pile d'appel. [[File:Single vs multithreaded processes.jpg|centre|vignette|upright=2.0|Distinction entre processus mono et multi-thread.]] Il va de soi que cette vision de l'espace d'adressage ne tient pas compte des périphériques. C'est-à-dire que les schémas précédents partent du principe qu'on a un espace d'adressage séparé pour les périphériques. Dans le cas où les entrées-sorties sont mappées en mémoire, l'organisation est plus compliquée. Généralement, les adresses associées aux périphériques sont placées juste au-dessus de la pile, dans les adresses hautes. ===Un seul espace d'adressage, partagé avec le système d'exploitation=== [[File:Gestion de la mémoire sur les OS monoprogrammés.png|vignette|upright=1.0|Gestion de la mémoire sur les OS monoprogrammés.]] Maintenant, étudions le cas où le programme partage la mémoire avec le système d'exploitation. Sur les systèmes d'exploitation les plus simples, on ne peut lancer qu'un seul programme à la fois. Le système d'exploitation réserve une portion de taille fixe réservée au système d'exploitation, alors que le reste de la mémoire est utilisé pour le programme à exécuter. Le programme est placé à un endroit en RAM qui est toujours le même. Le système d'exploitation peut être soit placé dans une ROM, soit copié en mémoire RAM depuis une mémoire de masse, comme le programme à lancer. Si le système d'exploitation est copié en mémoire RAM, il est généralement placé dans les premières adresses, les adresses basses. A l'inverse, un OS en mémoire ROM est généralement placé à la fin de la mémoire, dans les adresses hautes. Mais tout cela n'est qu'une convention, et les exceptions sont monnaie courante. [[File:Méthodes d'allocation de la mémoire avec un espace d'adressage unique.png|centre|vignette|upright=2.0|Méthodes d'allocation de la mémoire avec un espace d'adressage unique]] Sur de tels systèmes, il faut protéger l'OS contre une erreur ou malveillance d'un programme utilisateur. Notons qu'il s'agit d'une protection en écriture, pas en lecture. Le programme peut parfaitement lire les données de l'OS sans problèmes, au moins en partie. Dans ce qui suit, on part du principe que l'OS est dans les adresses basses. La solution la plus courante interdit au programme d'écrire au-delà d'une limite au-delà de laquelle se trouve le système d’exploitation. Pour cela, le processeur incorpore un '''registre limite''', qui contient l'adresse limite au-delà de laquelle un programme peut pas écrire. Pour toute écriture en espace utilisateur, l'adresse écrite est comparée au registre limite. Si l'adresse est inférieure au registre limite, le programme cherche écrire dans la mémoire réservée à l'OS. L’accès mémoire est interdit, une exception matérielle est levée et l'OS affiche un message d'erreur. [[File:Circuit total.png|centre|vignette|upright=2.0|Protection mémoire avec un registre limite.]] ===Un seul espace d'adressage, partagé entre plusieurs programmes=== Les systèmes d’exploitation modernes implémentent la '''multiprogrammation''', le fait de pouvoir lancer 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. Et il faut alors résoudre un problème : le partage de la RAM entre les différents programmes. Les différents programmes et leurs données sont placés en mémoire RAM, et il faut trouver un moyen pour répartir les différents programmes en RAM. [[File:Partitions mémoire.png|vignette|upright=1|Partitions mémoire]] Il y a plusieurs solutions pour cela, mais l'une d'entre elle est l'usage de segments mémoire. Avec elle, chaque programme se voit attribuer un ou plusieurs blocs de mémoire. Ils sont appelés des '''partitions mémoire''', ou encore des '''segments'''. Dans ce qui va suivre, nous partons du principe qu'un programme est égal à un segment, pour simplifier les explications. Mais sachez qu'un programme peut être éclaté en plusieurs segments dispersés dans la mémoire, et même être conçu pour ! Nous en reparlerons dans le chapitre sur le mémoire virtuelle. Le système d'exploitation mémorise une liste des segments importants en mémoire RAM, appelée la ''table des segments''. Il s'agit d'un tableau dont chaque entrée mémorise les informations pour un segment. Une entrée de ce tableau est appelée un '''descripteur de segment''' et contient son adresse de base, sa taille, et des autorisations liées à la protection mémoire. Précisément, l'entrée numéro N mémorise les informations du segment numéro N. A partir de l'adresse de base de la table des segments et du numéro de segment, on peut lire ou écrire un descripteur de segment. [[File:Global Descriptor table.png|centre|vignette|upright=2|Global Descriptor table]] Toutefois, l'usage de segments amène un paquet de problèmes qu'il faut résoudre au mieux. Le premier problème est tout simplement de placer les segments dans l'espace d'adressage, mais c'est quelque chose qui est du ressort du système d'exploitation. Par contre, cela implique qu'un segment peut être placé n'importe où en RAM et sa position en RAM change à chaque exécution. En conséquence, les adresses des branchements et des données ne sont jamais les mêmes d'une exécution à l'autre. Pour résoudre ce problème, le compilateur considère que le segment commence à l'adresse zéro, puis les adresses sont corrigées avant ou pendant l'exécution du programme. Cette correction est en général réalisée par l'OS lors de la copie du programme en mémoire, mais il existe aussi des techniques matérielles, que nous verrons dans le chapitre suivant. Il faut aussi prendre en compte le phénomène de l''''allocation mémoire'''. Derrière ce nom barbare, se cache quelque chose de simple : les programmes peuvent réclamer de la mémoire au système d'exploitation, pour y placer des données. Les langages de programmation bas niveau supportent des fonctions comme malloc(), qui permettent de demander un bloc de mémoire de N octets à l'OS, qui doit alors accommoder la demande. S'il n'y a pas assez de mémoire, l'appel échoue et le programme doit gérer la situation. De même, un programme peut libérer de la mémoire qu'il n'utilise plus avec des fonctions comme free(). Avec des segments, cela revient à changer la taille d'un segment, plus précisément à la fin du segment. Et tout cela est géré par le système d'exploitation, aussi on laisse cela pour le prochain chapitre. Enfin, sans protection particulière, les programmes peuvent techniquement lire ou écrire les données des autres. Pour éviter cela, il suffit de vérifier qu'un programme accède bien à son segment et pas ailleurs. Il suffit de vérifier si l'adresse lue/écrite est dans le bon intervalle. Il suffit alors d'utiliser deux registres limites, rien de plus. Mais au-delà de cette simple vérification, si un programme pouvait modifier les données d'un autre programme, on se retrouverait rapidement avec une situation non prévue par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses. Il faut donc introduire des mécanismes de protection mémoire, pour isoler les programmes les uns des autres, et éviter toute modification problématique. La protection mémoire regroupe plusieurs techniques assez variées, qui ont des objectifs différents. Elles ont pour point commun de faire intervenir à des niveaux divers le système d'exploitation et le processeur. ==La ''memory map'' des PC IBM x86== Dans ce qui suit, nous allons étudier l'espace d'adressage des processeurs x86. L'organisation de leur espace d'adressage a évolué dans le temps et l'espace d'adressage s'est adapté. Mais il s'est adapté sans modifier l'existant, pour des raisons de compatibilité. Aussi, les processeurs x86 modernes peuvent fonctionner dans plusieurs modes, appelés mode réel, protégé, virtuel 8086, long, ''system management mode'', et compatibilité 32 bits. [[File:AMD64StateDiagram.svg|centre|vignette|upright=2|AMD64StateDiagram]] Le ''system management mode'' a déjà été abordé dans le chapitre sur les interruptions. Pour simplifier, il ne s'agit pas d'un véritable mode au sens "adressage". Dans ce mode, le système d'exploitation est mis en pause et le BIOS ou un autre firmware s'exécute sans restriction. On y rentre grâce à une interruption dédiée, lancée par le noyau de l'OS. Le système d'exploitation exécute cette interruption afin de laisser la main au BIOS, pour lui déléguer certaines tâches. Par contre, les autres modes sont bien plus intéressant, car ils ont chacun un espace d'adressage différent. Voyons-les dans le détail. ===Le mode réel : l'adressage sur 20 bits=== Le tout premier mode était le '''mode réel''', qui utilisait des adresses de 20 bits, ce qui fait 1 mébioctet de mémoire adressable. Il était utilisé sur des processeurs 16 bits, qui géraient donc des adresses de 16 bits capables d'adresser 64 kibioctets de mémoire. Pour contourner cette limitation, le processeur utilisait la segmentation, que nous verrons dans le prochain chapitre. Pour le moment, nous allons dire que la mémoire en mode réel était composée de 16 blocs de 64 kibioctets. C'est une simplification qui ne fera pas de mal. Le processeur démarre systématiquement en mode réel, y compris sur les ordinateurs modernes. Le BIOS s'exécute dans ce mode, avant de passer en mode protégé et de laisser la main à l'UEFI et au système d'exploitation. Autrefois, le système d'exploitation s'exécutait aussi en mode réel, c'est le cas du DOS. En mode réel, l'espace d'adressage est peuplé par de la RAM, la ROM du BIOS et des périphériques. La RAM prenait les adresses basses, la ROM était au sommet de l'espace d'adressage, et les périphériques entre les deux. Les premiers 640 kibioctets sont réservés à la RAM et sont appelés la '''mémoire conventionnelle'''. Les octets restants forment la '''mémoire haute''', réservée aux ROM et aux périphériques. Les deux premiers kibioctets de la mémoire conventionnelle sont initialisés au démarrage de l'ordinateur. Ils sont utilisés pour stocker le vecteur d'interruption (on expliquera cela dans quelques chapitres) et servent aussi au BIOS. La portion réservée au BIOS, la ''BIOS Data Area'', mémorise des informations en RAM. Elle commence à l'adresse 0040:0000h, a une taille de 255 octets, et est initialisée lors du démarrage de l'ordinateur. Le reste de la mémoire conventionnelle est réservée au le système d'exploitation (MS-DOS, avant sa version 5.0) et au programme en cours d’exécution. La mémoire haute est sectionnée en deux portions. Le sommet de la mémoire haute est réservé au BIOS, alors que le bas de la mémoire haute est réservé aux périphériques. Dans cette dernière, on trouve les BIOS des périphériques (dont celui de la carte vidéo, s'il existe), qui sont nécessaires pour les initialiser et pour communiquer avec eux. De plus, on y trouve la mémoire de la carte vidéo et éventuellement la mémoire d'autres périphériques comme la carte son. {| class="wikitable" style="text-align:center;" |+ Espace d'adressage en mode réel |- ! Numéro du bloc !! Contenu du bloc |- | 1024 - 960 Kio || class="f_jaune" | BIOS principal (ROM) |- | 960 Kio - 640 Kio | class="f_vert" | ROM d'extension des périphériques (XT, EGA, 3270 PC) </br> Mémoire vidéo des cartes EGA, MDA ou CGA |- | 640 Kio - 0 || class="f_rouge" | Mémoire RAM, mémoire conventionnelle |} Pour détailler un peu plus, il faut tenir compte du découpage de l'espace d'adressage en blocs de 64 kibioctets. Le découpage en blocs de 64 kibioctets est particulièrement important pour la mémoire haute. Des blocs sont attribués à un périphérique spécifique. Par exemple, le 16ème bloc est réservé au BIOS, le 12ème aux ROM des périphériques, le 10ème et le 11ème à la mémoire vidéo, etc. {| class="wikitable" style="text-align:center;" |+ Espace d'adressage en mode réel |- ! Numéro du bloc !! Contenu du bloc |- | 0 || rowspan="10" class="f_rouge" | Mémoire RAM, mémoire conventionnelle |- | 1 |- | 2 |- | 3 |- | 4 |- | 5 |- | 6 |- | 7 |- | 8 |- | 9 |- | 10 || class="f_vert" | Mémoire vidéo des cartes EGA |- | 11 || class="f_vert" | Mémoire vidéo des cartes MDA ou CGA |- | 12 || class="f_vert" | ROM d'extension des périphériques (XT, EGA, 3270 PC) |- | 13 || class="f_gris" | Autre, non-réservé |- | 14 || class="f_gris" | Autre, non-réservé |- | 15 || class="f_jaune" | ROM du BIOS |} Vous remarquerez que les blocs 13 et 14 sont non-réservés. Ils peuvent être utilisés pour mapper des périphériques en mémoire. Mais si aucun périphérique n'est mappé dedans, les blocs sont laissés libres. Les OS n'hésitaient pas à les utiliser pour adresser de la RAM. Par exemple, si l'ordinateur n'a pas de carte vidéo EGA, le bloc qui leur était réservé était réutilisé comme mémoire RAM. La mémoire conventionnelle passait alors de 640 à 704 kibioctets de RAM disponibles. L'espace d'adressage correspond à la ROM du BIOS. Mais avec le temps, la ROM est devenue plus lente au point qu'exécuter du code directement depuis la ROM du BIOS était un problème niveau performance. Je rappelle que le BIOS contient de nombreuses routines d'interruptions qui étaient appelées par les systèmes d'exploitation de l'époque, notamment le DOS. Heureusement, diverses optimisations permettaient de copier le BIOS dans la mémoire RAM, plus rapide que la ROM du BIOS. Il s'agit des techniques de '''''BIOS Shadowing'''''. Elles s'appliquaient au BIOS proprement dit, mais le BIOS vidéo pouvait aussi être optimisé ainsi, mais les ROM d'extension ne le sont généralement pas. Le ''BIOS Shadowing'' peut s'implémenter de plusieurs manières, mais la plus courante mappait temporairement de la RAM dans le même espace d'adressage que la ROM du BIOS. Le code du BIOS copiait le BIOS avec une boucle, dont chaque itération lisait une adresse du BIOS et écrivait dans cette même adresse. Juste avant la boucle, le ''chipset'' était configuré par le BIOS pour que les lectures atterrissent dans la ROM du BIOS, mais que les écritures tombaient dans une mémoire RAM mappée. Une fois la copie/boucle terminée, l'adressage tombait dans la RAM aussi bien pour les lectures et écritures. Elle demandait un support du ''chipset'' et du logiciel, pour détourner les adresses temporairement. ===Interlude : les technologies d'expanded memory=== Déjà à l'époque, 1 mébioctet était une limite assez faible, même si cela n'a pas empêché à un grand dirigeant de l'industrie informatique de dire que "640K ought to be enough for anybody". Aussi, diverses solutions matérielles ont vu le jour. Elles étaient peu utilisées et ont concrètement servi à des applications spécifiques. La principale est l''''''expanded memory''''' (EMS), une solution utilisant une carte d'extension avec plusieurs modules de RAM installés dessus. Les modules étaient accédés via la technique de la commutation de banque. [[File:Expanded memory.svg|centre|vignette|upright=2|L'espace d'adressage avec ''Expanded memory''. La carte d'extension et le matériel requis ne sont pas représentés.]] Le terme ''expanded memory'' regroupe plusieurs technologies propriétaires semblables sur le principe mais différentes dans l'exécution. Les premières utilisaient des banques de 64 kibioctets, qui étaient mappées dans un bloc de 64 kibioctets libre en mémoire haute. L'usage de banques de 64 kibioctets collait bien avec l'adressage particulier du mode réel, où la mémoire était gérée par blocs de 64 kibioctets. Les technologies ultérieures utilisaient 2 à 4 pages de 16 kibioctets, qui pouvaient être placée n'importe où en mémoire conventionnelle. [[File:EmulexPersyst 4M ISA.jpeg|centre|vignette|upright=2|Carte d'extension ''Expanded memory'' de marque Emulex Persyst, de 4 mébioctets.]] L'''expanded memory'' demandait que les programmes soient codés pour utiliser cette fonctionnalité. Pour cela, ils devaient utiliser une technique de programmation nommée ''Overlay programming''. L'idée est de découper le programme en plusieurs blocs séparés, appelés des '''''overlays'''''. Certains blocs sont en permanence en RAM, mais d'autres sont soit chargés en RAM, soit stockés ailleurs. Le ailleurs est souvent le disque dur, ou dans la RAM ''expanded memory'' si elle est disponible. Le va-et-vient des ''overlays'' entre RAM et disque dur était réalisé en logiciel, par le programme lui-même. [[File:Overlay Programming.svg|centre|vignette|upright=1.5|''Overlay Programming''.]] L'utilisation de l'''expanded memory'' utilisait l'interruption logicielle 67h, une interruption peu utilisée à l'époque, qui était libre. La carte d'extension était utilisée à travers un pilote de périphérique dédié, qui était appelé via l'interruption 67h. ===Le mode protégé : l'adressage sur 24 et 32 bits=== Par la suite, le mode réel a été remplacé par le '''mode protégé'''. Il utilise la mémoire virtuelle, ce qui fait qu'on devrait le détailler dans le chapitre suivant. Il permettait aussi de dépasser la limite du mébioctet de mémoire. L'espace d'adressage en mode protégé est passé de 24 bits sur le CPU 286, à 32 bits sur le 386 et les CPU suivants. Les programmes conçus pour le mode réel ne pouvaient pas s'exécuter en mode protégé. Pour corriger cela, le 286 a introduit le '''mode virtuel 8086''', aussi appelé mode V86. Il s'agit d'une technique de virtualisation, ce qui fait qu'on en parlera en détail dans le chapitre sur la virtualisation assistée en matériel. Tout ce que l'on peut dire ici est que le mode V86 a été détourné pour que les applications DOS utilisent la mémoire au-delà du mébioctet, appelée la '''mémoire étendue'''. Les logiciels DOS accédaient à la mémoire étendue à travers un driver DOS dédié (HIMEM.SYS). Les logiciels DOS déplaçaient des données dans la mémoire étendue, puis les rapatriaient en mémoire conventionnelle quand ils en avaient besoin. Il ne faut pas confondre mémoire étendue et ''expanded memory''. La technique de ''expanded memory'' repose sur de la commutation de banque, demande une carte d'extension dédiée, fonctionne sans mode V86. A l'inverse, la mémoire étendue ne gère pas de commutation de banque, ne demande pas de carte d'extension, mais utilise le mode V86. Par contre, il est possible d'émuler l'''expanded memory'' sans carte d'extension, avec la mémoire étendue. Quelques cartes mères intégraient des techniques pour, mais une émulation logicielle était aussi possible en réécrivant l'interruption 67h. ===Le mode long : l'adressage 64 bits=== Par la suite, les processeurs ont introduit le '''mode long''', dans lequel les adresses font 64 bits. Le CPU en mode long a aussi un jeu d'instruction totalement différent, des registres en plus et d'autres fonctionnalités actives uniquement dans ce mode. Les programmes compilés pour le 64 bits s'exécutent nativement dans ce mode, alors que les programmes 32 et 16 bits s'exécutent dans un mode de compatibilité dédié. Sur les systèmes 64 bits, le bus d'adresse fait seulement 48 bits afin d'économiser des interconnexions. Avec 48 bits, il manque 64 - 48 = 16 bits d'adresses, qui ne peuvent pas être utilisés pour adresser quoi que ce soit. La règle est que ces 16 bits doivent être tous égaux : soit ils valent tous 0, soient ils sont tous à 1. Les adresses qui respectent cette contrainte sont appelées des '''adresses canoniques'''. Le résultat est que l'espace d'adressage est coupé en trois sections, comme illustré ci-dessous. * Les '''adresses canoniques basses''' ont leurs 16 bits de poids fort à 0 et sont situées en bas de l'espace d'adressage, dans les premières adresses. * Les '''adresses canoniques hautes''' ont leurs 16 bits de poids fort à 1 et sont situées au sommet de l'espace d'adressage, dans les dernières adresses. * Entre les deux, se trouvent des '''adresses non-canoniques''', qui ne sont pas accessibles. Y accéder déclenche la levée d'une exception matérielle. {| |[[File:AMD64-canonical--48-bit.png|vignette|Espace d'adressage x86-64 bits, avec des adresses physiques de 48 bits.]] |[[File:AMD64-canonical--56-bit.png|vignette|Espace d'adressage x86-64 bits, avec des adresses physiques de 57 bits.]] |} : Les futurs systèmes x86 devraient passer à des adresses de 57 bits, les trois sections auront donc des frontières différentes. Les adresses non-canoniques sont censées être inutilisables. Mais les programmeurs aimeraient bien pouvoir les utiliser pour des ''pointeurs tagués'', à savoir des pointeurs/adresses associées à des informations. L'idée serait d'utiliser les 16 bits de poids fort pour stocker des informations liées au pointeur, seuls les 48 bits restant codant l'adresse. Les 16 bits peuvent être utilisés de manières très diverses. Un exemple est la technique du '''''memory tagging''''', qui crée une somme de contrôle au pointeur. La somme de contrôle est générée par un algorithme cryptographique, à partir de l'adresse du pointeur. Elle est vérifiée à chaque utilisation du pointeur. Si la somme de contrôle ne correspond pas au pointeur, une erreur est levée. Si jamais un virus ou un code malveillant modifie un pointeur, il ne saura pas comment calculer la somme de contrôle, la somme de contrôle a énormément de chances d'être incorrecte. Quelques fonctionnalités des processeurs visent à autoriser l'utilisation des adresses non-canoniques. L'idée est que les 16 bits de poids fort sont ignorées lors des accès mémoire, ce qui permet d'utiliser les 16 bits de poids fort à volonté. Il s'agit des techniques ''Top Byte Ignore'' d'ARM, ''Upper Address Ignore'' d'AMD, et ''Linear Address Masking'' d'Intel. ==La z/architecture d'IBM== IBM a sa propre gamme de ''mainframes'', qui utilisent des processeurs fait maison par IBM lui-même. Les CPU IBM ont leur propre jeu d'instruction CISC, appelé '''''z/architecture'''''. Et ce jeu d'instruction a des possibilités en termes d'adressage qui sont extrêmement intéressantes, en plus d'être assez inédites. Pour ce chapitre, nous allons nous concentrer sur une fonctionnalité : les programmes ont accès à plusieurs espaces d'adressage naturellement. ===L'isolation des processus=== Les CPU ''z/architecture'' peuvent fonctionner en quatre modes, dont l'un permet à un programme d'utiliser plusieurs espaces d'adressage. L'avantage pour les programmes utilisateur n'est pas très évident, mais il l'est nettement plus pour les systèmes d'exploitation. Le système d'exploitation de ces ''mainframes'', le z/OS, profite de cette possibilité, idem pour l'hyperviseur z/VM. Il utilise cette possibilité pour donner un espace d'adressage séparé pour chaque programme en cours d'exécution. Les programmes sont alors isolés les uns des autres. Cette '''isolation des processus''' n'est pas spécifique à IBM : tous les systèmes d'exploitation modernes l'utilisent. Mais les processeurs x86 doivent se débrouiller seulement avec des techniques dites de mémoire virtuelle, alors que les ''mainframes'' IBM complètent la mémoire virtuelle avec des facilités en plus. Pour les programmes utilisateurs, un intérêt est que cela permet de partager des données entre plusieurs programmes. Ils ont un espace d'adressage rien qu'à eux, mais les données partagées sont placées dans un espace d'adressage commun, auquel les deux programmes ont accès. L'espace d'adressage partagé peu même être configuré de manière à ne contenir que des données, il est impossible d'exécuter du code depuis cet espace d'adressage. Ce qui empêche certains ''malwares'' de fonctionner. ===Les espaces d'adressage par défaut de la ''z/architecture''=== La ''z/architecture'' supporte 65536 espaces d'adressage simultanés. Les espaces d'adressage sont numérotés, ils ont chacun un identifiant codé sur 16 bits, appelé un '''''Address-Space Number'''''. Nous utiliserons l’abréviation ASN dans ce qui suit. Par contre, un programme ne peut pas utiliser tout ces espaces d'adressages, il ne peut en utiliser que 16 en même temps, maximum. De plus, ils ne sont accessibles que quand le processeur est dans un mode de fonctionnement bien précis. Pour utiliser un espace d'adressage, un programme doit connaitre l'ASN associé. Par défaut, un programme exécuté sur la ''z/architecture'' utilise deux espaces d'adressages : l''''espace d'adressage primaire''' où se situent le programme exécuté et les données importantes, un '''espace d'adressage secondaire''' utilisé pour des tâches/données annexes. L'ASN de ces deux espaces d'adressage est mémorisé dans les registres de contrôle du processeur. L'ASN de l'espace primaire est dans le registre de contrôle n°3, l'ASN de l'espace secondaire est dans le registre de contrôle n°4 (ils sont dans les 16 bits de poids faible, dans les deux cas). Les deux registres de controle sont initialisés au démarrage de l'application, par le système d'exploitation, ce qui fait que ce sont des espaces d'adressage par défaut. Le programme a donc accès à ces deux espaces d'adressage sans rien faire. : Ils ont aussi accès à un '''espace d'adressage ''home''''' unique qui est surtout utilisé par le système d'exploitation, qui a pour particularité de ne pas avoir d'ASN. Il n'est concrètement accesible qu'en mode superviseur, donc par le système d'exploitation. Nous n'en parlerons pas dans ce qui suit. Mais comment le processeur pour utiliser ces espaces d'adressages ? Par exemple, s'il veut faire une lecture, comment précise-t-il l'espace d'adressage courant ? La réponse est que le processeur peut être dans deux états : soit primaire, soit secondaire. Dans le premier état, toutes les lectures/écritures se font dans l'espace d'adressage primaire. Dans le second, elles se font toutes dans l'espace d'adressage secondaire. Il peut passer de l'un à l'autre en utilisant l'instruction ''Set Address Space Control''. ===Les registres d'accès de la ''z/architecture''=== Un programme peut donc passer de l'espace d'adressage primaire à secondaire (ou inversement) en utilisant l’instruction ''SET ADDRESS SPACE CONTROL''. Mais il peut en profiter pour passer dans un mode où il a accès à 16 espaces d'adressage différents, en même temps. Le mode en question est appelé le '''mode ''access register'''''. Le processeur a donc quatre modes de fonctionnement : primaire, secondaire, ''home'' et ''access register''. Qui dit 16 espaces d'adressage dit 16 ASNs à mémoriser quelque part. Pour cela, le processeur dispose de 16 '''registres d'accès''', chacun mémorisant un ASN. Un accès mémoire précise quel registre d'accès utiliser, ce qui lui permet de sélectionner l'espace d'adressage voulu. Pas besoin d'utiliser ''SET ADDRESS SPACE CONTROL'' pour passer de l'un à l'autre, le programme peut gérer 16 espaces d’adressage en même temps. Ce qui est une différence importante par rapport au mode de fonctionnement précédent. : Une précision importante est que le premier registre d'accès mémorise l'ASN de l'espace d'adressage primaire. De plus, les instructions sont chargées depuis l'espace d'adressage primaire. Les CPU ''z/architecture'' disposent de 16 registres généraux de 64 bits, de 16 registres flottants eux aussi de 64 bits, de 16 registres de contrôle eux aussi de 64 bits, et de '''registres d'accès''' qui gèrent les espaces d'adressage. Chaque registre entier est associé à un espace d'adressage particulier, qui est indiqué par son registre d'accès. Les registres d'accès sont utilisés lors des lectures/écritures indirectes. L'adresse est lue depuis un registre général, et son registre d'accès est consulté pour déterminer l'espace d'adressage. Une implémentation simple numéroterait les espaces d'adressage et mettrait les numéros dans les registres d'accès. Les registres d'accès seraient idéalement protégés en écriture et seulement accessibles par le système d'exploitation. Mais ce n'est pas ce qui est fait, pour des raisons de sécurité. Les registres d'accès mémorisent bien un identifiant, mais celui-ci ne sélectionne pas l'espace d'adressage directement. L'identifiant est envoyée à une table de correspondance matérielle, qui mémorise les correspondances entre un identifiant et un espace d'adressage. Il y a une table de correspondance par programme, ce qui fait qu'un identifiant ne correspondra pas au même espace d'adressage dans deux programmes différents. La table de correspondance mémorise aussi des droits d'accès, qui sont différents pour chaque programme. Par exemple, tel programme aura accès en lecture seule à tel espace d'adressage, alors qu'un autre programme y aura accès en lecture et écriture, etc. Un espace d'adressage peut ainsi être partagé entre deux programmes plus facilement, tout en ajoutant des garanties de sécurité. L'usage des registres d'accès est aussi plus flexible qu'on ne pourrait le croire. Le programme peut lire et écrire dans les registres d'accès, copier un registre d'accès dans un autre, etc. Par contre, la table de correspondance empêche à un programme d’accéder à un espace d'adressage arbitraire. Le système d'exploitation remplit la table de manière à n'autoriser l'accès qu'aux espaces d'adressage adéquats, il y enregistre seulement les correspondances utiles. Si un programme forge un identifiant arbitraire, et l'envoie à la table, celle-ci n'aura pas la correspondance associée et l'accès mémoire sera invalidé. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs 8 bits et moins | prevText=Les processeurs 8 bits et moins | next=L'abstraction mémoire et la mémoire virtuelle | nextText=L'abstraction mémoire et la mémoire virtuelle }} </noinclude> mwb916ws54dynsjr6dd2dbjh82cahbr 774364 774363 2026-10-07T14:40:01Z Mewtow 31375 /* L'isolation des processus */ 774364 wikitext text/x-wiki L''''espace d'adressage''' du processeur est l'ensemble des adresses utilisables par le processeur. Par exemple, un processeur 16 bits peut adresser 2^16 = 65536 adresses, l'ensemble de ces adresses forme son espace d'adressage. L'espace d'adressage n'est pas toujours égal à la mémoire réellement installée. S'il n'y a pas assez de RAM installée, des adresses seront inoccupées. De plus, une partie de l'espace d'adressage peut être détourné pour communiquer avec les périphériques, comme nous le verrons plus bas. Nous verrons aussi qu'un processeur peut avoir plusieurs espaces d'adressages séparés. ==L'adressage de la RAM et de la ROM== Avoir plusieurs espaces d'adressage spécialisés est quelque chose que nous avons déjà rencontré dans les chapitres précédents, mais sans le dire ouvertement. Aussi, nous allons faire quelques rappels sur les cas déjà rencontrés. En premier lieu, nous allons rappeler la différence entre architectures Von Neumann et Hardvard. La différence entre les deux tient dans l'adressage des mémoires RAM et ROM : est-ce qu'elles sont dans un seul espace d'adressage, ou dans des espaces d'adressage séparés. L''''architecture Von Neumann''' a un seul espace d'adressage, découpé entre la mémoire RAM d'un côté et la mémoire ROM de l'autre. Une adresse correspond soit à la mémoire RAM, soit à la mémoire ROM, mais pas aux deux. Typiquement, la mémoire ROM est placée dans les adresses hautes, les plus élevées, alors que la RAM est placée dans les adresses basses en commençant par l'adresse 0. C'est une convention qui n'est pas toujours respectée, aussi mieux vaut éviter de la tenir pour acquise. [[File:Vision de la mémoire par un processeur sur une architecture Von Neumann.png|centre|vignette|upright=2|Vision de la mémoire par un processeur sur une architecture Von Neumann.]] L''''architecture Harvard''' utilise des espaces d'adressage séparés pour la RAM et la ROM. Une même adresse peut correspondre soit à la ROM, soit à la RAM. Le processeur voit bien deux mémoires séparées, chacune dans son propre espace d'adressage. Les deux espaces d'adressage n'ont pas forcément la même taille. Il est possible d'avoir un plus gros espace d'adressage pour la RAM que pour la ROM. Cela implique que les adresses des instructions et des données soient de taille différentes. C'est peu pratique et c'est rarement implémenté, ce qui fait que le cas le plus courant est celui où les deux espaces d'adressages ont la même taille. [[File:Vision de la mémoire par un processeur sur une architecture Harvard.png|centre|vignette|upright=2|Vision de la mémoire par un processeur sur une architecture Harvard.]] Sur une '''architecture Harvard modifiée''', le programme est placé dans un espace d'adressage à part du reste. Le problème, c'est que cet espace d'adressage ne contient pas que le code machine à exécuter. Il contient aussi des constantes, à savoir des données qui gardent la même valeur lors de l'exécution du programme. Elles peuvent être lues, mais pas modifiées durant l'exécution du programme. L'accès à ces constantes demande d'aller lire celles-ci dans l'autre espace d'adressage, pour les copier dans les registres généraux. Le processeur doit pour cela avoir des instructions de lecture dédiées. En clair, il y a une instruction LOAD pour lire dans la mémoire ROM, une autre pour lire dans la RAM. [[File:Espaces d'adressage sur une archi harvard modifiée.png|centre|vignette|upright=3|Espaces d'adressage sur une archi harvard modifiée]] Une autre solution utilise des instructions de copie, qui copient les constantes en mémoire RAM, donc d'un espace d'adressage à l'autre. [[File:Organisation des espaces d'adressage sur une archi harvard modifiée.png|centre|vignette|upright=3|Organisation des espaces d'adressage sur une archi harvard modifiée]] ==L'adressage des périphériques== Passons maintenant à l'adressage des périphériques. La communication avec les périphériques se fait par l'intermédiaire de registres d’interfaçage. Et ces registres peuvent soit avoir un espace d'adressage séparé, soit être inclus dans l'espace d'adressage des mémoires. Dans ce qui suit, nous allons supposer que l'architecture des de type Von Neumann pour simplifier les explications. ===Les entrées-sorties mappées en mémoire=== Une partie de l'espace d'adressage peut être détourné pour communiquer avec les périphériques. Le périphérique se retrouve inclus dans l'ensemble des adresses utilisées pour manipuler la mémoire : on dit qu'il est mappé en mémoire. Les adresses mémoires associées à un périphérique sont redirigées automatiquement vers les registres du périphérique en question, voire vers sa mémoire intégrée. On parle alors d''''entrées-sorties mappées en mémoire'''. [[File:IO mappées en mémoire.png|centre|vignette|upright=2.0|IO mappées en mémoire]] On remarque un défaut inhérent à cette technique : les adresses utilisées pour les périphériques ne sont plus disponibles pour la RAM. On ne peut plus adresser autant de RAM qu'avant. La perte dépend des périphériques installés et de leur gourmandise en adresses mémoires. C'est ce qui causait un problème assez connu sur les ordinateurs 32 bits, capables d'adresser 4 gibioctets de mémoire. Certaines personnes installaient 4 gigaoctets de mémoire sur leur ordinateur 32 bits et se retrouvaient avec « seulement » 3,5 à 3,8 gigaoctets de mémoire, les périphériques prenant le reste. Il est possible que la RAM d'un périphérique soit mappée en RAM. Un exemple classique est celui des cartes graphiques qui incorporent une mémoire RAM appelée la mémoire vidéo. La mémoire vidéo est mappée en mémoire, ce qui permet au processeur d'écrire directement dedans. Toute lecture ou écriture dans les adresses associées est redirigée vers le bus PCI/AGP/PCI-Express. Nous verrons plus bas des exemples d'ordinateurs où la mémoire vidéo est mappée en mémoire, que ce soit totalement ou partiellement. Si je dis totalement ou partiellement, c'est parce que les cartes graphiques modernes disposent de tellement de mémoire qu'on ne peut pas la mapper totalement dans l'espace d'adressage. Sur les systèmes d'avant 2008, seuls 256 mégaoctets de mémoire vidéo sont mappés en mémoire RAM. Le reste de la mémoire vidéo est invisible du point de vue du processeur, mais manipulable par le GPU à sa guise. Après 2008, la spécification du PCI-Express ajouta un support de la technologie ''Resizable Bar'', qui permet au processeur d’accéder directement à plus de 256 mégaoctets de mémoire vidéo, voire à la totalité de la mémoire vidéo. ===L'espace d'adressage séparé pour les entrées-sorties=== Les entrées-sorties et périphériques peuvent avoir leur propre espace d'adressage dédié, séparé de celui utilisé pour les mémoires RAM et ROM. [[File:Espaces_d'adressages_séparés_entre_mémoire_et_périphérique.png|centre|vignette|upright=3|Bit IO.]] Une même adresse peut donc adresser soit une entrée-sortie, soit une case mémoire. Et pour faire la différence, le processeur doit avoir des instructions séparées pour adresser les périphériques et la mémoire. Il a des instructions de lecture/écriture pour lire/écrire en mémoire, et d'autres pour lire/écrire les registres d’interfaçage. Sans cela, le processeur ne saurait pas si une adresse est destinée à un périphérique ou à la mémoire. Cela élimine aussi les problèmes avec les caches : les accès à l'espace d'adressage de la RAM passent par l'intermédiaire de la mémoire cache, alors les accès dans l'espace d'adressage des périphériques le contournent totalement. Là encore, les deux espaces d'adressage n'ont pas forcément la même taille. Il arrive que les deux espaces d'adressage aient la même taille, le plus souvent sur des ordinateurs complexes avec beaucoup de périphériques. Mais les systèmes embarqués ont souvent des espaces d'adressage plus petits pour les périphériques que pour la ou les mémoires. L'implémentation varie grandement suivant le cas, la première méthode imposant d'avoir deux bus séparés pour les mémoires et les périphériques, l'autre permettant un certain partage du bus d'adresse. Nous reviendrons dessus plus en détail dans le chapitre sur l'adressage des périphériques. ==La ''memory map'' d'un ordinateur== Les deux sections précédentes nous ont appris que l'on peut utiliser un espace d'adressage séparé pour la ROM et un autre pour les périphériques. En tout, cela donne quatre possibilités distinctes. {|class="wikitable" |- ! ! IO mappées en mémoire ! IO séparées |- ! ROM mappée en mémoire (architecture Von Neumann) | Un seul espace d'adressage | Deux espaces d'adressage : * un pour les mémoires RAM/ROM ; * un pour les IO. |- ! ROM séparée (architecture Harvard) | Deux espaces d'adressage : * un pour la RAM et les IO ; * un pour la ROM. | Trois espaces d'adressages : * un pour la RAM ; * un pour la ROM ; * un pour les IO. |} Les quatre solutions ont des avantages et inconvénients divers, mais il est intéressant de contraster un espace d'adressage unique avec plusieurs espaces d'adressages. ===L'espace d'adressage unique=== Avec un espace d'adressage unique, la ROM est au sommet de l'espace d'adressage, les périphériques sont juste en-dessous, la RAM commence à l'adresse 0 et prend les adresses basses. Faire simplifie grandement l'implémentation matérielle de l'adressage. Notons que d'autres composants que les périphériques ou les mémoires peuvent se trouver dans l'espace d'adressage. On peut y trouver les horloges temps réels, des timers, des senseurs de température, ou d'autres composants placés sur la carte mère. Un exemple un peu original est le suivant : la console de jeu Nintendo DS incorporait une unité de calcul spécialisée dans les divisions et racines carrées, séparée du processeur, qui était justement mappée en mémoire ! [[File:Espace d'adressage classique avec entrées-sorties mappées en mémoire.png|centre|vignette|upright=2|Espace d'adressage classique avec entrées-sorties mappées en mémoire]] L'espace d'adressage unique est plus simple pour les programmeurs, malgré un désavantage dont il faut parler. Le problème est que des adresses censées être disponibles pour la RAM sont détournées vers la ROM ou les périphériques, ce qui limite la quantité de RAM qui peut être réellement adressée en pratique. C'est un défaut qui se manifeste seulement pour les petits espaces d'adressages, de 8 à 16 bits, qui ne permettent pas d'adresser beaucoup de RAM. Avec des adresses codées sur 16 bits, un espace d'adressage de 64 kibioctets de RAM est grignoté par des entrées-sorties assez rapidement. Par contre, pour des adresses de 32 à 64 bits, une bonne partie de l'espace d'adressage est inutilisé, car il n'y a pas assez de RAM. L'espace d'adressage unique n'a donc pas de désavantages. Les vielles consoles de jeux et certains vieux ordinateurs (Commodores et Amiga), utilisaient un espace d'adressage unique. Elles n'avaient pas une variété de cartes graphiques ou de cartes sons différentes, comme sur les PCs modernes. Au contraire, elles avaient la même configuration matérielle, le matériel ne pouvait pas être changé ni upgradé. Le système d'exploitation était rudimentaire et ne contrôlait pas vraiment l'accès au matériel. Les programmeurs avaient donc totalement accès au matériel et mapper les entrées/sorties en mémoire rendait la programmation des périphériques très simple. <gallery widths=200px heights=500px> N5200mk2 memory map.svg|Adressage mémoire (carte mémoire) du N5200mk2. PC-9801VM memory map.svg|Adressage mémoire (carte mémoire) du PC-9801VM. </gallery> ===La commutation de banques (''bank switching'')=== Le '''''bank switching''''', aussi appelé '''commutation de banque''', permet d'utiliser plusieurs espaces d'adressage sur un même processeur, sans attribuer chaque espace d'adressage pour une raison précise. L'espace d'adressage est présent en plusieurs exemplaires appelés des '''banques'''. Les banques sont numérotées, chaque numéro de banque permettant de l'identifier et de le sélectionner. Le but de cette technique est d'augmenter la mémoire disponible pour l'ordinateur. Par exemple, supposons que j'ai besoin d'adresser une mémoire ROM de 4 kibioctets, une RAM de 8 kibioctets, et divers périphériques. Le processeur a un bus d'adresse de 12 bits, ce qui limite l'espace d'adressage à 4 kibioctets. Dans ce cas, je peux réserver 4 banques : une pour la ROM, une pour les périphériques, et deux banques qui contiennent chacune la moitié de la RAM. La simplicité et l'efficacité de cette technique font qu'elle est beaucoup utilisée dans l'informatique embarquée. [[File:PC-8801MemoryMap.gif|centre|vignette|upright=2|exemple de Bank switching.]] Cette technique demande d'utiliser un bus d'adresse plus grand que les adresses du processeur. L'adresse réelle se calcule en concaténant le numéro de banque avec l'adresse accédée. Le numéro de la banque actuellement en cours d'utilisation est mémorisé dans un registre appelé le '''registre de banque'''. On peut changer de banque en changeant le contenu de ce registre. Le processeur dispose souvent d'instructions spécialisées qui en sont capables. {| class="wikitable flexible" |[[File:Banque mémoire.png|Banque mémoire.]] |[[File:Registre de banque.png|Registre de banque.]] |} ==Le placement des programmes dans l'espace d'adressage== Les ordinateurs modernes sont des architectures Von Neumann, avec un seul espace d'adressage, qui est partagé suivant les besoins. Et un point important est que le système d'exploitation et les programmes sont copiés en mémoire RAM avant d'être exécutés. Ils ne sont pas exécutés depuis une mémoire ROM, cela est réservé aux systèmes embarqués simples. Et cela a une influence sur l'espace d'adressage. Les programmes et le système d'exploitation doivent se partager l'espace d'adressage. Voyons voir comment ce partage se fait. Dans ce chapitre, nous allons volontairement mettre de côté les ordinateurs qui supportent la mémoire virtuelle. Nous en parlerons dans le prochain chapitre. Le principe est de voir comment un processeur avec un seul espace d'adressage gèrent la présence d'un ou de plusieurs programmes. ===Un seul espace d'adressage, non-partagé=== Le cas le plus simple est celui où il n'y a pas de système d'exploitation et où un seul programme s'exécute sur l'ordinateur. Le programme a alors accès à tout l'espace d'adressage, sauf pour ce qui est réservé aux périphériques et à la mémoire ROM. Le programme organise la mémoire en plusieurs sections, dans lesquelles le programme range des données différentes. Typiquement, on trouve quatre blocs de mémoire, appelés des segments, chacun étant spécialisé pour les données, le code, la pile, etc. Voici ces trois sections : * Le segment '''''text''''' 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. * Le segment pour la '''pile''', de taille variable. * le reste est appelé le '''tas''', de taille variable. [[File:Organisation d'un espace d'adressage unique utilisé par un programme unique.png|centre|vignette|upright=2|Organisation d'un espace d'adressage unique utilisé par un programme unique]] [[File:Typical computer data memory arrangement.png|vignette|upright=0.5|Typical computer data memory arrangement]] La pile et le tas sont de taille variable, ce qui veut dire qu'ils peuvent grandir ou diminuer à volonté, contrairement au reste. Entre le tas et la pile, on trouve un espace de mémoire inutilisée, qui peut être réquisitionné selon les besoins. La pile commence généralement à l'adresse la plus haute et grandit en descendant, alors que le tas grandit en remontant vers les adresses hautes. Il s'agit là d'une convention, rien de plus. Il est possible d'inverser la pile et le tas sans problème, c'est juste que cette organisation est rentrée dans les usages. Avant de poursuivre, précisons qu'il est possible de regrouper plusieurs programmes distincts dans un seul, afin qu'ils partagent le même morceau de mémoire. Les programmes portent alors le nom de '''threads'''. Les ''threads'' d'un même processus partagent le même espace d'adressage. Ils partagent généralement certains segments : ils se partagent le code, le tas et les données statiques. Par contre, chaque thread dispose de sa propre pile d'appel. [[File:Single vs multithreaded processes.jpg|centre|vignette|upright=2.0|Distinction entre processus mono et multi-thread.]] Il va de soi que cette vision de l'espace d'adressage ne tient pas compte des périphériques. C'est-à-dire que les schémas précédents partent du principe qu'on a un espace d'adressage séparé pour les périphériques. Dans le cas où les entrées-sorties sont mappées en mémoire, l'organisation est plus compliquée. Généralement, les adresses associées aux périphériques sont placées juste au-dessus de la pile, dans les adresses hautes. ===Un seul espace d'adressage, partagé avec le système d'exploitation=== [[File:Gestion de la mémoire sur les OS monoprogrammés.png|vignette|upright=1.0|Gestion de la mémoire sur les OS monoprogrammés.]] Maintenant, étudions le cas où le programme partage la mémoire avec le système d'exploitation. Sur les systèmes d'exploitation les plus simples, on ne peut lancer qu'un seul programme à la fois. Le système d'exploitation réserve une portion de taille fixe réservée au système d'exploitation, alors que le reste de la mémoire est utilisé pour le programme à exécuter. Le programme est placé à un endroit en RAM qui est toujours le même. Le système d'exploitation peut être soit placé dans une ROM, soit copié en mémoire RAM depuis une mémoire de masse, comme le programme à lancer. Si le système d'exploitation est copié en mémoire RAM, il est généralement placé dans les premières adresses, les adresses basses. A l'inverse, un OS en mémoire ROM est généralement placé à la fin de la mémoire, dans les adresses hautes. Mais tout cela n'est qu'une convention, et les exceptions sont monnaie courante. [[File:Méthodes d'allocation de la mémoire avec un espace d'adressage unique.png|centre|vignette|upright=2.0|Méthodes d'allocation de la mémoire avec un espace d'adressage unique]] Sur de tels systèmes, il faut protéger l'OS contre une erreur ou malveillance d'un programme utilisateur. Notons qu'il s'agit d'une protection en écriture, pas en lecture. Le programme peut parfaitement lire les données de l'OS sans problèmes, au moins en partie. Dans ce qui suit, on part du principe que l'OS est dans les adresses basses. La solution la plus courante interdit au programme d'écrire au-delà d'une limite au-delà de laquelle se trouve le système d’exploitation. Pour cela, le processeur incorpore un '''registre limite''', qui contient l'adresse limite au-delà de laquelle un programme peut pas écrire. Pour toute écriture en espace utilisateur, l'adresse écrite est comparée au registre limite. Si l'adresse est inférieure au registre limite, le programme cherche écrire dans la mémoire réservée à l'OS. L’accès mémoire est interdit, une exception matérielle est levée et l'OS affiche un message d'erreur. [[File:Circuit total.png|centre|vignette|upright=2.0|Protection mémoire avec un registre limite.]] ===Un seul espace d'adressage, partagé entre plusieurs programmes=== Les systèmes d’exploitation modernes implémentent la '''multiprogrammation''', le fait de pouvoir lancer 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. Et il faut alors résoudre un problème : le partage de la RAM entre les différents programmes. Les différents programmes et leurs données sont placés en mémoire RAM, et il faut trouver un moyen pour répartir les différents programmes en RAM. [[File:Partitions mémoire.png|vignette|upright=1|Partitions mémoire]] Il y a plusieurs solutions pour cela, mais l'une d'entre elle est l'usage de segments mémoire. Avec elle, chaque programme se voit attribuer un ou plusieurs blocs de mémoire. Ils sont appelés des '''partitions mémoire''', ou encore des '''segments'''. Dans ce qui va suivre, nous partons du principe qu'un programme est égal à un segment, pour simplifier les explications. Mais sachez qu'un programme peut être éclaté en plusieurs segments dispersés dans la mémoire, et même être conçu pour ! Nous en reparlerons dans le chapitre sur le mémoire virtuelle. Le système d'exploitation mémorise une liste des segments importants en mémoire RAM, appelée la ''table des segments''. Il s'agit d'un tableau dont chaque entrée mémorise les informations pour un segment. Une entrée de ce tableau est appelée un '''descripteur de segment''' et contient son adresse de base, sa taille, et des autorisations liées à la protection mémoire. Précisément, l'entrée numéro N mémorise les informations du segment numéro N. A partir de l'adresse de base de la table des segments et du numéro de segment, on peut lire ou écrire un descripteur de segment. [[File:Global Descriptor table.png|centre|vignette|upright=2|Global Descriptor table]] Toutefois, l'usage de segments amène un paquet de problèmes qu'il faut résoudre au mieux. Le premier problème est tout simplement de placer les segments dans l'espace d'adressage, mais c'est quelque chose qui est du ressort du système d'exploitation. Par contre, cela implique qu'un segment peut être placé n'importe où en RAM et sa position en RAM change à chaque exécution. En conséquence, les adresses des branchements et des données ne sont jamais les mêmes d'une exécution à l'autre. Pour résoudre ce problème, le compilateur considère que le segment commence à l'adresse zéro, puis les adresses sont corrigées avant ou pendant l'exécution du programme. Cette correction est en général réalisée par l'OS lors de la copie du programme en mémoire, mais il existe aussi des techniques matérielles, que nous verrons dans le chapitre suivant. Il faut aussi prendre en compte le phénomène de l''''allocation mémoire'''. Derrière ce nom barbare, se cache quelque chose de simple : les programmes peuvent réclamer de la mémoire au système d'exploitation, pour y placer des données. Les langages de programmation bas niveau supportent des fonctions comme malloc(), qui permettent de demander un bloc de mémoire de N octets à l'OS, qui doit alors accommoder la demande. S'il n'y a pas assez de mémoire, l'appel échoue et le programme doit gérer la situation. De même, un programme peut libérer de la mémoire qu'il n'utilise plus avec des fonctions comme free(). Avec des segments, cela revient à changer la taille d'un segment, plus précisément à la fin du segment. Et tout cela est géré par le système d'exploitation, aussi on laisse cela pour le prochain chapitre. Enfin, sans protection particulière, les programmes peuvent techniquement lire ou écrire les données des autres. Pour éviter cela, il suffit de vérifier qu'un programme accède bien à son segment et pas ailleurs. Il suffit de vérifier si l'adresse lue/écrite est dans le bon intervalle. Il suffit alors d'utiliser deux registres limites, rien de plus. Mais au-delà de cette simple vérification, si un programme pouvait modifier les données d'un autre programme, on se retrouverait rapidement avec une situation non prévue par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses. Il faut donc introduire des mécanismes de protection mémoire, pour isoler les programmes les uns des autres, et éviter toute modification problématique. La protection mémoire regroupe plusieurs techniques assez variées, qui ont des objectifs différents. Elles ont pour point commun de faire intervenir à des niveaux divers le système d'exploitation et le processeur. ==La ''memory map'' des PC IBM x86== Dans ce qui suit, nous allons étudier l'espace d'adressage des processeurs x86. L'organisation de leur espace d'adressage a évolué dans le temps et l'espace d'adressage s'est adapté. Mais il s'est adapté sans modifier l'existant, pour des raisons de compatibilité. Aussi, les processeurs x86 modernes peuvent fonctionner dans plusieurs modes, appelés mode réel, protégé, virtuel 8086, long, ''system management mode'', et compatibilité 32 bits. [[File:AMD64StateDiagram.svg|centre|vignette|upright=2|AMD64StateDiagram]] Le ''system management mode'' a déjà été abordé dans le chapitre sur les interruptions. Pour simplifier, il ne s'agit pas d'un véritable mode au sens "adressage". Dans ce mode, le système d'exploitation est mis en pause et le BIOS ou un autre firmware s'exécute sans restriction. On y rentre grâce à une interruption dédiée, lancée par le noyau de l'OS. Le système d'exploitation exécute cette interruption afin de laisser la main au BIOS, pour lui déléguer certaines tâches. Par contre, les autres modes sont bien plus intéressant, car ils ont chacun un espace d'adressage différent. Voyons-les dans le détail. ===Le mode réel : l'adressage sur 20 bits=== Le tout premier mode était le '''mode réel''', qui utilisait des adresses de 20 bits, ce qui fait 1 mébioctet de mémoire adressable. Il était utilisé sur des processeurs 16 bits, qui géraient donc des adresses de 16 bits capables d'adresser 64 kibioctets de mémoire. Pour contourner cette limitation, le processeur utilisait la segmentation, que nous verrons dans le prochain chapitre. Pour le moment, nous allons dire que la mémoire en mode réel était composée de 16 blocs de 64 kibioctets. C'est une simplification qui ne fera pas de mal. Le processeur démarre systématiquement en mode réel, y compris sur les ordinateurs modernes. Le BIOS s'exécute dans ce mode, avant de passer en mode protégé et de laisser la main à l'UEFI et au système d'exploitation. Autrefois, le système d'exploitation s'exécutait aussi en mode réel, c'est le cas du DOS. En mode réel, l'espace d'adressage est peuplé par de la RAM, la ROM du BIOS et des périphériques. La RAM prenait les adresses basses, la ROM était au sommet de l'espace d'adressage, et les périphériques entre les deux. Les premiers 640 kibioctets sont réservés à la RAM et sont appelés la '''mémoire conventionnelle'''. Les octets restants forment la '''mémoire haute''', réservée aux ROM et aux périphériques. Les deux premiers kibioctets de la mémoire conventionnelle sont initialisés au démarrage de l'ordinateur. Ils sont utilisés pour stocker le vecteur d'interruption (on expliquera cela dans quelques chapitres) et servent aussi au BIOS. La portion réservée au BIOS, la ''BIOS Data Area'', mémorise des informations en RAM. Elle commence à l'adresse 0040:0000h, a une taille de 255 octets, et est initialisée lors du démarrage de l'ordinateur. Le reste de la mémoire conventionnelle est réservée au le système d'exploitation (MS-DOS, avant sa version 5.0) et au programme en cours d’exécution. La mémoire haute est sectionnée en deux portions. Le sommet de la mémoire haute est réservé au BIOS, alors que le bas de la mémoire haute est réservé aux périphériques. Dans cette dernière, on trouve les BIOS des périphériques (dont celui de la carte vidéo, s'il existe), qui sont nécessaires pour les initialiser et pour communiquer avec eux. De plus, on y trouve la mémoire de la carte vidéo et éventuellement la mémoire d'autres périphériques comme la carte son. {| class="wikitable" style="text-align:center;" |+ Espace d'adressage en mode réel |- ! Numéro du bloc !! Contenu du bloc |- | 1024 - 960 Kio || class="f_jaune" | BIOS principal (ROM) |- | 960 Kio - 640 Kio | class="f_vert" | ROM d'extension des périphériques (XT, EGA, 3270 PC) </br> Mémoire vidéo des cartes EGA, MDA ou CGA |- | 640 Kio - 0 || class="f_rouge" | Mémoire RAM, mémoire conventionnelle |} Pour détailler un peu plus, il faut tenir compte du découpage de l'espace d'adressage en blocs de 64 kibioctets. Le découpage en blocs de 64 kibioctets est particulièrement important pour la mémoire haute. Des blocs sont attribués à un périphérique spécifique. Par exemple, le 16ème bloc est réservé au BIOS, le 12ème aux ROM des périphériques, le 10ème et le 11ème à la mémoire vidéo, etc. {| class="wikitable" style="text-align:center;" |+ Espace d'adressage en mode réel |- ! Numéro du bloc !! Contenu du bloc |- | 0 || rowspan="10" class="f_rouge" | Mémoire RAM, mémoire conventionnelle |- | 1 |- | 2 |- | 3 |- | 4 |- | 5 |- | 6 |- | 7 |- | 8 |- | 9 |- | 10 || class="f_vert" | Mémoire vidéo des cartes EGA |- | 11 || class="f_vert" | Mémoire vidéo des cartes MDA ou CGA |- | 12 || class="f_vert" | ROM d'extension des périphériques (XT, EGA, 3270 PC) |- | 13 || class="f_gris" | Autre, non-réservé |- | 14 || class="f_gris" | Autre, non-réservé |- | 15 || class="f_jaune" | ROM du BIOS |} Vous remarquerez que les blocs 13 et 14 sont non-réservés. Ils peuvent être utilisés pour mapper des périphériques en mémoire. Mais si aucun périphérique n'est mappé dedans, les blocs sont laissés libres. Les OS n'hésitaient pas à les utiliser pour adresser de la RAM. Par exemple, si l'ordinateur n'a pas de carte vidéo EGA, le bloc qui leur était réservé était réutilisé comme mémoire RAM. La mémoire conventionnelle passait alors de 640 à 704 kibioctets de RAM disponibles. L'espace d'adressage correspond à la ROM du BIOS. Mais avec le temps, la ROM est devenue plus lente au point qu'exécuter du code directement depuis la ROM du BIOS était un problème niveau performance. Je rappelle que le BIOS contient de nombreuses routines d'interruptions qui étaient appelées par les systèmes d'exploitation de l'époque, notamment le DOS. Heureusement, diverses optimisations permettaient de copier le BIOS dans la mémoire RAM, plus rapide que la ROM du BIOS. Il s'agit des techniques de '''''BIOS Shadowing'''''. Elles s'appliquaient au BIOS proprement dit, mais le BIOS vidéo pouvait aussi être optimisé ainsi, mais les ROM d'extension ne le sont généralement pas. Le ''BIOS Shadowing'' peut s'implémenter de plusieurs manières, mais la plus courante mappait temporairement de la RAM dans le même espace d'adressage que la ROM du BIOS. Le code du BIOS copiait le BIOS avec une boucle, dont chaque itération lisait une adresse du BIOS et écrivait dans cette même adresse. Juste avant la boucle, le ''chipset'' était configuré par le BIOS pour que les lectures atterrissent dans la ROM du BIOS, mais que les écritures tombaient dans une mémoire RAM mappée. Une fois la copie/boucle terminée, l'adressage tombait dans la RAM aussi bien pour les lectures et écritures. Elle demandait un support du ''chipset'' et du logiciel, pour détourner les adresses temporairement. ===Interlude : les technologies d'expanded memory=== Déjà à l'époque, 1 mébioctet était une limite assez faible, même si cela n'a pas empêché à un grand dirigeant de l'industrie informatique de dire que "640K ought to be enough for anybody". Aussi, diverses solutions matérielles ont vu le jour. Elles étaient peu utilisées et ont concrètement servi à des applications spécifiques. La principale est l''''''expanded memory''''' (EMS), une solution utilisant une carte d'extension avec plusieurs modules de RAM installés dessus. Les modules étaient accédés via la technique de la commutation de banque. [[File:Expanded memory.svg|centre|vignette|upright=2|L'espace d'adressage avec ''Expanded memory''. La carte d'extension et le matériel requis ne sont pas représentés.]] Le terme ''expanded memory'' regroupe plusieurs technologies propriétaires semblables sur le principe mais différentes dans l'exécution. Les premières utilisaient des banques de 64 kibioctets, qui étaient mappées dans un bloc de 64 kibioctets libre en mémoire haute. L'usage de banques de 64 kibioctets collait bien avec l'adressage particulier du mode réel, où la mémoire était gérée par blocs de 64 kibioctets. Les technologies ultérieures utilisaient 2 à 4 pages de 16 kibioctets, qui pouvaient être placée n'importe où en mémoire conventionnelle. [[File:EmulexPersyst 4M ISA.jpeg|centre|vignette|upright=2|Carte d'extension ''Expanded memory'' de marque Emulex Persyst, de 4 mébioctets.]] L'''expanded memory'' demandait que les programmes soient codés pour utiliser cette fonctionnalité. Pour cela, ils devaient utiliser une technique de programmation nommée ''Overlay programming''. L'idée est de découper le programme en plusieurs blocs séparés, appelés des '''''overlays'''''. Certains blocs sont en permanence en RAM, mais d'autres sont soit chargés en RAM, soit stockés ailleurs. Le ailleurs est souvent le disque dur, ou dans la RAM ''expanded memory'' si elle est disponible. Le va-et-vient des ''overlays'' entre RAM et disque dur était réalisé en logiciel, par le programme lui-même. [[File:Overlay Programming.svg|centre|vignette|upright=1.5|''Overlay Programming''.]] L'utilisation de l'''expanded memory'' utilisait l'interruption logicielle 67h, une interruption peu utilisée à l'époque, qui était libre. La carte d'extension était utilisée à travers un pilote de périphérique dédié, qui était appelé via l'interruption 67h. ===Le mode protégé : l'adressage sur 24 et 32 bits=== Par la suite, le mode réel a été remplacé par le '''mode protégé'''. Il utilise la mémoire virtuelle, ce qui fait qu'on devrait le détailler dans le chapitre suivant. Il permettait aussi de dépasser la limite du mébioctet de mémoire. L'espace d'adressage en mode protégé est passé de 24 bits sur le CPU 286, à 32 bits sur le 386 et les CPU suivants. Les programmes conçus pour le mode réel ne pouvaient pas s'exécuter en mode protégé. Pour corriger cela, le 286 a introduit le '''mode virtuel 8086''', aussi appelé mode V86. Il s'agit d'une technique de virtualisation, ce qui fait qu'on en parlera en détail dans le chapitre sur la virtualisation assistée en matériel. Tout ce que l'on peut dire ici est que le mode V86 a été détourné pour que les applications DOS utilisent la mémoire au-delà du mébioctet, appelée la '''mémoire étendue'''. Les logiciels DOS accédaient à la mémoire étendue à travers un driver DOS dédié (HIMEM.SYS). Les logiciels DOS déplaçaient des données dans la mémoire étendue, puis les rapatriaient en mémoire conventionnelle quand ils en avaient besoin. Il ne faut pas confondre mémoire étendue et ''expanded memory''. La technique de ''expanded memory'' repose sur de la commutation de banque, demande une carte d'extension dédiée, fonctionne sans mode V86. A l'inverse, la mémoire étendue ne gère pas de commutation de banque, ne demande pas de carte d'extension, mais utilise le mode V86. Par contre, il est possible d'émuler l'''expanded memory'' sans carte d'extension, avec la mémoire étendue. Quelques cartes mères intégraient des techniques pour, mais une émulation logicielle était aussi possible en réécrivant l'interruption 67h. ===Le mode long : l'adressage 64 bits=== Par la suite, les processeurs ont introduit le '''mode long''', dans lequel les adresses font 64 bits. Le CPU en mode long a aussi un jeu d'instruction totalement différent, des registres en plus et d'autres fonctionnalités actives uniquement dans ce mode. Les programmes compilés pour le 64 bits s'exécutent nativement dans ce mode, alors que les programmes 32 et 16 bits s'exécutent dans un mode de compatibilité dédié. Sur les systèmes 64 bits, le bus d'adresse fait seulement 48 bits afin d'économiser des interconnexions. Avec 48 bits, il manque 64 - 48 = 16 bits d'adresses, qui ne peuvent pas être utilisés pour adresser quoi que ce soit. La règle est que ces 16 bits doivent être tous égaux : soit ils valent tous 0, soient ils sont tous à 1. Les adresses qui respectent cette contrainte sont appelées des '''adresses canoniques'''. Le résultat est que l'espace d'adressage est coupé en trois sections, comme illustré ci-dessous. * Les '''adresses canoniques basses''' ont leurs 16 bits de poids fort à 0 et sont situées en bas de l'espace d'adressage, dans les premières adresses. * Les '''adresses canoniques hautes''' ont leurs 16 bits de poids fort à 1 et sont situées au sommet de l'espace d'adressage, dans les dernières adresses. * Entre les deux, se trouvent des '''adresses non-canoniques''', qui ne sont pas accessibles. Y accéder déclenche la levée d'une exception matérielle. {| |[[File:AMD64-canonical--48-bit.png|vignette|Espace d'adressage x86-64 bits, avec des adresses physiques de 48 bits.]] |[[File:AMD64-canonical--56-bit.png|vignette|Espace d'adressage x86-64 bits, avec des adresses physiques de 57 bits.]] |} : Les futurs systèmes x86 devraient passer à des adresses de 57 bits, les trois sections auront donc des frontières différentes. Les adresses non-canoniques sont censées être inutilisables. Mais les programmeurs aimeraient bien pouvoir les utiliser pour des ''pointeurs tagués'', à savoir des pointeurs/adresses associées à des informations. L'idée serait d'utiliser les 16 bits de poids fort pour stocker des informations liées au pointeur, seuls les 48 bits restant codant l'adresse. Les 16 bits peuvent être utilisés de manières très diverses. Un exemple est la technique du '''''memory tagging''''', qui crée une somme de contrôle au pointeur. La somme de contrôle est générée par un algorithme cryptographique, à partir de l'adresse du pointeur. Elle est vérifiée à chaque utilisation du pointeur. Si la somme de contrôle ne correspond pas au pointeur, une erreur est levée. Si jamais un virus ou un code malveillant modifie un pointeur, il ne saura pas comment calculer la somme de contrôle, la somme de contrôle a énormément de chances d'être incorrecte. Quelques fonctionnalités des processeurs visent à autoriser l'utilisation des adresses non-canoniques. L'idée est que les 16 bits de poids fort sont ignorées lors des accès mémoire, ce qui permet d'utiliser les 16 bits de poids fort à volonté. Il s'agit des techniques ''Top Byte Ignore'' d'ARM, ''Upper Address Ignore'' d'AMD, et ''Linear Address Masking'' d'Intel. ==La z/architecture d'IBM== IBM a sa propre gamme de ''mainframes'', qui utilisent des processeurs fait maison par IBM lui-même. Les CPU IBM ont leur propre jeu d'instruction CISC, appelé '''''z/architecture'''''. Et ce jeu d'instruction a des possibilités en termes d'adressage qui sont extrêmement intéressantes, en plus d'être assez inédites. Pour ce chapitre, nous allons nous concentrer sur une fonctionnalité : les programmes ont accès à plusieurs espaces d'adressage naturellement. L'avantage pour les programmes utilisateur n'est pas très évident, mais il l'est nettement plus pour les systèmes d'exploitation. Le système d'exploitation de ces ''mainframes'', le z/OS, profite de cette possibilité, idem pour l'hyperviseur z/VM. Il utilise cette possibilité pour donner un espace d'adressage séparé pour chaque programme en cours d'exécution. Les programmes sont alors isolés les uns des autres. Cette '''isolation des processus''' n'est pas spécifique à IBM : tous les systèmes d'exploitation modernes l'utilisent. Mais les processeurs x86 doivent se débrouiller seulement avec des techniques dites de mémoire virtuelle, alors que les ''mainframes'' IBM complètent la mémoire virtuelle avec des facilités en plus. Pour les programmes utilisateurs, un intérêt est que cela permet de partager des données entre plusieurs programmes. Ils ont un espace d'adressage rien qu'à eux, mais les données partagées sont placées dans un espace d'adressage commun, auquel les deux programmes ont accès. L'espace d'adressage partagé peu même être configuré de manière à ne contenir que des données, il est impossible d'exécuter du code depuis cet espace d'adressage. Ce qui empêche certains ''malwares'' de fonctionner. ===Les espaces d'adressage par défaut de la ''z/architecture''=== La ''z/architecture'' supporte 65536 espaces d'adressage simultanés. Les espaces d'adressage sont numérotés, ils ont chacun un identifiant codé sur 16 bits, appelé un '''''Address-Space Number'''''. Nous utiliserons l’abréviation ASN dans ce qui suit. Par contre, un programme ne peut pas utiliser tout ces espaces d'adressages, il ne peut en utiliser que 16 en même temps, maximum. De plus, ils ne sont accessibles que quand le processeur est dans un mode de fonctionnement bien précis. Pour utiliser un espace d'adressage, un programme doit connaitre l'ASN associé. Par défaut, un programme exécuté sur la ''z/architecture'' utilise deux espaces d'adressages : l''''espace d'adressage primaire''' où se situent le programme exécuté et les données importantes, un '''espace d'adressage secondaire''' utilisé pour des tâches/données annexes. L'ASN de ces deux espaces d'adressage est mémorisé dans les registres de contrôle du processeur. L'ASN de l'espace primaire est dans le registre de contrôle n°3, l'ASN de l'espace secondaire est dans le registre de contrôle n°4 (ils sont dans les 16 bits de poids faible, dans les deux cas). Les deux registres de controle sont initialisés au démarrage de l'application, par le système d'exploitation, ce qui fait que ce sont des espaces d'adressage par défaut. Le programme a donc accès à ces deux espaces d'adressage sans rien faire. : Ils ont aussi accès à un '''espace d'adressage ''home''''' unique qui est surtout utilisé par le système d'exploitation, qui a pour particularité de ne pas avoir d'ASN. Il n'est concrètement accesible qu'en mode superviseur, donc par le système d'exploitation. Nous n'en parlerons pas dans ce qui suit. Mais comment le processeur pour utiliser ces espaces d'adressages ? Par exemple, s'il veut faire une lecture, comment précise-t-il l'espace d'adressage courant ? La réponse est que le processeur peut être dans deux états : soit primaire, soit secondaire. Dans le premier état, toutes les lectures/écritures se font dans l'espace d'adressage primaire. Dans le second, elles se font toutes dans l'espace d'adressage secondaire. Il peut passer de l'un à l'autre en utilisant l'instruction ''Set Address Space Control''. ===Les registres d'accès de la ''z/architecture''=== Un programme peut donc passer de l'espace d'adressage primaire à secondaire (ou inversement) en utilisant l’instruction ''SET ADDRESS SPACE CONTROL''. Mais il peut en profiter pour passer dans un mode où il a accès à 16 espaces d'adressage différents, en même temps. Le mode en question est appelé le '''mode ''access register'''''. Le processeur a donc quatre modes de fonctionnement : primaire, secondaire, ''home'' et ''access register''. Qui dit 16 espaces d'adressage dit 16 ASNs à mémoriser quelque part. Pour cela, le processeur dispose de 16 '''registres d'accès''', chacun mémorisant un ASN. Un accès mémoire précise quel registre d'accès utiliser, ce qui lui permet de sélectionner l'espace d'adressage voulu. Pas besoin d'utiliser ''SET ADDRESS SPACE CONTROL'' pour passer de l'un à l'autre, le programme peut gérer 16 espaces d’adressage en même temps. Ce qui est une différence importante par rapport au mode de fonctionnement précédent. : Une précision importante est que le premier registre d'accès mémorise l'ASN de l'espace d'adressage primaire. De plus, les instructions sont chargées depuis l'espace d'adressage primaire. Les CPU ''z/architecture'' disposent de 16 registres généraux de 64 bits, de 16 registres flottants eux aussi de 64 bits, de 16 registres de contrôle eux aussi de 64 bits, et de '''registres d'accès''' qui gèrent les espaces d'adressage. Chaque registre entier est associé à un espace d'adressage particulier, qui est indiqué par son registre d'accès. Les registres d'accès sont utilisés lors des lectures/écritures indirectes. L'adresse est lue depuis un registre général, et son registre d'accès est consulté pour déterminer l'espace d'adressage. Une implémentation simple numéroterait les espaces d'adressage et mettrait les numéros dans les registres d'accès. Les registres d'accès seraient idéalement protégés en écriture et seulement accessibles par le système d'exploitation. Mais ce n'est pas ce qui est fait, pour des raisons de sécurité. Les registres d'accès mémorisent bien un identifiant, mais celui-ci ne sélectionne pas l'espace d'adressage directement. L'identifiant est envoyée à une table de correspondance matérielle, qui mémorise les correspondances entre un identifiant et un espace d'adressage. Il y a une table de correspondance par programme, ce qui fait qu'un identifiant ne correspondra pas au même espace d'adressage dans deux programmes différents. La table de correspondance mémorise aussi des droits d'accès, qui sont différents pour chaque programme. Par exemple, tel programme aura accès en lecture seule à tel espace d'adressage, alors qu'un autre programme y aura accès en lecture et écriture, etc. Un espace d'adressage peut ainsi être partagé entre deux programmes plus facilement, tout en ajoutant des garanties de sécurité. L'usage des registres d'accès est aussi plus flexible qu'on ne pourrait le croire. Le programme peut lire et écrire dans les registres d'accès, copier un registre d'accès dans un autre, etc. Par contre, la table de correspondance empêche à un programme d’accéder à un espace d'adressage arbitraire. Le système d'exploitation remplit la table de manière à n'autoriser l'accès qu'aux espaces d'adressage adéquats, il y enregistre seulement les correspondances utiles. Si un programme forge un identifiant arbitraire, et l'envoie à la table, celle-ci n'aura pas la correspondance associée et l'accès mémoire sera invalidé. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs 8 bits et moins | prevText=Les processeurs 8 bits et moins | next=L'abstraction mémoire et la mémoire virtuelle | nextText=L'abstraction mémoire et la mémoire virtuelle }} </noinclude> hsvt4cylpyqt2nprxnjb33fr14ukinb 774365 774364 2026-10-07T14:45:19Z Mewtow 31375 /* Les registres d'accès de la z/architecture */ 774365 wikitext text/x-wiki L''''espace d'adressage''' du processeur est l'ensemble des adresses utilisables par le processeur. Par exemple, un processeur 16 bits peut adresser 2^16 = 65536 adresses, l'ensemble de ces adresses forme son espace d'adressage. L'espace d'adressage n'est pas toujours égal à la mémoire réellement installée. S'il n'y a pas assez de RAM installée, des adresses seront inoccupées. De plus, une partie de l'espace d'adressage peut être détourné pour communiquer avec les périphériques, comme nous le verrons plus bas. Nous verrons aussi qu'un processeur peut avoir plusieurs espaces d'adressages séparés. ==L'adressage de la RAM et de la ROM== Avoir plusieurs espaces d'adressage spécialisés est quelque chose que nous avons déjà rencontré dans les chapitres précédents, mais sans le dire ouvertement. Aussi, nous allons faire quelques rappels sur les cas déjà rencontrés. En premier lieu, nous allons rappeler la différence entre architectures Von Neumann et Hardvard. La différence entre les deux tient dans l'adressage des mémoires RAM et ROM : est-ce qu'elles sont dans un seul espace d'adressage, ou dans des espaces d'adressage séparés. L''''architecture Von Neumann''' a un seul espace d'adressage, découpé entre la mémoire RAM d'un côté et la mémoire ROM de l'autre. Une adresse correspond soit à la mémoire RAM, soit à la mémoire ROM, mais pas aux deux. Typiquement, la mémoire ROM est placée dans les adresses hautes, les plus élevées, alors que la RAM est placée dans les adresses basses en commençant par l'adresse 0. C'est une convention qui n'est pas toujours respectée, aussi mieux vaut éviter de la tenir pour acquise. [[File:Vision de la mémoire par un processeur sur une architecture Von Neumann.png|centre|vignette|upright=2|Vision de la mémoire par un processeur sur une architecture Von Neumann.]] L''''architecture Harvard''' utilise des espaces d'adressage séparés pour la RAM et la ROM. Une même adresse peut correspondre soit à la ROM, soit à la RAM. Le processeur voit bien deux mémoires séparées, chacune dans son propre espace d'adressage. Les deux espaces d'adressage n'ont pas forcément la même taille. Il est possible d'avoir un plus gros espace d'adressage pour la RAM que pour la ROM. Cela implique que les adresses des instructions et des données soient de taille différentes. C'est peu pratique et c'est rarement implémenté, ce qui fait que le cas le plus courant est celui où les deux espaces d'adressages ont la même taille. [[File:Vision de la mémoire par un processeur sur une architecture Harvard.png|centre|vignette|upright=2|Vision de la mémoire par un processeur sur une architecture Harvard.]] Sur une '''architecture Harvard modifiée''', le programme est placé dans un espace d'adressage à part du reste. Le problème, c'est que cet espace d'adressage ne contient pas que le code machine à exécuter. Il contient aussi des constantes, à savoir des données qui gardent la même valeur lors de l'exécution du programme. Elles peuvent être lues, mais pas modifiées durant l'exécution du programme. L'accès à ces constantes demande d'aller lire celles-ci dans l'autre espace d'adressage, pour les copier dans les registres généraux. Le processeur doit pour cela avoir des instructions de lecture dédiées. En clair, il y a une instruction LOAD pour lire dans la mémoire ROM, une autre pour lire dans la RAM. [[File:Espaces d'adressage sur une archi harvard modifiée.png|centre|vignette|upright=3|Espaces d'adressage sur une archi harvard modifiée]] Une autre solution utilise des instructions de copie, qui copient les constantes en mémoire RAM, donc d'un espace d'adressage à l'autre. [[File:Organisation des espaces d'adressage sur une archi harvard modifiée.png|centre|vignette|upright=3|Organisation des espaces d'adressage sur une archi harvard modifiée]] ==L'adressage des périphériques== Passons maintenant à l'adressage des périphériques. La communication avec les périphériques se fait par l'intermédiaire de registres d’interfaçage. Et ces registres peuvent soit avoir un espace d'adressage séparé, soit être inclus dans l'espace d'adressage des mémoires. Dans ce qui suit, nous allons supposer que l'architecture des de type Von Neumann pour simplifier les explications. ===Les entrées-sorties mappées en mémoire=== Une partie de l'espace d'adressage peut être détourné pour communiquer avec les périphériques. Le périphérique se retrouve inclus dans l'ensemble des adresses utilisées pour manipuler la mémoire : on dit qu'il est mappé en mémoire. Les adresses mémoires associées à un périphérique sont redirigées automatiquement vers les registres du périphérique en question, voire vers sa mémoire intégrée. On parle alors d''''entrées-sorties mappées en mémoire'''. [[File:IO mappées en mémoire.png|centre|vignette|upright=2.0|IO mappées en mémoire]] On remarque un défaut inhérent à cette technique : les adresses utilisées pour les périphériques ne sont plus disponibles pour la RAM. On ne peut plus adresser autant de RAM qu'avant. La perte dépend des périphériques installés et de leur gourmandise en adresses mémoires. C'est ce qui causait un problème assez connu sur les ordinateurs 32 bits, capables d'adresser 4 gibioctets de mémoire. Certaines personnes installaient 4 gigaoctets de mémoire sur leur ordinateur 32 bits et se retrouvaient avec « seulement » 3,5 à 3,8 gigaoctets de mémoire, les périphériques prenant le reste. Il est possible que la RAM d'un périphérique soit mappée en RAM. Un exemple classique est celui des cartes graphiques qui incorporent une mémoire RAM appelée la mémoire vidéo. La mémoire vidéo est mappée en mémoire, ce qui permet au processeur d'écrire directement dedans. Toute lecture ou écriture dans les adresses associées est redirigée vers le bus PCI/AGP/PCI-Express. Nous verrons plus bas des exemples d'ordinateurs où la mémoire vidéo est mappée en mémoire, que ce soit totalement ou partiellement. Si je dis totalement ou partiellement, c'est parce que les cartes graphiques modernes disposent de tellement de mémoire qu'on ne peut pas la mapper totalement dans l'espace d'adressage. Sur les systèmes d'avant 2008, seuls 256 mégaoctets de mémoire vidéo sont mappés en mémoire RAM. Le reste de la mémoire vidéo est invisible du point de vue du processeur, mais manipulable par le GPU à sa guise. Après 2008, la spécification du PCI-Express ajouta un support de la technologie ''Resizable Bar'', qui permet au processeur d’accéder directement à plus de 256 mégaoctets de mémoire vidéo, voire à la totalité de la mémoire vidéo. ===L'espace d'adressage séparé pour les entrées-sorties=== Les entrées-sorties et périphériques peuvent avoir leur propre espace d'adressage dédié, séparé de celui utilisé pour les mémoires RAM et ROM. [[File:Espaces_d'adressages_séparés_entre_mémoire_et_périphérique.png|centre|vignette|upright=3|Bit IO.]] Une même adresse peut donc adresser soit une entrée-sortie, soit une case mémoire. Et pour faire la différence, le processeur doit avoir des instructions séparées pour adresser les périphériques et la mémoire. Il a des instructions de lecture/écriture pour lire/écrire en mémoire, et d'autres pour lire/écrire les registres d’interfaçage. Sans cela, le processeur ne saurait pas si une adresse est destinée à un périphérique ou à la mémoire. Cela élimine aussi les problèmes avec les caches : les accès à l'espace d'adressage de la RAM passent par l'intermédiaire de la mémoire cache, alors les accès dans l'espace d'adressage des périphériques le contournent totalement. Là encore, les deux espaces d'adressage n'ont pas forcément la même taille. Il arrive que les deux espaces d'adressage aient la même taille, le plus souvent sur des ordinateurs complexes avec beaucoup de périphériques. Mais les systèmes embarqués ont souvent des espaces d'adressage plus petits pour les périphériques que pour la ou les mémoires. L'implémentation varie grandement suivant le cas, la première méthode imposant d'avoir deux bus séparés pour les mémoires et les périphériques, l'autre permettant un certain partage du bus d'adresse. Nous reviendrons dessus plus en détail dans le chapitre sur l'adressage des périphériques. ==La ''memory map'' d'un ordinateur== Les deux sections précédentes nous ont appris que l'on peut utiliser un espace d'adressage séparé pour la ROM et un autre pour les périphériques. En tout, cela donne quatre possibilités distinctes. {|class="wikitable" |- ! ! IO mappées en mémoire ! IO séparées |- ! ROM mappée en mémoire (architecture Von Neumann) | Un seul espace d'adressage | Deux espaces d'adressage : * un pour les mémoires RAM/ROM ; * un pour les IO. |- ! ROM séparée (architecture Harvard) | Deux espaces d'adressage : * un pour la RAM et les IO ; * un pour la ROM. | Trois espaces d'adressages : * un pour la RAM ; * un pour la ROM ; * un pour les IO. |} Les quatre solutions ont des avantages et inconvénients divers, mais il est intéressant de contraster un espace d'adressage unique avec plusieurs espaces d'adressages. ===L'espace d'adressage unique=== Avec un espace d'adressage unique, la ROM est au sommet de l'espace d'adressage, les périphériques sont juste en-dessous, la RAM commence à l'adresse 0 et prend les adresses basses. Faire simplifie grandement l'implémentation matérielle de l'adressage. Notons que d'autres composants que les périphériques ou les mémoires peuvent se trouver dans l'espace d'adressage. On peut y trouver les horloges temps réels, des timers, des senseurs de température, ou d'autres composants placés sur la carte mère. Un exemple un peu original est le suivant : la console de jeu Nintendo DS incorporait une unité de calcul spécialisée dans les divisions et racines carrées, séparée du processeur, qui était justement mappée en mémoire ! [[File:Espace d'adressage classique avec entrées-sorties mappées en mémoire.png|centre|vignette|upright=2|Espace d'adressage classique avec entrées-sorties mappées en mémoire]] L'espace d'adressage unique est plus simple pour les programmeurs, malgré un désavantage dont il faut parler. Le problème est que des adresses censées être disponibles pour la RAM sont détournées vers la ROM ou les périphériques, ce qui limite la quantité de RAM qui peut être réellement adressée en pratique. C'est un défaut qui se manifeste seulement pour les petits espaces d'adressages, de 8 à 16 bits, qui ne permettent pas d'adresser beaucoup de RAM. Avec des adresses codées sur 16 bits, un espace d'adressage de 64 kibioctets de RAM est grignoté par des entrées-sorties assez rapidement. Par contre, pour des adresses de 32 à 64 bits, une bonne partie de l'espace d'adressage est inutilisé, car il n'y a pas assez de RAM. L'espace d'adressage unique n'a donc pas de désavantages. Les vielles consoles de jeux et certains vieux ordinateurs (Commodores et Amiga), utilisaient un espace d'adressage unique. Elles n'avaient pas une variété de cartes graphiques ou de cartes sons différentes, comme sur les PCs modernes. Au contraire, elles avaient la même configuration matérielle, le matériel ne pouvait pas être changé ni upgradé. Le système d'exploitation était rudimentaire et ne contrôlait pas vraiment l'accès au matériel. Les programmeurs avaient donc totalement accès au matériel et mapper les entrées/sorties en mémoire rendait la programmation des périphériques très simple. <gallery widths=200px heights=500px> N5200mk2 memory map.svg|Adressage mémoire (carte mémoire) du N5200mk2. PC-9801VM memory map.svg|Adressage mémoire (carte mémoire) du PC-9801VM. </gallery> ===La commutation de banques (''bank switching'')=== Le '''''bank switching''''', aussi appelé '''commutation de banque''', permet d'utiliser plusieurs espaces d'adressage sur un même processeur, sans attribuer chaque espace d'adressage pour une raison précise. L'espace d'adressage est présent en plusieurs exemplaires appelés des '''banques'''. Les banques sont numérotées, chaque numéro de banque permettant de l'identifier et de le sélectionner. Le but de cette technique est d'augmenter la mémoire disponible pour l'ordinateur. Par exemple, supposons que j'ai besoin d'adresser une mémoire ROM de 4 kibioctets, une RAM de 8 kibioctets, et divers périphériques. Le processeur a un bus d'adresse de 12 bits, ce qui limite l'espace d'adressage à 4 kibioctets. Dans ce cas, je peux réserver 4 banques : une pour la ROM, une pour les périphériques, et deux banques qui contiennent chacune la moitié de la RAM. La simplicité et l'efficacité de cette technique font qu'elle est beaucoup utilisée dans l'informatique embarquée. [[File:PC-8801MemoryMap.gif|centre|vignette|upright=2|exemple de Bank switching.]] Cette technique demande d'utiliser un bus d'adresse plus grand que les adresses du processeur. L'adresse réelle se calcule en concaténant le numéro de banque avec l'adresse accédée. Le numéro de la banque actuellement en cours d'utilisation est mémorisé dans un registre appelé le '''registre de banque'''. On peut changer de banque en changeant le contenu de ce registre. Le processeur dispose souvent d'instructions spécialisées qui en sont capables. {| class="wikitable flexible" |[[File:Banque mémoire.png|Banque mémoire.]] |[[File:Registre de banque.png|Registre de banque.]] |} ==Le placement des programmes dans l'espace d'adressage== Les ordinateurs modernes sont des architectures Von Neumann, avec un seul espace d'adressage, qui est partagé suivant les besoins. Et un point important est que le système d'exploitation et les programmes sont copiés en mémoire RAM avant d'être exécutés. Ils ne sont pas exécutés depuis une mémoire ROM, cela est réservé aux systèmes embarqués simples. Et cela a une influence sur l'espace d'adressage. Les programmes et le système d'exploitation doivent se partager l'espace d'adressage. Voyons voir comment ce partage se fait. Dans ce chapitre, nous allons volontairement mettre de côté les ordinateurs qui supportent la mémoire virtuelle. Nous en parlerons dans le prochain chapitre. Le principe est de voir comment un processeur avec un seul espace d'adressage gèrent la présence d'un ou de plusieurs programmes. ===Un seul espace d'adressage, non-partagé=== Le cas le plus simple est celui où il n'y a pas de système d'exploitation et où un seul programme s'exécute sur l'ordinateur. Le programme a alors accès à tout l'espace d'adressage, sauf pour ce qui est réservé aux périphériques et à la mémoire ROM. Le programme organise la mémoire en plusieurs sections, dans lesquelles le programme range des données différentes. Typiquement, on trouve quatre blocs de mémoire, appelés des segments, chacun étant spécialisé pour les données, le code, la pile, etc. Voici ces trois sections : * Le segment '''''text''''' 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. * Le segment pour la '''pile''', de taille variable. * le reste est appelé le '''tas''', de taille variable. [[File:Organisation d'un espace d'adressage unique utilisé par un programme unique.png|centre|vignette|upright=2|Organisation d'un espace d'adressage unique utilisé par un programme unique]] [[File:Typical computer data memory arrangement.png|vignette|upright=0.5|Typical computer data memory arrangement]] La pile et le tas sont de taille variable, ce qui veut dire qu'ils peuvent grandir ou diminuer à volonté, contrairement au reste. Entre le tas et la pile, on trouve un espace de mémoire inutilisée, qui peut être réquisitionné selon les besoins. La pile commence généralement à l'adresse la plus haute et grandit en descendant, alors que le tas grandit en remontant vers les adresses hautes. Il s'agit là d'une convention, rien de plus. Il est possible d'inverser la pile et le tas sans problème, c'est juste que cette organisation est rentrée dans les usages. Avant de poursuivre, précisons qu'il est possible de regrouper plusieurs programmes distincts dans un seul, afin qu'ils partagent le même morceau de mémoire. Les programmes portent alors le nom de '''threads'''. Les ''threads'' d'un même processus partagent le même espace d'adressage. Ils partagent généralement certains segments : ils se partagent le code, le tas et les données statiques. Par contre, chaque thread dispose de sa propre pile d'appel. [[File:Single vs multithreaded processes.jpg|centre|vignette|upright=2.0|Distinction entre processus mono et multi-thread.]] Il va de soi que cette vision de l'espace d'adressage ne tient pas compte des périphériques. C'est-à-dire que les schémas précédents partent du principe qu'on a un espace d'adressage séparé pour les périphériques. Dans le cas où les entrées-sorties sont mappées en mémoire, l'organisation est plus compliquée. Généralement, les adresses associées aux périphériques sont placées juste au-dessus de la pile, dans les adresses hautes. ===Un seul espace d'adressage, partagé avec le système d'exploitation=== [[File:Gestion de la mémoire sur les OS monoprogrammés.png|vignette|upright=1.0|Gestion de la mémoire sur les OS monoprogrammés.]] Maintenant, étudions le cas où le programme partage la mémoire avec le système d'exploitation. Sur les systèmes d'exploitation les plus simples, on ne peut lancer qu'un seul programme à la fois. Le système d'exploitation réserve une portion de taille fixe réservée au système d'exploitation, alors que le reste de la mémoire est utilisé pour le programme à exécuter. Le programme est placé à un endroit en RAM qui est toujours le même. Le système d'exploitation peut être soit placé dans une ROM, soit copié en mémoire RAM depuis une mémoire de masse, comme le programme à lancer. Si le système d'exploitation est copié en mémoire RAM, il est généralement placé dans les premières adresses, les adresses basses. A l'inverse, un OS en mémoire ROM est généralement placé à la fin de la mémoire, dans les adresses hautes. Mais tout cela n'est qu'une convention, et les exceptions sont monnaie courante. [[File:Méthodes d'allocation de la mémoire avec un espace d'adressage unique.png|centre|vignette|upright=2.0|Méthodes d'allocation de la mémoire avec un espace d'adressage unique]] Sur de tels systèmes, il faut protéger l'OS contre une erreur ou malveillance d'un programme utilisateur. Notons qu'il s'agit d'une protection en écriture, pas en lecture. Le programme peut parfaitement lire les données de l'OS sans problèmes, au moins en partie. Dans ce qui suit, on part du principe que l'OS est dans les adresses basses. La solution la plus courante interdit au programme d'écrire au-delà d'une limite au-delà de laquelle se trouve le système d’exploitation. Pour cela, le processeur incorpore un '''registre limite''', qui contient l'adresse limite au-delà de laquelle un programme peut pas écrire. Pour toute écriture en espace utilisateur, l'adresse écrite est comparée au registre limite. Si l'adresse est inférieure au registre limite, le programme cherche écrire dans la mémoire réservée à l'OS. L’accès mémoire est interdit, une exception matérielle est levée et l'OS affiche un message d'erreur. [[File:Circuit total.png|centre|vignette|upright=2.0|Protection mémoire avec un registre limite.]] ===Un seul espace d'adressage, partagé entre plusieurs programmes=== Les systèmes d’exploitation modernes implémentent la '''multiprogrammation''', le fait de pouvoir lancer 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. Et il faut alors résoudre un problème : le partage de la RAM entre les différents programmes. Les différents programmes et leurs données sont placés en mémoire RAM, et il faut trouver un moyen pour répartir les différents programmes en RAM. [[File:Partitions mémoire.png|vignette|upright=1|Partitions mémoire]] Il y a plusieurs solutions pour cela, mais l'une d'entre elle est l'usage de segments mémoire. Avec elle, chaque programme se voit attribuer un ou plusieurs blocs de mémoire. Ils sont appelés des '''partitions mémoire''', ou encore des '''segments'''. Dans ce qui va suivre, nous partons du principe qu'un programme est égal à un segment, pour simplifier les explications. Mais sachez qu'un programme peut être éclaté en plusieurs segments dispersés dans la mémoire, et même être conçu pour ! Nous en reparlerons dans le chapitre sur le mémoire virtuelle. Le système d'exploitation mémorise une liste des segments importants en mémoire RAM, appelée la ''table des segments''. Il s'agit d'un tableau dont chaque entrée mémorise les informations pour un segment. Une entrée de ce tableau est appelée un '''descripteur de segment''' et contient son adresse de base, sa taille, et des autorisations liées à la protection mémoire. Précisément, l'entrée numéro N mémorise les informations du segment numéro N. A partir de l'adresse de base de la table des segments et du numéro de segment, on peut lire ou écrire un descripteur de segment. [[File:Global Descriptor table.png|centre|vignette|upright=2|Global Descriptor table]] Toutefois, l'usage de segments amène un paquet de problèmes qu'il faut résoudre au mieux. Le premier problème est tout simplement de placer les segments dans l'espace d'adressage, mais c'est quelque chose qui est du ressort du système d'exploitation. Par contre, cela implique qu'un segment peut être placé n'importe où en RAM et sa position en RAM change à chaque exécution. En conséquence, les adresses des branchements et des données ne sont jamais les mêmes d'une exécution à l'autre. Pour résoudre ce problème, le compilateur considère que le segment commence à l'adresse zéro, puis les adresses sont corrigées avant ou pendant l'exécution du programme. Cette correction est en général réalisée par l'OS lors de la copie du programme en mémoire, mais il existe aussi des techniques matérielles, que nous verrons dans le chapitre suivant. Il faut aussi prendre en compte le phénomène de l''''allocation mémoire'''. Derrière ce nom barbare, se cache quelque chose de simple : les programmes peuvent réclamer de la mémoire au système d'exploitation, pour y placer des données. Les langages de programmation bas niveau supportent des fonctions comme malloc(), qui permettent de demander un bloc de mémoire de N octets à l'OS, qui doit alors accommoder la demande. S'il n'y a pas assez de mémoire, l'appel échoue et le programme doit gérer la situation. De même, un programme peut libérer de la mémoire qu'il n'utilise plus avec des fonctions comme free(). Avec des segments, cela revient à changer la taille d'un segment, plus précisément à la fin du segment. Et tout cela est géré par le système d'exploitation, aussi on laisse cela pour le prochain chapitre. Enfin, sans protection particulière, les programmes peuvent techniquement lire ou écrire les données des autres. Pour éviter cela, il suffit de vérifier qu'un programme accède bien à son segment et pas ailleurs. Il suffit de vérifier si l'adresse lue/écrite est dans le bon intervalle. Il suffit alors d'utiliser deux registres limites, rien de plus. Mais au-delà de cette simple vérification, si un programme pouvait modifier les données d'un autre programme, on se retrouverait rapidement avec une situation non prévue par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses. Il faut donc introduire des mécanismes de protection mémoire, pour isoler les programmes les uns des autres, et éviter toute modification problématique. La protection mémoire regroupe plusieurs techniques assez variées, qui ont des objectifs différents. Elles ont pour point commun de faire intervenir à des niveaux divers le système d'exploitation et le processeur. ==La ''memory map'' des PC IBM x86== Dans ce qui suit, nous allons étudier l'espace d'adressage des processeurs x86. L'organisation de leur espace d'adressage a évolué dans le temps et l'espace d'adressage s'est adapté. Mais il s'est adapté sans modifier l'existant, pour des raisons de compatibilité. Aussi, les processeurs x86 modernes peuvent fonctionner dans plusieurs modes, appelés mode réel, protégé, virtuel 8086, long, ''system management mode'', et compatibilité 32 bits. [[File:AMD64StateDiagram.svg|centre|vignette|upright=2|AMD64StateDiagram]] Le ''system management mode'' a déjà été abordé dans le chapitre sur les interruptions. Pour simplifier, il ne s'agit pas d'un véritable mode au sens "adressage". Dans ce mode, le système d'exploitation est mis en pause et le BIOS ou un autre firmware s'exécute sans restriction. On y rentre grâce à une interruption dédiée, lancée par le noyau de l'OS. Le système d'exploitation exécute cette interruption afin de laisser la main au BIOS, pour lui déléguer certaines tâches. Par contre, les autres modes sont bien plus intéressant, car ils ont chacun un espace d'adressage différent. Voyons-les dans le détail. ===Le mode réel : l'adressage sur 20 bits=== Le tout premier mode était le '''mode réel''', qui utilisait des adresses de 20 bits, ce qui fait 1 mébioctet de mémoire adressable. Il était utilisé sur des processeurs 16 bits, qui géraient donc des adresses de 16 bits capables d'adresser 64 kibioctets de mémoire. Pour contourner cette limitation, le processeur utilisait la segmentation, que nous verrons dans le prochain chapitre. Pour le moment, nous allons dire que la mémoire en mode réel était composée de 16 blocs de 64 kibioctets. C'est une simplification qui ne fera pas de mal. Le processeur démarre systématiquement en mode réel, y compris sur les ordinateurs modernes. Le BIOS s'exécute dans ce mode, avant de passer en mode protégé et de laisser la main à l'UEFI et au système d'exploitation. Autrefois, le système d'exploitation s'exécutait aussi en mode réel, c'est le cas du DOS. En mode réel, l'espace d'adressage est peuplé par de la RAM, la ROM du BIOS et des périphériques. La RAM prenait les adresses basses, la ROM était au sommet de l'espace d'adressage, et les périphériques entre les deux. Les premiers 640 kibioctets sont réservés à la RAM et sont appelés la '''mémoire conventionnelle'''. Les octets restants forment la '''mémoire haute''', réservée aux ROM et aux périphériques. Les deux premiers kibioctets de la mémoire conventionnelle sont initialisés au démarrage de l'ordinateur. Ils sont utilisés pour stocker le vecteur d'interruption (on expliquera cela dans quelques chapitres) et servent aussi au BIOS. La portion réservée au BIOS, la ''BIOS Data Area'', mémorise des informations en RAM. Elle commence à l'adresse 0040:0000h, a une taille de 255 octets, et est initialisée lors du démarrage de l'ordinateur. Le reste de la mémoire conventionnelle est réservée au le système d'exploitation (MS-DOS, avant sa version 5.0) et au programme en cours d’exécution. La mémoire haute est sectionnée en deux portions. Le sommet de la mémoire haute est réservé au BIOS, alors que le bas de la mémoire haute est réservé aux périphériques. Dans cette dernière, on trouve les BIOS des périphériques (dont celui de la carte vidéo, s'il existe), qui sont nécessaires pour les initialiser et pour communiquer avec eux. De plus, on y trouve la mémoire de la carte vidéo et éventuellement la mémoire d'autres périphériques comme la carte son. {| class="wikitable" style="text-align:center;" |+ Espace d'adressage en mode réel |- ! Numéro du bloc !! Contenu du bloc |- | 1024 - 960 Kio || class="f_jaune" | BIOS principal (ROM) |- | 960 Kio - 640 Kio | class="f_vert" | ROM d'extension des périphériques (XT, EGA, 3270 PC) </br> Mémoire vidéo des cartes EGA, MDA ou CGA |- | 640 Kio - 0 || class="f_rouge" | Mémoire RAM, mémoire conventionnelle |} Pour détailler un peu plus, il faut tenir compte du découpage de l'espace d'adressage en blocs de 64 kibioctets. Le découpage en blocs de 64 kibioctets est particulièrement important pour la mémoire haute. Des blocs sont attribués à un périphérique spécifique. Par exemple, le 16ème bloc est réservé au BIOS, le 12ème aux ROM des périphériques, le 10ème et le 11ème à la mémoire vidéo, etc. {| class="wikitable" style="text-align:center;" |+ Espace d'adressage en mode réel |- ! Numéro du bloc !! Contenu du bloc |- | 0 || rowspan="10" class="f_rouge" | Mémoire RAM, mémoire conventionnelle |- | 1 |- | 2 |- | 3 |- | 4 |- | 5 |- | 6 |- | 7 |- | 8 |- | 9 |- | 10 || class="f_vert" | Mémoire vidéo des cartes EGA |- | 11 || class="f_vert" | Mémoire vidéo des cartes MDA ou CGA |- | 12 || class="f_vert" | ROM d'extension des périphériques (XT, EGA, 3270 PC) |- | 13 || class="f_gris" | Autre, non-réservé |- | 14 || class="f_gris" | Autre, non-réservé |- | 15 || class="f_jaune" | ROM du BIOS |} Vous remarquerez que les blocs 13 et 14 sont non-réservés. Ils peuvent être utilisés pour mapper des périphériques en mémoire. Mais si aucun périphérique n'est mappé dedans, les blocs sont laissés libres. Les OS n'hésitaient pas à les utiliser pour adresser de la RAM. Par exemple, si l'ordinateur n'a pas de carte vidéo EGA, le bloc qui leur était réservé était réutilisé comme mémoire RAM. La mémoire conventionnelle passait alors de 640 à 704 kibioctets de RAM disponibles. L'espace d'adressage correspond à la ROM du BIOS. Mais avec le temps, la ROM est devenue plus lente au point qu'exécuter du code directement depuis la ROM du BIOS était un problème niveau performance. Je rappelle que le BIOS contient de nombreuses routines d'interruptions qui étaient appelées par les systèmes d'exploitation de l'époque, notamment le DOS. Heureusement, diverses optimisations permettaient de copier le BIOS dans la mémoire RAM, plus rapide que la ROM du BIOS. Il s'agit des techniques de '''''BIOS Shadowing'''''. Elles s'appliquaient au BIOS proprement dit, mais le BIOS vidéo pouvait aussi être optimisé ainsi, mais les ROM d'extension ne le sont généralement pas. Le ''BIOS Shadowing'' peut s'implémenter de plusieurs manières, mais la plus courante mappait temporairement de la RAM dans le même espace d'adressage que la ROM du BIOS. Le code du BIOS copiait le BIOS avec une boucle, dont chaque itération lisait une adresse du BIOS et écrivait dans cette même adresse. Juste avant la boucle, le ''chipset'' était configuré par le BIOS pour que les lectures atterrissent dans la ROM du BIOS, mais que les écritures tombaient dans une mémoire RAM mappée. Une fois la copie/boucle terminée, l'adressage tombait dans la RAM aussi bien pour les lectures et écritures. Elle demandait un support du ''chipset'' et du logiciel, pour détourner les adresses temporairement. ===Interlude : les technologies d'expanded memory=== Déjà à l'époque, 1 mébioctet était une limite assez faible, même si cela n'a pas empêché à un grand dirigeant de l'industrie informatique de dire que "640K ought to be enough for anybody". Aussi, diverses solutions matérielles ont vu le jour. Elles étaient peu utilisées et ont concrètement servi à des applications spécifiques. La principale est l''''''expanded memory''''' (EMS), une solution utilisant une carte d'extension avec plusieurs modules de RAM installés dessus. Les modules étaient accédés via la technique de la commutation de banque. [[File:Expanded memory.svg|centre|vignette|upright=2|L'espace d'adressage avec ''Expanded memory''. La carte d'extension et le matériel requis ne sont pas représentés.]] Le terme ''expanded memory'' regroupe plusieurs technologies propriétaires semblables sur le principe mais différentes dans l'exécution. Les premières utilisaient des banques de 64 kibioctets, qui étaient mappées dans un bloc de 64 kibioctets libre en mémoire haute. L'usage de banques de 64 kibioctets collait bien avec l'adressage particulier du mode réel, où la mémoire était gérée par blocs de 64 kibioctets. Les technologies ultérieures utilisaient 2 à 4 pages de 16 kibioctets, qui pouvaient être placée n'importe où en mémoire conventionnelle. [[File:EmulexPersyst 4M ISA.jpeg|centre|vignette|upright=2|Carte d'extension ''Expanded memory'' de marque Emulex Persyst, de 4 mébioctets.]] L'''expanded memory'' demandait que les programmes soient codés pour utiliser cette fonctionnalité. Pour cela, ils devaient utiliser une technique de programmation nommée ''Overlay programming''. L'idée est de découper le programme en plusieurs blocs séparés, appelés des '''''overlays'''''. Certains blocs sont en permanence en RAM, mais d'autres sont soit chargés en RAM, soit stockés ailleurs. Le ailleurs est souvent le disque dur, ou dans la RAM ''expanded memory'' si elle est disponible. Le va-et-vient des ''overlays'' entre RAM et disque dur était réalisé en logiciel, par le programme lui-même. [[File:Overlay Programming.svg|centre|vignette|upright=1.5|''Overlay Programming''.]] L'utilisation de l'''expanded memory'' utilisait l'interruption logicielle 67h, une interruption peu utilisée à l'époque, qui était libre. La carte d'extension était utilisée à travers un pilote de périphérique dédié, qui était appelé via l'interruption 67h. ===Le mode protégé : l'adressage sur 24 et 32 bits=== Par la suite, le mode réel a été remplacé par le '''mode protégé'''. Il utilise la mémoire virtuelle, ce qui fait qu'on devrait le détailler dans le chapitre suivant. Il permettait aussi de dépasser la limite du mébioctet de mémoire. L'espace d'adressage en mode protégé est passé de 24 bits sur le CPU 286, à 32 bits sur le 386 et les CPU suivants. Les programmes conçus pour le mode réel ne pouvaient pas s'exécuter en mode protégé. Pour corriger cela, le 286 a introduit le '''mode virtuel 8086''', aussi appelé mode V86. Il s'agit d'une technique de virtualisation, ce qui fait qu'on en parlera en détail dans le chapitre sur la virtualisation assistée en matériel. Tout ce que l'on peut dire ici est que le mode V86 a été détourné pour que les applications DOS utilisent la mémoire au-delà du mébioctet, appelée la '''mémoire étendue'''. Les logiciels DOS accédaient à la mémoire étendue à travers un driver DOS dédié (HIMEM.SYS). Les logiciels DOS déplaçaient des données dans la mémoire étendue, puis les rapatriaient en mémoire conventionnelle quand ils en avaient besoin. Il ne faut pas confondre mémoire étendue et ''expanded memory''. La technique de ''expanded memory'' repose sur de la commutation de banque, demande une carte d'extension dédiée, fonctionne sans mode V86. A l'inverse, la mémoire étendue ne gère pas de commutation de banque, ne demande pas de carte d'extension, mais utilise le mode V86. Par contre, il est possible d'émuler l'''expanded memory'' sans carte d'extension, avec la mémoire étendue. Quelques cartes mères intégraient des techniques pour, mais une émulation logicielle était aussi possible en réécrivant l'interruption 67h. ===Le mode long : l'adressage 64 bits=== Par la suite, les processeurs ont introduit le '''mode long''', dans lequel les adresses font 64 bits. Le CPU en mode long a aussi un jeu d'instruction totalement différent, des registres en plus et d'autres fonctionnalités actives uniquement dans ce mode. Les programmes compilés pour le 64 bits s'exécutent nativement dans ce mode, alors que les programmes 32 et 16 bits s'exécutent dans un mode de compatibilité dédié. Sur les systèmes 64 bits, le bus d'adresse fait seulement 48 bits afin d'économiser des interconnexions. Avec 48 bits, il manque 64 - 48 = 16 bits d'adresses, qui ne peuvent pas être utilisés pour adresser quoi que ce soit. La règle est que ces 16 bits doivent être tous égaux : soit ils valent tous 0, soient ils sont tous à 1. Les adresses qui respectent cette contrainte sont appelées des '''adresses canoniques'''. Le résultat est que l'espace d'adressage est coupé en trois sections, comme illustré ci-dessous. * Les '''adresses canoniques basses''' ont leurs 16 bits de poids fort à 0 et sont situées en bas de l'espace d'adressage, dans les premières adresses. * Les '''adresses canoniques hautes''' ont leurs 16 bits de poids fort à 1 et sont situées au sommet de l'espace d'adressage, dans les dernières adresses. * Entre les deux, se trouvent des '''adresses non-canoniques''', qui ne sont pas accessibles. Y accéder déclenche la levée d'une exception matérielle. {| |[[File:AMD64-canonical--48-bit.png|vignette|Espace d'adressage x86-64 bits, avec des adresses physiques de 48 bits.]] |[[File:AMD64-canonical--56-bit.png|vignette|Espace d'adressage x86-64 bits, avec des adresses physiques de 57 bits.]] |} : Les futurs systèmes x86 devraient passer à des adresses de 57 bits, les trois sections auront donc des frontières différentes. Les adresses non-canoniques sont censées être inutilisables. Mais les programmeurs aimeraient bien pouvoir les utiliser pour des ''pointeurs tagués'', à savoir des pointeurs/adresses associées à des informations. L'idée serait d'utiliser les 16 bits de poids fort pour stocker des informations liées au pointeur, seuls les 48 bits restant codant l'adresse. Les 16 bits peuvent être utilisés de manières très diverses. Un exemple est la technique du '''''memory tagging''''', qui crée une somme de contrôle au pointeur. La somme de contrôle est générée par un algorithme cryptographique, à partir de l'adresse du pointeur. Elle est vérifiée à chaque utilisation du pointeur. Si la somme de contrôle ne correspond pas au pointeur, une erreur est levée. Si jamais un virus ou un code malveillant modifie un pointeur, il ne saura pas comment calculer la somme de contrôle, la somme de contrôle a énormément de chances d'être incorrecte. Quelques fonctionnalités des processeurs visent à autoriser l'utilisation des adresses non-canoniques. L'idée est que les 16 bits de poids fort sont ignorées lors des accès mémoire, ce qui permet d'utiliser les 16 bits de poids fort à volonté. Il s'agit des techniques ''Top Byte Ignore'' d'ARM, ''Upper Address Ignore'' d'AMD, et ''Linear Address Masking'' d'Intel. ==La z/architecture d'IBM== IBM a sa propre gamme de ''mainframes'', qui utilisent des processeurs fait maison par IBM lui-même. Les CPU IBM ont leur propre jeu d'instruction CISC, appelé '''''z/architecture'''''. Et ce jeu d'instruction a des possibilités en termes d'adressage qui sont extrêmement intéressantes, en plus d'être assez inédites. Pour ce chapitre, nous allons nous concentrer sur une fonctionnalité : les programmes ont accès à plusieurs espaces d'adressage naturellement. L'avantage pour les programmes utilisateur n'est pas très évident, mais il l'est nettement plus pour les systèmes d'exploitation. Le système d'exploitation de ces ''mainframes'', le z/OS, profite de cette possibilité, idem pour l'hyperviseur z/VM. Il utilise cette possibilité pour donner un espace d'adressage séparé pour chaque programme en cours d'exécution. Les programmes sont alors isolés les uns des autres. Cette '''isolation des processus''' n'est pas spécifique à IBM : tous les systèmes d'exploitation modernes l'utilisent. Mais les processeurs x86 doivent se débrouiller seulement avec des techniques dites de mémoire virtuelle, alors que les ''mainframes'' IBM complètent la mémoire virtuelle avec des facilités en plus. Pour les programmes utilisateurs, un intérêt est que cela permet de partager des données entre plusieurs programmes. Ils ont un espace d'adressage rien qu'à eux, mais les données partagées sont placées dans un espace d'adressage commun, auquel les deux programmes ont accès. L'espace d'adressage partagé peu même être configuré de manière à ne contenir que des données, il est impossible d'exécuter du code depuis cet espace d'adressage. Ce qui empêche certains ''malwares'' de fonctionner. ===Les espaces d'adressage par défaut de la ''z/architecture''=== La ''z/architecture'' supporte 65536 espaces d'adressage simultanés. Les espaces d'adressage sont numérotés, ils ont chacun un identifiant codé sur 16 bits, appelé un '''''Address-Space Number'''''. Nous utiliserons l’abréviation ASN dans ce qui suit. Par contre, un programme ne peut pas utiliser tout ces espaces d'adressages, il ne peut en utiliser que 16 en même temps, maximum. De plus, ils ne sont accessibles que quand le processeur est dans un mode de fonctionnement bien précis. Pour utiliser un espace d'adressage, un programme doit connaitre l'ASN associé. Par défaut, un programme exécuté sur la ''z/architecture'' utilise deux espaces d'adressages : l''''espace d'adressage primaire''' où se situent le programme exécuté et les données importantes, un '''espace d'adressage secondaire''' utilisé pour des tâches/données annexes. L'ASN de ces deux espaces d'adressage est mémorisé dans les registres de contrôle du processeur. L'ASN de l'espace primaire est dans le registre de contrôle n°3, l'ASN de l'espace secondaire est dans le registre de contrôle n°4 (ils sont dans les 16 bits de poids faible, dans les deux cas). Les deux registres de controle sont initialisés au démarrage de l'application, par le système d'exploitation, ce qui fait que ce sont des espaces d'adressage par défaut. Le programme a donc accès à ces deux espaces d'adressage sans rien faire. : Ils ont aussi accès à un '''espace d'adressage ''home''''' unique qui est surtout utilisé par le système d'exploitation, qui a pour particularité de ne pas avoir d'ASN. Il n'est concrètement accesible qu'en mode superviseur, donc par le système d'exploitation. Nous n'en parlerons pas dans ce qui suit. Mais comment le processeur pour utiliser ces espaces d'adressages ? Par exemple, s'il veut faire une lecture, comment précise-t-il l'espace d'adressage courant ? La réponse est que le processeur peut être dans deux états : soit primaire, soit secondaire. Dans le premier état, toutes les lectures/écritures se font dans l'espace d'adressage primaire. Dans le second, elles se font toutes dans l'espace d'adressage secondaire. Il peut passer de l'un à l'autre en utilisant l'instruction ''Set Address Space Control''. ===Les registres d'accès de la ''z/architecture''=== Un programme peut donc passer de l'espace d'adressage primaire à secondaire (ou inversement) en utilisant l’instruction ''SET ADDRESS SPACE CONTROL''. Mais il peut en profiter pour passer dans un mode où il a accès à 16 espaces d'adressage différents, en même temps. Le mode en question est appelé le '''mode ''access register'''''. Le processeur a donc quatre modes de fonctionnement : primaire, secondaire, ''home'' et ''access register''. Qui dit 16 espaces d'adressage dit 16 ASNs à mémoriser quelque part. Pour cela, le processeur dispose de 16 '''registres d'accès''', chacun mémorisant un ASN. Un accès mémoire précise quel registre d'accès utiliser, ce qui lui permet de sélectionner l'espace d'adressage voulu. Pas besoin d'utiliser ''SET ADDRESS SPACE CONTROL'' pour passer de l'un à l'autre, le programme peut gérer 16 espaces d’adressage en même temps. Ce qui est une différence importante par rapport au mode de fonctionnement précédent. : Une précision importante est que le premier registre d'accès mémorise l'ASN de l'espace d'adressage primaire. De plus, les instructions sont chargées depuis l'espace d'adressage primaire. Les CPU ''z/architecture'' disposent de 16 registres généraux de 64 bits, de 16 registres flottants eux aussi de 64 bits, de 16 registres de contrôle eux aussi de 64 bits, et de '''registres d'accès''' qui gèrent les espaces d'adressage. Chaque registre entier est associé à un espace d'adressage particulier, qui est indiqué par son registre d'accès. Les registres d'accès sont utilisés lors des lectures/écritures indirectes. L'adresse est lue depuis un registre général, et son registre d'accès est consulté pour déterminer l'espace d'adressage. Une implémentation simple numéroterait les espaces d'adressage et mettrait les numéros dans les registres d'accès. Les registres d'accès seraient idéalement protégés en écriture et seulement accessibles par le système d'exploitation. Mais ce n'est pas ce qui est fait, pour des raisons de sécurité. Les registres d'accès mémorisent bien un identifiant, mais celui-ci ne sélectionne pas l'espace d'adressage directement. L'identifiant est envoyée à une table de correspondance matérielle, qui mémorise les correspondances entre un identifiant et un espace d'adressage. Il y a une table de correspondance par programme, ce qui fait qu'un identifiant ne correspondra pas au même espace d'adressage dans deux programmes différents. La table de correspondance mémorise aussi des droits d'accès, qui sont différents pour chaque programme. Par exemple, tel programme aura accès en lecture seule à tel espace d'adressage, alors qu'un autre programme y aura accès en lecture et écriture, etc. Un espace d'adressage peut ainsi être partagé entre deux programmes plus facilement, tout en ajoutant des garanties de sécurité. L'usage des registres d'accès est aussi plus flexible qu'on ne pourrait le croire. Le programme peut lire et écrire dans les registres d'accès, copier un registre d'accès dans un autre, etc. Par contre, la table de correspondance empêche à un programme d’accéder à un espace d'adressage arbitraire. Le système d'exploitation remplit la table de manière à n'autoriser l'accès qu'aux espaces d'adressage adéquats, il y enregistre seulement les correspondances utiles. Si un programme forge un identifiant arbitraire, et l'envoie à la table de correspondance, celle-ci n'aura pas la correspondance associée et l'accès mémoire sera invalidé. La table de correspondance est en réalité une table à deux niveaux. Une première table reçoit l'ASN et fournit l'adresse de la seconde table. La seconde table récupère alors l'adresse. Vu que le processeur utilise la mémoire virtuelle, c'est une adresse dite virtuelle, mais laissons cela pour le prochain chapitre. La première table est appelée l'''''access list''''', alors que la seconde est appelée l''''''ASN-Second-Table-Entry Sequence Number'''''. Les deux tables sont accessibles en écriture via une instruction ''LOAD ADDRESS SPACE PARAMETERS'', seulement accesible en mode noyau. : Il faut noter que si l'''access list'' a une taille limitée à 64 kibioctets, la seconde n'est pas limitée, ce qui permet de dépasser la limite des 655536 ASNs. La limite de 65535 ASNs est uen limite par programme, pas totale. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs 8 bits et moins | prevText=Les processeurs 8 bits et moins | next=L'abstraction mémoire et la mémoire virtuelle | nextText=L'abstraction mémoire et la mémoire virtuelle }} </noinclude> n712ufpkrfglw0lsp3mnr1pwwhu3ucg 774366 774365 2026-10-07T14:48:47Z Mewtow 31375 /* Les registres d'accès de la z/architecture */ 774366 wikitext text/x-wiki L''''espace d'adressage''' du processeur est l'ensemble des adresses utilisables par le processeur. Par exemple, un processeur 16 bits peut adresser 2^16 = 65536 adresses, l'ensemble de ces adresses forme son espace d'adressage. L'espace d'adressage n'est pas toujours égal à la mémoire réellement installée. S'il n'y a pas assez de RAM installée, des adresses seront inoccupées. De plus, une partie de l'espace d'adressage peut être détourné pour communiquer avec les périphériques, comme nous le verrons plus bas. Nous verrons aussi qu'un processeur peut avoir plusieurs espaces d'adressages séparés. ==L'adressage de la RAM et de la ROM== Avoir plusieurs espaces d'adressage spécialisés est quelque chose que nous avons déjà rencontré dans les chapitres précédents, mais sans le dire ouvertement. Aussi, nous allons faire quelques rappels sur les cas déjà rencontrés. En premier lieu, nous allons rappeler la différence entre architectures Von Neumann et Hardvard. La différence entre les deux tient dans l'adressage des mémoires RAM et ROM : est-ce qu'elles sont dans un seul espace d'adressage, ou dans des espaces d'adressage séparés. L''''architecture Von Neumann''' a un seul espace d'adressage, découpé entre la mémoire RAM d'un côté et la mémoire ROM de l'autre. Une adresse correspond soit à la mémoire RAM, soit à la mémoire ROM, mais pas aux deux. Typiquement, la mémoire ROM est placée dans les adresses hautes, les plus élevées, alors que la RAM est placée dans les adresses basses en commençant par l'adresse 0. C'est une convention qui n'est pas toujours respectée, aussi mieux vaut éviter de la tenir pour acquise. [[File:Vision de la mémoire par un processeur sur une architecture Von Neumann.png|centre|vignette|upright=2|Vision de la mémoire par un processeur sur une architecture Von Neumann.]] L''''architecture Harvard''' utilise des espaces d'adressage séparés pour la RAM et la ROM. Une même adresse peut correspondre soit à la ROM, soit à la RAM. Le processeur voit bien deux mémoires séparées, chacune dans son propre espace d'adressage. Les deux espaces d'adressage n'ont pas forcément la même taille. Il est possible d'avoir un plus gros espace d'adressage pour la RAM que pour la ROM. Cela implique que les adresses des instructions et des données soient de taille différentes. C'est peu pratique et c'est rarement implémenté, ce qui fait que le cas le plus courant est celui où les deux espaces d'adressages ont la même taille. [[File:Vision de la mémoire par un processeur sur une architecture Harvard.png|centre|vignette|upright=2|Vision de la mémoire par un processeur sur une architecture Harvard.]] Sur une '''architecture Harvard modifiée''', le programme est placé dans un espace d'adressage à part du reste. Le problème, c'est que cet espace d'adressage ne contient pas que le code machine à exécuter. Il contient aussi des constantes, à savoir des données qui gardent la même valeur lors de l'exécution du programme. Elles peuvent être lues, mais pas modifiées durant l'exécution du programme. L'accès à ces constantes demande d'aller lire celles-ci dans l'autre espace d'adressage, pour les copier dans les registres généraux. Le processeur doit pour cela avoir des instructions de lecture dédiées. En clair, il y a une instruction LOAD pour lire dans la mémoire ROM, une autre pour lire dans la RAM. [[File:Espaces d'adressage sur une archi harvard modifiée.png|centre|vignette|upright=3|Espaces d'adressage sur une archi harvard modifiée]] Une autre solution utilise des instructions de copie, qui copient les constantes en mémoire RAM, donc d'un espace d'adressage à l'autre. [[File:Organisation des espaces d'adressage sur une archi harvard modifiée.png|centre|vignette|upright=3|Organisation des espaces d'adressage sur une archi harvard modifiée]] ==L'adressage des périphériques== Passons maintenant à l'adressage des périphériques. La communication avec les périphériques se fait par l'intermédiaire de registres d’interfaçage. Et ces registres peuvent soit avoir un espace d'adressage séparé, soit être inclus dans l'espace d'adressage des mémoires. Dans ce qui suit, nous allons supposer que l'architecture des de type Von Neumann pour simplifier les explications. ===Les entrées-sorties mappées en mémoire=== Une partie de l'espace d'adressage peut être détourné pour communiquer avec les périphériques. Le périphérique se retrouve inclus dans l'ensemble des adresses utilisées pour manipuler la mémoire : on dit qu'il est mappé en mémoire. Les adresses mémoires associées à un périphérique sont redirigées automatiquement vers les registres du périphérique en question, voire vers sa mémoire intégrée. On parle alors d''''entrées-sorties mappées en mémoire'''. [[File:IO mappées en mémoire.png|centre|vignette|upright=2.0|IO mappées en mémoire]] On remarque un défaut inhérent à cette technique : les adresses utilisées pour les périphériques ne sont plus disponibles pour la RAM. On ne peut plus adresser autant de RAM qu'avant. La perte dépend des périphériques installés et de leur gourmandise en adresses mémoires. C'est ce qui causait un problème assez connu sur les ordinateurs 32 bits, capables d'adresser 4 gibioctets de mémoire. Certaines personnes installaient 4 gigaoctets de mémoire sur leur ordinateur 32 bits et se retrouvaient avec « seulement » 3,5 à 3,8 gigaoctets de mémoire, les périphériques prenant le reste. Il est possible que la RAM d'un périphérique soit mappée en RAM. Un exemple classique est celui des cartes graphiques qui incorporent une mémoire RAM appelée la mémoire vidéo. La mémoire vidéo est mappée en mémoire, ce qui permet au processeur d'écrire directement dedans. Toute lecture ou écriture dans les adresses associées est redirigée vers le bus PCI/AGP/PCI-Express. Nous verrons plus bas des exemples d'ordinateurs où la mémoire vidéo est mappée en mémoire, que ce soit totalement ou partiellement. Si je dis totalement ou partiellement, c'est parce que les cartes graphiques modernes disposent de tellement de mémoire qu'on ne peut pas la mapper totalement dans l'espace d'adressage. Sur les systèmes d'avant 2008, seuls 256 mégaoctets de mémoire vidéo sont mappés en mémoire RAM. Le reste de la mémoire vidéo est invisible du point de vue du processeur, mais manipulable par le GPU à sa guise. Après 2008, la spécification du PCI-Express ajouta un support de la technologie ''Resizable Bar'', qui permet au processeur d’accéder directement à plus de 256 mégaoctets de mémoire vidéo, voire à la totalité de la mémoire vidéo. ===L'espace d'adressage séparé pour les entrées-sorties=== Les entrées-sorties et périphériques peuvent avoir leur propre espace d'adressage dédié, séparé de celui utilisé pour les mémoires RAM et ROM. [[File:Espaces_d'adressages_séparés_entre_mémoire_et_périphérique.png|centre|vignette|upright=3|Bit IO.]] Une même adresse peut donc adresser soit une entrée-sortie, soit une case mémoire. Et pour faire la différence, le processeur doit avoir des instructions séparées pour adresser les périphériques et la mémoire. Il a des instructions de lecture/écriture pour lire/écrire en mémoire, et d'autres pour lire/écrire les registres d’interfaçage. Sans cela, le processeur ne saurait pas si une adresse est destinée à un périphérique ou à la mémoire. Cela élimine aussi les problèmes avec les caches : les accès à l'espace d'adressage de la RAM passent par l'intermédiaire de la mémoire cache, alors les accès dans l'espace d'adressage des périphériques le contournent totalement. Là encore, les deux espaces d'adressage n'ont pas forcément la même taille. Il arrive que les deux espaces d'adressage aient la même taille, le plus souvent sur des ordinateurs complexes avec beaucoup de périphériques. Mais les systèmes embarqués ont souvent des espaces d'adressage plus petits pour les périphériques que pour la ou les mémoires. L'implémentation varie grandement suivant le cas, la première méthode imposant d'avoir deux bus séparés pour les mémoires et les périphériques, l'autre permettant un certain partage du bus d'adresse. Nous reviendrons dessus plus en détail dans le chapitre sur l'adressage des périphériques. ==La ''memory map'' d'un ordinateur== Les deux sections précédentes nous ont appris que l'on peut utiliser un espace d'adressage séparé pour la ROM et un autre pour les périphériques. En tout, cela donne quatre possibilités distinctes. {|class="wikitable" |- ! ! IO mappées en mémoire ! IO séparées |- ! ROM mappée en mémoire (architecture Von Neumann) | Un seul espace d'adressage | Deux espaces d'adressage : * un pour les mémoires RAM/ROM ; * un pour les IO. |- ! ROM séparée (architecture Harvard) | Deux espaces d'adressage : * un pour la RAM et les IO ; * un pour la ROM. | Trois espaces d'adressages : * un pour la RAM ; * un pour la ROM ; * un pour les IO. |} Les quatre solutions ont des avantages et inconvénients divers, mais il est intéressant de contraster un espace d'adressage unique avec plusieurs espaces d'adressages. ===L'espace d'adressage unique=== Avec un espace d'adressage unique, la ROM est au sommet de l'espace d'adressage, les périphériques sont juste en-dessous, la RAM commence à l'adresse 0 et prend les adresses basses. Faire simplifie grandement l'implémentation matérielle de l'adressage. Notons que d'autres composants que les périphériques ou les mémoires peuvent se trouver dans l'espace d'adressage. On peut y trouver les horloges temps réels, des timers, des senseurs de température, ou d'autres composants placés sur la carte mère. Un exemple un peu original est le suivant : la console de jeu Nintendo DS incorporait une unité de calcul spécialisée dans les divisions et racines carrées, séparée du processeur, qui était justement mappée en mémoire ! [[File:Espace d'adressage classique avec entrées-sorties mappées en mémoire.png|centre|vignette|upright=2|Espace d'adressage classique avec entrées-sorties mappées en mémoire]] L'espace d'adressage unique est plus simple pour les programmeurs, malgré un désavantage dont il faut parler. Le problème est que des adresses censées être disponibles pour la RAM sont détournées vers la ROM ou les périphériques, ce qui limite la quantité de RAM qui peut être réellement adressée en pratique. C'est un défaut qui se manifeste seulement pour les petits espaces d'adressages, de 8 à 16 bits, qui ne permettent pas d'adresser beaucoup de RAM. Avec des adresses codées sur 16 bits, un espace d'adressage de 64 kibioctets de RAM est grignoté par des entrées-sorties assez rapidement. Par contre, pour des adresses de 32 à 64 bits, une bonne partie de l'espace d'adressage est inutilisé, car il n'y a pas assez de RAM. L'espace d'adressage unique n'a donc pas de désavantages. Les vielles consoles de jeux et certains vieux ordinateurs (Commodores et Amiga), utilisaient un espace d'adressage unique. Elles n'avaient pas une variété de cartes graphiques ou de cartes sons différentes, comme sur les PCs modernes. Au contraire, elles avaient la même configuration matérielle, le matériel ne pouvait pas être changé ni upgradé. Le système d'exploitation était rudimentaire et ne contrôlait pas vraiment l'accès au matériel. Les programmeurs avaient donc totalement accès au matériel et mapper les entrées/sorties en mémoire rendait la programmation des périphériques très simple. <gallery widths=200px heights=500px> N5200mk2 memory map.svg|Adressage mémoire (carte mémoire) du N5200mk2. PC-9801VM memory map.svg|Adressage mémoire (carte mémoire) du PC-9801VM. </gallery> ===La commutation de banques (''bank switching'')=== Le '''''bank switching''''', aussi appelé '''commutation de banque''', permet d'utiliser plusieurs espaces d'adressage sur un même processeur, sans attribuer chaque espace d'adressage pour une raison précise. L'espace d'adressage est présent en plusieurs exemplaires appelés des '''banques'''. Les banques sont numérotées, chaque numéro de banque permettant de l'identifier et de le sélectionner. Le but de cette technique est d'augmenter la mémoire disponible pour l'ordinateur. Par exemple, supposons que j'ai besoin d'adresser une mémoire ROM de 4 kibioctets, une RAM de 8 kibioctets, et divers périphériques. Le processeur a un bus d'adresse de 12 bits, ce qui limite l'espace d'adressage à 4 kibioctets. Dans ce cas, je peux réserver 4 banques : une pour la ROM, une pour les périphériques, et deux banques qui contiennent chacune la moitié de la RAM. La simplicité et l'efficacité de cette technique font qu'elle est beaucoup utilisée dans l'informatique embarquée. [[File:PC-8801MemoryMap.gif|centre|vignette|upright=2|exemple de Bank switching.]] Cette technique demande d'utiliser un bus d'adresse plus grand que les adresses du processeur. L'adresse réelle se calcule en concaténant le numéro de banque avec l'adresse accédée. Le numéro de la banque actuellement en cours d'utilisation est mémorisé dans un registre appelé le '''registre de banque'''. On peut changer de banque en changeant le contenu de ce registre. Le processeur dispose souvent d'instructions spécialisées qui en sont capables. {| class="wikitable flexible" |[[File:Banque mémoire.png|Banque mémoire.]] |[[File:Registre de banque.png|Registre de banque.]] |} ==Le placement des programmes dans l'espace d'adressage== Les ordinateurs modernes sont des architectures Von Neumann, avec un seul espace d'adressage, qui est partagé suivant les besoins. Et un point important est que le système d'exploitation et les programmes sont copiés en mémoire RAM avant d'être exécutés. Ils ne sont pas exécutés depuis une mémoire ROM, cela est réservé aux systèmes embarqués simples. Et cela a une influence sur l'espace d'adressage. Les programmes et le système d'exploitation doivent se partager l'espace d'adressage. Voyons voir comment ce partage se fait. Dans ce chapitre, nous allons volontairement mettre de côté les ordinateurs qui supportent la mémoire virtuelle. Nous en parlerons dans le prochain chapitre. Le principe est de voir comment un processeur avec un seul espace d'adressage gèrent la présence d'un ou de plusieurs programmes. ===Un seul espace d'adressage, non-partagé=== Le cas le plus simple est celui où il n'y a pas de système d'exploitation et où un seul programme s'exécute sur l'ordinateur. Le programme a alors accès à tout l'espace d'adressage, sauf pour ce qui est réservé aux périphériques et à la mémoire ROM. Le programme organise la mémoire en plusieurs sections, dans lesquelles le programme range des données différentes. Typiquement, on trouve quatre blocs de mémoire, appelés des segments, chacun étant spécialisé pour les données, le code, la pile, etc. Voici ces trois sections : * Le segment '''''text''''' 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. * Le segment pour la '''pile''', de taille variable. * le reste est appelé le '''tas''', de taille variable. [[File:Organisation d'un espace d'adressage unique utilisé par un programme unique.png|centre|vignette|upright=2|Organisation d'un espace d'adressage unique utilisé par un programme unique]] [[File:Typical computer data memory arrangement.png|vignette|upright=0.5|Typical computer data memory arrangement]] La pile et le tas sont de taille variable, ce qui veut dire qu'ils peuvent grandir ou diminuer à volonté, contrairement au reste. Entre le tas et la pile, on trouve un espace de mémoire inutilisée, qui peut être réquisitionné selon les besoins. La pile commence généralement à l'adresse la plus haute et grandit en descendant, alors que le tas grandit en remontant vers les adresses hautes. Il s'agit là d'une convention, rien de plus. Il est possible d'inverser la pile et le tas sans problème, c'est juste que cette organisation est rentrée dans les usages. Avant de poursuivre, précisons qu'il est possible de regrouper plusieurs programmes distincts dans un seul, afin qu'ils partagent le même morceau de mémoire. Les programmes portent alors le nom de '''threads'''. Les ''threads'' d'un même processus partagent le même espace d'adressage. Ils partagent généralement certains segments : ils se partagent le code, le tas et les données statiques. Par contre, chaque thread dispose de sa propre pile d'appel. [[File:Single vs multithreaded processes.jpg|centre|vignette|upright=2.0|Distinction entre processus mono et multi-thread.]] Il va de soi que cette vision de l'espace d'adressage ne tient pas compte des périphériques. C'est-à-dire que les schémas précédents partent du principe qu'on a un espace d'adressage séparé pour les périphériques. Dans le cas où les entrées-sorties sont mappées en mémoire, l'organisation est plus compliquée. Généralement, les adresses associées aux périphériques sont placées juste au-dessus de la pile, dans les adresses hautes. ===Un seul espace d'adressage, partagé avec le système d'exploitation=== [[File:Gestion de la mémoire sur les OS monoprogrammés.png|vignette|upright=1.0|Gestion de la mémoire sur les OS monoprogrammés.]] Maintenant, étudions le cas où le programme partage la mémoire avec le système d'exploitation. Sur les systèmes d'exploitation les plus simples, on ne peut lancer qu'un seul programme à la fois. Le système d'exploitation réserve une portion de taille fixe réservée au système d'exploitation, alors que le reste de la mémoire est utilisé pour le programme à exécuter. Le programme est placé à un endroit en RAM qui est toujours le même. Le système d'exploitation peut être soit placé dans une ROM, soit copié en mémoire RAM depuis une mémoire de masse, comme le programme à lancer. Si le système d'exploitation est copié en mémoire RAM, il est généralement placé dans les premières adresses, les adresses basses. A l'inverse, un OS en mémoire ROM est généralement placé à la fin de la mémoire, dans les adresses hautes. Mais tout cela n'est qu'une convention, et les exceptions sont monnaie courante. [[File:Méthodes d'allocation de la mémoire avec un espace d'adressage unique.png|centre|vignette|upright=2.0|Méthodes d'allocation de la mémoire avec un espace d'adressage unique]] Sur de tels systèmes, il faut protéger l'OS contre une erreur ou malveillance d'un programme utilisateur. Notons qu'il s'agit d'une protection en écriture, pas en lecture. Le programme peut parfaitement lire les données de l'OS sans problèmes, au moins en partie. Dans ce qui suit, on part du principe que l'OS est dans les adresses basses. La solution la plus courante interdit au programme d'écrire au-delà d'une limite au-delà de laquelle se trouve le système d’exploitation. Pour cela, le processeur incorpore un '''registre limite''', qui contient l'adresse limite au-delà de laquelle un programme peut pas écrire. Pour toute écriture en espace utilisateur, l'adresse écrite est comparée au registre limite. Si l'adresse est inférieure au registre limite, le programme cherche écrire dans la mémoire réservée à l'OS. L’accès mémoire est interdit, une exception matérielle est levée et l'OS affiche un message d'erreur. [[File:Circuit total.png|centre|vignette|upright=2.0|Protection mémoire avec un registre limite.]] ===Un seul espace d'adressage, partagé entre plusieurs programmes=== Les systèmes d’exploitation modernes implémentent la '''multiprogrammation''', le fait de pouvoir lancer 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. Et il faut alors résoudre un problème : le partage de la RAM entre les différents programmes. Les différents programmes et leurs données sont placés en mémoire RAM, et il faut trouver un moyen pour répartir les différents programmes en RAM. [[File:Partitions mémoire.png|vignette|upright=1|Partitions mémoire]] Il y a plusieurs solutions pour cela, mais l'une d'entre elle est l'usage de segments mémoire. Avec elle, chaque programme se voit attribuer un ou plusieurs blocs de mémoire. Ils sont appelés des '''partitions mémoire''', ou encore des '''segments'''. Dans ce qui va suivre, nous partons du principe qu'un programme est égal à un segment, pour simplifier les explications. Mais sachez qu'un programme peut être éclaté en plusieurs segments dispersés dans la mémoire, et même être conçu pour ! Nous en reparlerons dans le chapitre sur le mémoire virtuelle. Le système d'exploitation mémorise une liste des segments importants en mémoire RAM, appelée la ''table des segments''. Il s'agit d'un tableau dont chaque entrée mémorise les informations pour un segment. Une entrée de ce tableau est appelée un '''descripteur de segment''' et contient son adresse de base, sa taille, et des autorisations liées à la protection mémoire. Précisément, l'entrée numéro N mémorise les informations du segment numéro N. A partir de l'adresse de base de la table des segments et du numéro de segment, on peut lire ou écrire un descripteur de segment. [[File:Global Descriptor table.png|centre|vignette|upright=2|Global Descriptor table]] Toutefois, l'usage de segments amène un paquet de problèmes qu'il faut résoudre au mieux. Le premier problème est tout simplement de placer les segments dans l'espace d'adressage, mais c'est quelque chose qui est du ressort du système d'exploitation. Par contre, cela implique qu'un segment peut être placé n'importe où en RAM et sa position en RAM change à chaque exécution. En conséquence, les adresses des branchements et des données ne sont jamais les mêmes d'une exécution à l'autre. Pour résoudre ce problème, le compilateur considère que le segment commence à l'adresse zéro, puis les adresses sont corrigées avant ou pendant l'exécution du programme. Cette correction est en général réalisée par l'OS lors de la copie du programme en mémoire, mais il existe aussi des techniques matérielles, que nous verrons dans le chapitre suivant. Il faut aussi prendre en compte le phénomène de l''''allocation mémoire'''. Derrière ce nom barbare, se cache quelque chose de simple : les programmes peuvent réclamer de la mémoire au système d'exploitation, pour y placer des données. Les langages de programmation bas niveau supportent des fonctions comme malloc(), qui permettent de demander un bloc de mémoire de N octets à l'OS, qui doit alors accommoder la demande. S'il n'y a pas assez de mémoire, l'appel échoue et le programme doit gérer la situation. De même, un programme peut libérer de la mémoire qu'il n'utilise plus avec des fonctions comme free(). Avec des segments, cela revient à changer la taille d'un segment, plus précisément à la fin du segment. Et tout cela est géré par le système d'exploitation, aussi on laisse cela pour le prochain chapitre. Enfin, sans protection particulière, les programmes peuvent techniquement lire ou écrire les données des autres. Pour éviter cela, il suffit de vérifier qu'un programme accède bien à son segment et pas ailleurs. Il suffit de vérifier si l'adresse lue/écrite est dans le bon intervalle. Il suffit alors d'utiliser deux registres limites, rien de plus. Mais au-delà de cette simple vérification, si un programme pouvait modifier les données d'un autre programme, on se retrouverait rapidement avec une situation non prévue par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses. Il faut donc introduire des mécanismes de protection mémoire, pour isoler les programmes les uns des autres, et éviter toute modification problématique. La protection mémoire regroupe plusieurs techniques assez variées, qui ont des objectifs différents. Elles ont pour point commun de faire intervenir à des niveaux divers le système d'exploitation et le processeur. ==La ''memory map'' des PC IBM x86== Dans ce qui suit, nous allons étudier l'espace d'adressage des processeurs x86. L'organisation de leur espace d'adressage a évolué dans le temps et l'espace d'adressage s'est adapté. Mais il s'est adapté sans modifier l'existant, pour des raisons de compatibilité. Aussi, les processeurs x86 modernes peuvent fonctionner dans plusieurs modes, appelés mode réel, protégé, virtuel 8086, long, ''system management mode'', et compatibilité 32 bits. [[File:AMD64StateDiagram.svg|centre|vignette|upright=2|AMD64StateDiagram]] Le ''system management mode'' a déjà été abordé dans le chapitre sur les interruptions. Pour simplifier, il ne s'agit pas d'un véritable mode au sens "adressage". Dans ce mode, le système d'exploitation est mis en pause et le BIOS ou un autre firmware s'exécute sans restriction. On y rentre grâce à une interruption dédiée, lancée par le noyau de l'OS. Le système d'exploitation exécute cette interruption afin de laisser la main au BIOS, pour lui déléguer certaines tâches. Par contre, les autres modes sont bien plus intéressant, car ils ont chacun un espace d'adressage différent. Voyons-les dans le détail. ===Le mode réel : l'adressage sur 20 bits=== Le tout premier mode était le '''mode réel''', qui utilisait des adresses de 20 bits, ce qui fait 1 mébioctet de mémoire adressable. Il était utilisé sur des processeurs 16 bits, qui géraient donc des adresses de 16 bits capables d'adresser 64 kibioctets de mémoire. Pour contourner cette limitation, le processeur utilisait la segmentation, que nous verrons dans le prochain chapitre. Pour le moment, nous allons dire que la mémoire en mode réel était composée de 16 blocs de 64 kibioctets. C'est une simplification qui ne fera pas de mal. Le processeur démarre systématiquement en mode réel, y compris sur les ordinateurs modernes. Le BIOS s'exécute dans ce mode, avant de passer en mode protégé et de laisser la main à l'UEFI et au système d'exploitation. Autrefois, le système d'exploitation s'exécutait aussi en mode réel, c'est le cas du DOS. En mode réel, l'espace d'adressage est peuplé par de la RAM, la ROM du BIOS et des périphériques. La RAM prenait les adresses basses, la ROM était au sommet de l'espace d'adressage, et les périphériques entre les deux. Les premiers 640 kibioctets sont réservés à la RAM et sont appelés la '''mémoire conventionnelle'''. Les octets restants forment la '''mémoire haute''', réservée aux ROM et aux périphériques. Les deux premiers kibioctets de la mémoire conventionnelle sont initialisés au démarrage de l'ordinateur. Ils sont utilisés pour stocker le vecteur d'interruption (on expliquera cela dans quelques chapitres) et servent aussi au BIOS. La portion réservée au BIOS, la ''BIOS Data Area'', mémorise des informations en RAM. Elle commence à l'adresse 0040:0000h, a une taille de 255 octets, et est initialisée lors du démarrage de l'ordinateur. Le reste de la mémoire conventionnelle est réservée au le système d'exploitation (MS-DOS, avant sa version 5.0) et au programme en cours d’exécution. La mémoire haute est sectionnée en deux portions. Le sommet de la mémoire haute est réservé au BIOS, alors que le bas de la mémoire haute est réservé aux périphériques. Dans cette dernière, on trouve les BIOS des périphériques (dont celui de la carte vidéo, s'il existe), qui sont nécessaires pour les initialiser et pour communiquer avec eux. De plus, on y trouve la mémoire de la carte vidéo et éventuellement la mémoire d'autres périphériques comme la carte son. {| class="wikitable" style="text-align:center;" |+ Espace d'adressage en mode réel |- ! Numéro du bloc !! Contenu du bloc |- | 1024 - 960 Kio || class="f_jaune" | BIOS principal (ROM) |- | 960 Kio - 640 Kio | class="f_vert" | ROM d'extension des périphériques (XT, EGA, 3270 PC) </br> Mémoire vidéo des cartes EGA, MDA ou CGA |- | 640 Kio - 0 || class="f_rouge" | Mémoire RAM, mémoire conventionnelle |} Pour détailler un peu plus, il faut tenir compte du découpage de l'espace d'adressage en blocs de 64 kibioctets. Le découpage en blocs de 64 kibioctets est particulièrement important pour la mémoire haute. Des blocs sont attribués à un périphérique spécifique. Par exemple, le 16ème bloc est réservé au BIOS, le 12ème aux ROM des périphériques, le 10ème et le 11ème à la mémoire vidéo, etc. {| class="wikitable" style="text-align:center;" |+ Espace d'adressage en mode réel |- ! Numéro du bloc !! Contenu du bloc |- | 0 || rowspan="10" class="f_rouge" | Mémoire RAM, mémoire conventionnelle |- | 1 |- | 2 |- | 3 |- | 4 |- | 5 |- | 6 |- | 7 |- | 8 |- | 9 |- | 10 || class="f_vert" | Mémoire vidéo des cartes EGA |- | 11 || class="f_vert" | Mémoire vidéo des cartes MDA ou CGA |- | 12 || class="f_vert" | ROM d'extension des périphériques (XT, EGA, 3270 PC) |- | 13 || class="f_gris" | Autre, non-réservé |- | 14 || class="f_gris" | Autre, non-réservé |- | 15 || class="f_jaune" | ROM du BIOS |} Vous remarquerez que les blocs 13 et 14 sont non-réservés. Ils peuvent être utilisés pour mapper des périphériques en mémoire. Mais si aucun périphérique n'est mappé dedans, les blocs sont laissés libres. Les OS n'hésitaient pas à les utiliser pour adresser de la RAM. Par exemple, si l'ordinateur n'a pas de carte vidéo EGA, le bloc qui leur était réservé était réutilisé comme mémoire RAM. La mémoire conventionnelle passait alors de 640 à 704 kibioctets de RAM disponibles. L'espace d'adressage correspond à la ROM du BIOS. Mais avec le temps, la ROM est devenue plus lente au point qu'exécuter du code directement depuis la ROM du BIOS était un problème niveau performance. Je rappelle que le BIOS contient de nombreuses routines d'interruptions qui étaient appelées par les systèmes d'exploitation de l'époque, notamment le DOS. Heureusement, diverses optimisations permettaient de copier le BIOS dans la mémoire RAM, plus rapide que la ROM du BIOS. Il s'agit des techniques de '''''BIOS Shadowing'''''. Elles s'appliquaient au BIOS proprement dit, mais le BIOS vidéo pouvait aussi être optimisé ainsi, mais les ROM d'extension ne le sont généralement pas. Le ''BIOS Shadowing'' peut s'implémenter de plusieurs manières, mais la plus courante mappait temporairement de la RAM dans le même espace d'adressage que la ROM du BIOS. Le code du BIOS copiait le BIOS avec une boucle, dont chaque itération lisait une adresse du BIOS et écrivait dans cette même adresse. Juste avant la boucle, le ''chipset'' était configuré par le BIOS pour que les lectures atterrissent dans la ROM du BIOS, mais que les écritures tombaient dans une mémoire RAM mappée. Une fois la copie/boucle terminée, l'adressage tombait dans la RAM aussi bien pour les lectures et écritures. Elle demandait un support du ''chipset'' et du logiciel, pour détourner les adresses temporairement. ===Interlude : les technologies d'expanded memory=== Déjà à l'époque, 1 mébioctet était une limite assez faible, même si cela n'a pas empêché à un grand dirigeant de l'industrie informatique de dire que "640K ought to be enough for anybody". Aussi, diverses solutions matérielles ont vu le jour. Elles étaient peu utilisées et ont concrètement servi à des applications spécifiques. La principale est l''''''expanded memory''''' (EMS), une solution utilisant une carte d'extension avec plusieurs modules de RAM installés dessus. Les modules étaient accédés via la technique de la commutation de banque. [[File:Expanded memory.svg|centre|vignette|upright=2|L'espace d'adressage avec ''Expanded memory''. La carte d'extension et le matériel requis ne sont pas représentés.]] Le terme ''expanded memory'' regroupe plusieurs technologies propriétaires semblables sur le principe mais différentes dans l'exécution. Les premières utilisaient des banques de 64 kibioctets, qui étaient mappées dans un bloc de 64 kibioctets libre en mémoire haute. L'usage de banques de 64 kibioctets collait bien avec l'adressage particulier du mode réel, où la mémoire était gérée par blocs de 64 kibioctets. Les technologies ultérieures utilisaient 2 à 4 pages de 16 kibioctets, qui pouvaient être placée n'importe où en mémoire conventionnelle. [[File:EmulexPersyst 4M ISA.jpeg|centre|vignette|upright=2|Carte d'extension ''Expanded memory'' de marque Emulex Persyst, de 4 mébioctets.]] L'''expanded memory'' demandait que les programmes soient codés pour utiliser cette fonctionnalité. Pour cela, ils devaient utiliser une technique de programmation nommée ''Overlay programming''. L'idée est de découper le programme en plusieurs blocs séparés, appelés des '''''overlays'''''. Certains blocs sont en permanence en RAM, mais d'autres sont soit chargés en RAM, soit stockés ailleurs. Le ailleurs est souvent le disque dur, ou dans la RAM ''expanded memory'' si elle est disponible. Le va-et-vient des ''overlays'' entre RAM et disque dur était réalisé en logiciel, par le programme lui-même. [[File:Overlay Programming.svg|centre|vignette|upright=1.5|''Overlay Programming''.]] L'utilisation de l'''expanded memory'' utilisait l'interruption logicielle 67h, une interruption peu utilisée à l'époque, qui était libre. La carte d'extension était utilisée à travers un pilote de périphérique dédié, qui était appelé via l'interruption 67h. ===Le mode protégé : l'adressage sur 24 et 32 bits=== Par la suite, le mode réel a été remplacé par le '''mode protégé'''. Il utilise la mémoire virtuelle, ce qui fait qu'on devrait le détailler dans le chapitre suivant. Il permettait aussi de dépasser la limite du mébioctet de mémoire. L'espace d'adressage en mode protégé est passé de 24 bits sur le CPU 286, à 32 bits sur le 386 et les CPU suivants. Les programmes conçus pour le mode réel ne pouvaient pas s'exécuter en mode protégé. Pour corriger cela, le 286 a introduit le '''mode virtuel 8086''', aussi appelé mode V86. Il s'agit d'une technique de virtualisation, ce qui fait qu'on en parlera en détail dans le chapitre sur la virtualisation assistée en matériel. Tout ce que l'on peut dire ici est que le mode V86 a été détourné pour que les applications DOS utilisent la mémoire au-delà du mébioctet, appelée la '''mémoire étendue'''. Les logiciels DOS accédaient à la mémoire étendue à travers un driver DOS dédié (HIMEM.SYS). Les logiciels DOS déplaçaient des données dans la mémoire étendue, puis les rapatriaient en mémoire conventionnelle quand ils en avaient besoin. Il ne faut pas confondre mémoire étendue et ''expanded memory''. La technique de ''expanded memory'' repose sur de la commutation de banque, demande une carte d'extension dédiée, fonctionne sans mode V86. A l'inverse, la mémoire étendue ne gère pas de commutation de banque, ne demande pas de carte d'extension, mais utilise le mode V86. Par contre, il est possible d'émuler l'''expanded memory'' sans carte d'extension, avec la mémoire étendue. Quelques cartes mères intégraient des techniques pour, mais une émulation logicielle était aussi possible en réécrivant l'interruption 67h. ===Le mode long : l'adressage 64 bits=== Par la suite, les processeurs ont introduit le '''mode long''', dans lequel les adresses font 64 bits. Le CPU en mode long a aussi un jeu d'instruction totalement différent, des registres en plus et d'autres fonctionnalités actives uniquement dans ce mode. Les programmes compilés pour le 64 bits s'exécutent nativement dans ce mode, alors que les programmes 32 et 16 bits s'exécutent dans un mode de compatibilité dédié. Sur les systèmes 64 bits, le bus d'adresse fait seulement 48 bits afin d'économiser des interconnexions. Avec 48 bits, il manque 64 - 48 = 16 bits d'adresses, qui ne peuvent pas être utilisés pour adresser quoi que ce soit. La règle est que ces 16 bits doivent être tous égaux : soit ils valent tous 0, soient ils sont tous à 1. Les adresses qui respectent cette contrainte sont appelées des '''adresses canoniques'''. Le résultat est que l'espace d'adressage est coupé en trois sections, comme illustré ci-dessous. * Les '''adresses canoniques basses''' ont leurs 16 bits de poids fort à 0 et sont situées en bas de l'espace d'adressage, dans les premières adresses. * Les '''adresses canoniques hautes''' ont leurs 16 bits de poids fort à 1 et sont situées au sommet de l'espace d'adressage, dans les dernières adresses. * Entre les deux, se trouvent des '''adresses non-canoniques''', qui ne sont pas accessibles. Y accéder déclenche la levée d'une exception matérielle. {| |[[File:AMD64-canonical--48-bit.png|vignette|Espace d'adressage x86-64 bits, avec des adresses physiques de 48 bits.]] |[[File:AMD64-canonical--56-bit.png|vignette|Espace d'adressage x86-64 bits, avec des adresses physiques de 57 bits.]] |} : Les futurs systèmes x86 devraient passer à des adresses de 57 bits, les trois sections auront donc des frontières différentes. Les adresses non-canoniques sont censées être inutilisables. Mais les programmeurs aimeraient bien pouvoir les utiliser pour des ''pointeurs tagués'', à savoir des pointeurs/adresses associées à des informations. L'idée serait d'utiliser les 16 bits de poids fort pour stocker des informations liées au pointeur, seuls les 48 bits restant codant l'adresse. Les 16 bits peuvent être utilisés de manières très diverses. Un exemple est la technique du '''''memory tagging''''', qui crée une somme de contrôle au pointeur. La somme de contrôle est générée par un algorithme cryptographique, à partir de l'adresse du pointeur. Elle est vérifiée à chaque utilisation du pointeur. Si la somme de contrôle ne correspond pas au pointeur, une erreur est levée. Si jamais un virus ou un code malveillant modifie un pointeur, il ne saura pas comment calculer la somme de contrôle, la somme de contrôle a énormément de chances d'être incorrecte. Quelques fonctionnalités des processeurs visent à autoriser l'utilisation des adresses non-canoniques. L'idée est que les 16 bits de poids fort sont ignorées lors des accès mémoire, ce qui permet d'utiliser les 16 bits de poids fort à volonté. Il s'agit des techniques ''Top Byte Ignore'' d'ARM, ''Upper Address Ignore'' d'AMD, et ''Linear Address Masking'' d'Intel. ==La z/architecture d'IBM== IBM a sa propre gamme de ''mainframes'', qui utilisent des processeurs fait maison par IBM lui-même. Les CPU IBM ont leur propre jeu d'instruction CISC, appelé '''''z/architecture'''''. Et ce jeu d'instruction a des possibilités en termes d'adressage qui sont extrêmement intéressantes, en plus d'être assez inédites. Pour ce chapitre, nous allons nous concentrer sur une fonctionnalité : les programmes ont accès à plusieurs espaces d'adressage naturellement. L'avantage pour les programmes utilisateur n'est pas très évident, mais il l'est nettement plus pour les systèmes d'exploitation. Le système d'exploitation de ces ''mainframes'', le z/OS, profite de cette possibilité, idem pour l'hyperviseur z/VM. Il utilise cette possibilité pour donner un espace d'adressage séparé pour chaque programme en cours d'exécution. Les programmes sont alors isolés les uns des autres. Cette '''isolation des processus''' n'est pas spécifique à IBM : tous les systèmes d'exploitation modernes l'utilisent. Mais les processeurs x86 doivent se débrouiller seulement avec des techniques dites de mémoire virtuelle, alors que les ''mainframes'' IBM complètent la mémoire virtuelle avec des facilités en plus. Pour les programmes utilisateurs, un intérêt est que cela permet de partager des données entre plusieurs programmes. Ils ont un espace d'adressage rien qu'à eux, mais les données partagées sont placées dans un espace d'adressage commun, auquel les deux programmes ont accès. L'espace d'adressage partagé peu même être configuré de manière à ne contenir que des données, il est impossible d'exécuter du code depuis cet espace d'adressage. Ce qui empêche certains ''malwares'' de fonctionner. ===Les espaces d'adressage par défaut de la ''z/architecture''=== La ''z/architecture'' supporte 65536 espaces d'adressage simultanés. Les espaces d'adressage sont numérotés, ils ont chacun un identifiant codé sur 16 bits, appelé un '''''Address-Space Number'''''. Nous utiliserons l’abréviation ASN dans ce qui suit. Par contre, un programme ne peut pas utiliser tout ces espaces d'adressages, il ne peut en utiliser que 16 en même temps, maximum. De plus, ils ne sont accessibles que quand le processeur est dans un mode de fonctionnement bien précis. Pour utiliser un espace d'adressage, un programme doit connaitre l'ASN associé. Par défaut, un programme exécuté sur la ''z/architecture'' utilise deux espaces d'adressages : l''''espace d'adressage primaire''' où se situent le programme exécuté et les données importantes, un '''espace d'adressage secondaire''' utilisé pour des tâches/données annexes. L'ASN de ces deux espaces d'adressage est mémorisé dans les registres de contrôle du processeur. L'ASN de l'espace primaire est dans le registre de contrôle n°3, l'ASN de l'espace secondaire est dans le registre de contrôle n°4 (ils sont dans les 16 bits de poids faible, dans les deux cas). Les deux registres de controle sont initialisés au démarrage de l'application, par le système d'exploitation, ce qui fait que ce sont des espaces d'adressage par défaut. Le programme a donc accès à ces deux espaces d'adressage sans rien faire. : Ils ont aussi accès à un '''espace d'adressage ''home''''' unique qui est surtout utilisé par le système d'exploitation, qui a pour particularité de ne pas avoir d'ASN. Il n'est concrètement accesible qu'en mode superviseur, donc par le système d'exploitation. Nous n'en parlerons pas dans ce qui suit. Mais comment le processeur pour utiliser ces espaces d'adressages ? Par exemple, s'il veut faire une lecture, comment précise-t-il l'espace d'adressage courant ? La réponse est que le processeur peut être dans deux états : soit primaire, soit secondaire. Dans le premier état, toutes les lectures/écritures se font dans l'espace d'adressage primaire. Dans le second, elles se font toutes dans l'espace d'adressage secondaire. Il peut passer de l'un à l'autre en utilisant l'instruction ''Set Address Space Control''. ===Les registres d'accès de la ''z/architecture''=== Un programme peut donc passer de l'espace d'adressage primaire à secondaire (ou inversement) en utilisant l’instruction ''SET ADDRESS SPACE CONTROL''. Mais il peut en profiter pour passer dans un mode où il a accès à 16 espaces d'adressage différents, en même temps. Le mode en question est appelé le '''mode ''access register'''''. Le processeur a donc quatre modes de fonctionnement : primaire, secondaire, ''home'' et ''access register''. Qui dit 16 espaces d'adressage dit 16 ASNs à mémoriser quelque part. Pour cela, le processeur dispose de 16 '''registres d'accès''', chacun mémorisant un ASN. Un accès mémoire précise quel registre d'accès utiliser, ce qui lui permet de sélectionner l'espace d'adressage voulu. Pas besoin d'utiliser ''SET ADDRESS SPACE CONTROL'' pour passer de l'un à l'autre, le programme peut gérer 16 espaces d’adressage en même temps. Ce qui est une différence importante par rapport au mode de fonctionnement précédent. : Une précision importante est que le premier registre d'accès mémorise l'ASN de l'espace d'adressage primaire. De plus, les instructions sont chargées depuis l'espace d'adressage primaire, sauf quand le CPU est en mode ''home''. Les CPU ''z/architecture'' disposent de 16 registres généraux de 64 bits, de 16 registres flottants eux aussi de 64 bits, de 16 registres de contrôle eux aussi de 64 bits, et de '''registres d'accès''' qui gèrent les espaces d'adressage. Chaque registre entier est associé à un espace d'adressage particulier, qui est indiqué par son registre d'accès. Les registres d'accès sont utilisés lors des lectures/écritures indirectes. L'adresse est lue depuis un registre général, et son registre d'accès est consulté pour déterminer l'espace d'adressage. Une implémentation simple numéroterait les espaces d'adressage et mettrait les numéros dans les registres d'accès. Les registres d'accès seraient idéalement protégés en écriture et seulement accessibles par le système d'exploitation. Mais ce n'est pas ce qui est fait, pour des raisons de sécurité. Les registres d'accès mémorisent bien un identifiant, mais celui-ci ne sélectionne pas l'espace d'adressage directement. L'identifiant est envoyée à une table de correspondance matérielle, qui mémorise les correspondances entre un identifiant et un espace d'adressage. Il y a une table de correspondance par programme, ce qui fait qu'un identifiant ne correspondra pas au même espace d'adressage dans deux programmes différents. La table de correspondance mémorise aussi des droits d'accès, qui sont différents pour chaque programme. Par exemple, tel programme aura accès en lecture seule à tel espace d'adressage, alors qu'un autre programme y aura accès en lecture et écriture, etc. Un espace d'adressage peut ainsi être partagé entre deux programmes plus facilement, tout en ajoutant des garanties de sécurité. L'usage des registres d'accès est aussi plus flexible qu'on ne pourrait le croire. Le programme peut lire et écrire dans les registres d'accès, copier un registre d'accès dans un autre, etc. Par contre, la table de correspondance empêche à un programme d’accéder à un espace d'adressage arbitraire. Le système d'exploitation remplit la table de manière à n'autoriser l'accès qu'aux espaces d'adressage adéquats, il y enregistre seulement les correspondances utiles. Si un programme forge un identifiant arbitraire, et l'envoie à la table de correspondance, celle-ci n'aura pas la correspondance associée et l'accès mémoire sera invalidé. La table de correspondance est en réalité une table à deux niveaux. Une première table reçoit l'ASN et fournit l'adresse de la seconde table. La seconde table récupère alors l'adresse. Vu que le processeur utilise la mémoire virtuelle, c'est une adresse dite virtuelle, mais laissons cela pour le prochain chapitre. La première table est appelée l'''''access list''''', alors que la seconde est appelée l''''''ASN-Second-Table-Entry Sequence Number'''''. Les deux tables sont accessibles en écriture via une instruction ''LOAD ADDRESS SPACE PARAMETERS'', seulement accesible en mode noyau. : Il faut noter que si l'''access list'' a une taille limitée à 64 kibioctets, la seconde n'est pas limitée, ce qui permet de dépasser la limite des 655536 ASNs. La limite de 65535 ASNs est uen limite par programme, pas totale. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs 8 bits et moins | prevText=Les processeurs 8 bits et moins | next=L'abstraction mémoire et la mémoire virtuelle | nextText=L'abstraction mémoire et la mémoire virtuelle }} </noinclude> 3kepc6xj4ueigj9jh9eaajtmexbf6w2 774367 774366 2026-10-07T14:51:37Z Mewtow 31375 /* Les espaces d'adressage par défaut de la z/architecture */ 774367 wikitext text/x-wiki L''''espace d'adressage''' du processeur est l'ensemble des adresses utilisables par le processeur. Par exemple, un processeur 16 bits peut adresser 2^16 = 65536 adresses, l'ensemble de ces adresses forme son espace d'adressage. L'espace d'adressage n'est pas toujours égal à la mémoire réellement installée. S'il n'y a pas assez de RAM installée, des adresses seront inoccupées. De plus, une partie de l'espace d'adressage peut être détourné pour communiquer avec les périphériques, comme nous le verrons plus bas. Nous verrons aussi qu'un processeur peut avoir plusieurs espaces d'adressages séparés. ==L'adressage de la RAM et de la ROM== Avoir plusieurs espaces d'adressage spécialisés est quelque chose que nous avons déjà rencontré dans les chapitres précédents, mais sans le dire ouvertement. Aussi, nous allons faire quelques rappels sur les cas déjà rencontrés. En premier lieu, nous allons rappeler la différence entre architectures Von Neumann et Hardvard. La différence entre les deux tient dans l'adressage des mémoires RAM et ROM : est-ce qu'elles sont dans un seul espace d'adressage, ou dans des espaces d'adressage séparés. L''''architecture Von Neumann''' a un seul espace d'adressage, découpé entre la mémoire RAM d'un côté et la mémoire ROM de l'autre. Une adresse correspond soit à la mémoire RAM, soit à la mémoire ROM, mais pas aux deux. Typiquement, la mémoire ROM est placée dans les adresses hautes, les plus élevées, alors que la RAM est placée dans les adresses basses en commençant par l'adresse 0. C'est une convention qui n'est pas toujours respectée, aussi mieux vaut éviter de la tenir pour acquise. [[File:Vision de la mémoire par un processeur sur une architecture Von Neumann.png|centre|vignette|upright=2|Vision de la mémoire par un processeur sur une architecture Von Neumann.]] L''''architecture Harvard''' utilise des espaces d'adressage séparés pour la RAM et la ROM. Une même adresse peut correspondre soit à la ROM, soit à la RAM. Le processeur voit bien deux mémoires séparées, chacune dans son propre espace d'adressage. Les deux espaces d'adressage n'ont pas forcément la même taille. Il est possible d'avoir un plus gros espace d'adressage pour la RAM que pour la ROM. Cela implique que les adresses des instructions et des données soient de taille différentes. C'est peu pratique et c'est rarement implémenté, ce qui fait que le cas le plus courant est celui où les deux espaces d'adressages ont la même taille. [[File:Vision de la mémoire par un processeur sur une architecture Harvard.png|centre|vignette|upright=2|Vision de la mémoire par un processeur sur une architecture Harvard.]] Sur une '''architecture Harvard modifiée''', le programme est placé dans un espace d'adressage à part du reste. Le problème, c'est que cet espace d'adressage ne contient pas que le code machine à exécuter. Il contient aussi des constantes, à savoir des données qui gardent la même valeur lors de l'exécution du programme. Elles peuvent être lues, mais pas modifiées durant l'exécution du programme. L'accès à ces constantes demande d'aller lire celles-ci dans l'autre espace d'adressage, pour les copier dans les registres généraux. Le processeur doit pour cela avoir des instructions de lecture dédiées. En clair, il y a une instruction LOAD pour lire dans la mémoire ROM, une autre pour lire dans la RAM. [[File:Espaces d'adressage sur une archi harvard modifiée.png|centre|vignette|upright=3|Espaces d'adressage sur une archi harvard modifiée]] Une autre solution utilise des instructions de copie, qui copient les constantes en mémoire RAM, donc d'un espace d'adressage à l'autre. [[File:Organisation des espaces d'adressage sur une archi harvard modifiée.png|centre|vignette|upright=3|Organisation des espaces d'adressage sur une archi harvard modifiée]] ==L'adressage des périphériques== Passons maintenant à l'adressage des périphériques. La communication avec les périphériques se fait par l'intermédiaire de registres d’interfaçage. Et ces registres peuvent soit avoir un espace d'adressage séparé, soit être inclus dans l'espace d'adressage des mémoires. Dans ce qui suit, nous allons supposer que l'architecture des de type Von Neumann pour simplifier les explications. ===Les entrées-sorties mappées en mémoire=== Une partie de l'espace d'adressage peut être détourné pour communiquer avec les périphériques. Le périphérique se retrouve inclus dans l'ensemble des adresses utilisées pour manipuler la mémoire : on dit qu'il est mappé en mémoire. Les adresses mémoires associées à un périphérique sont redirigées automatiquement vers les registres du périphérique en question, voire vers sa mémoire intégrée. On parle alors d''''entrées-sorties mappées en mémoire'''. [[File:IO mappées en mémoire.png|centre|vignette|upright=2.0|IO mappées en mémoire]] On remarque un défaut inhérent à cette technique : les adresses utilisées pour les périphériques ne sont plus disponibles pour la RAM. On ne peut plus adresser autant de RAM qu'avant. La perte dépend des périphériques installés et de leur gourmandise en adresses mémoires. C'est ce qui causait un problème assez connu sur les ordinateurs 32 bits, capables d'adresser 4 gibioctets de mémoire. Certaines personnes installaient 4 gigaoctets de mémoire sur leur ordinateur 32 bits et se retrouvaient avec « seulement » 3,5 à 3,8 gigaoctets de mémoire, les périphériques prenant le reste. Il est possible que la RAM d'un périphérique soit mappée en RAM. Un exemple classique est celui des cartes graphiques qui incorporent une mémoire RAM appelée la mémoire vidéo. La mémoire vidéo est mappée en mémoire, ce qui permet au processeur d'écrire directement dedans. Toute lecture ou écriture dans les adresses associées est redirigée vers le bus PCI/AGP/PCI-Express. Nous verrons plus bas des exemples d'ordinateurs où la mémoire vidéo est mappée en mémoire, que ce soit totalement ou partiellement. Si je dis totalement ou partiellement, c'est parce que les cartes graphiques modernes disposent de tellement de mémoire qu'on ne peut pas la mapper totalement dans l'espace d'adressage. Sur les systèmes d'avant 2008, seuls 256 mégaoctets de mémoire vidéo sont mappés en mémoire RAM. Le reste de la mémoire vidéo est invisible du point de vue du processeur, mais manipulable par le GPU à sa guise. Après 2008, la spécification du PCI-Express ajouta un support de la technologie ''Resizable Bar'', qui permet au processeur d’accéder directement à plus de 256 mégaoctets de mémoire vidéo, voire à la totalité de la mémoire vidéo. ===L'espace d'adressage séparé pour les entrées-sorties=== Les entrées-sorties et périphériques peuvent avoir leur propre espace d'adressage dédié, séparé de celui utilisé pour les mémoires RAM et ROM. [[File:Espaces_d'adressages_séparés_entre_mémoire_et_périphérique.png|centre|vignette|upright=3|Bit IO.]] Une même adresse peut donc adresser soit une entrée-sortie, soit une case mémoire. Et pour faire la différence, le processeur doit avoir des instructions séparées pour adresser les périphériques et la mémoire. Il a des instructions de lecture/écriture pour lire/écrire en mémoire, et d'autres pour lire/écrire les registres d’interfaçage. Sans cela, le processeur ne saurait pas si une adresse est destinée à un périphérique ou à la mémoire. Cela élimine aussi les problèmes avec les caches : les accès à l'espace d'adressage de la RAM passent par l'intermédiaire de la mémoire cache, alors les accès dans l'espace d'adressage des périphériques le contournent totalement. Là encore, les deux espaces d'adressage n'ont pas forcément la même taille. Il arrive que les deux espaces d'adressage aient la même taille, le plus souvent sur des ordinateurs complexes avec beaucoup de périphériques. Mais les systèmes embarqués ont souvent des espaces d'adressage plus petits pour les périphériques que pour la ou les mémoires. L'implémentation varie grandement suivant le cas, la première méthode imposant d'avoir deux bus séparés pour les mémoires et les périphériques, l'autre permettant un certain partage du bus d'adresse. Nous reviendrons dessus plus en détail dans le chapitre sur l'adressage des périphériques. ==La ''memory map'' d'un ordinateur== Les deux sections précédentes nous ont appris que l'on peut utiliser un espace d'adressage séparé pour la ROM et un autre pour les périphériques. En tout, cela donne quatre possibilités distinctes. {|class="wikitable" |- ! ! IO mappées en mémoire ! IO séparées |- ! ROM mappée en mémoire (architecture Von Neumann) | Un seul espace d'adressage | Deux espaces d'adressage : * un pour les mémoires RAM/ROM ; * un pour les IO. |- ! ROM séparée (architecture Harvard) | Deux espaces d'adressage : * un pour la RAM et les IO ; * un pour la ROM. | Trois espaces d'adressages : * un pour la RAM ; * un pour la ROM ; * un pour les IO. |} Les quatre solutions ont des avantages et inconvénients divers, mais il est intéressant de contraster un espace d'adressage unique avec plusieurs espaces d'adressages. ===L'espace d'adressage unique=== Avec un espace d'adressage unique, la ROM est au sommet de l'espace d'adressage, les périphériques sont juste en-dessous, la RAM commence à l'adresse 0 et prend les adresses basses. Faire simplifie grandement l'implémentation matérielle de l'adressage. Notons que d'autres composants que les périphériques ou les mémoires peuvent se trouver dans l'espace d'adressage. On peut y trouver les horloges temps réels, des timers, des senseurs de température, ou d'autres composants placés sur la carte mère. Un exemple un peu original est le suivant : la console de jeu Nintendo DS incorporait une unité de calcul spécialisée dans les divisions et racines carrées, séparée du processeur, qui était justement mappée en mémoire ! [[File:Espace d'adressage classique avec entrées-sorties mappées en mémoire.png|centre|vignette|upright=2|Espace d'adressage classique avec entrées-sorties mappées en mémoire]] L'espace d'adressage unique est plus simple pour les programmeurs, malgré un désavantage dont il faut parler. Le problème est que des adresses censées être disponibles pour la RAM sont détournées vers la ROM ou les périphériques, ce qui limite la quantité de RAM qui peut être réellement adressée en pratique. C'est un défaut qui se manifeste seulement pour les petits espaces d'adressages, de 8 à 16 bits, qui ne permettent pas d'adresser beaucoup de RAM. Avec des adresses codées sur 16 bits, un espace d'adressage de 64 kibioctets de RAM est grignoté par des entrées-sorties assez rapidement. Par contre, pour des adresses de 32 à 64 bits, une bonne partie de l'espace d'adressage est inutilisé, car il n'y a pas assez de RAM. L'espace d'adressage unique n'a donc pas de désavantages. Les vielles consoles de jeux et certains vieux ordinateurs (Commodores et Amiga), utilisaient un espace d'adressage unique. Elles n'avaient pas une variété de cartes graphiques ou de cartes sons différentes, comme sur les PCs modernes. Au contraire, elles avaient la même configuration matérielle, le matériel ne pouvait pas être changé ni upgradé. Le système d'exploitation était rudimentaire et ne contrôlait pas vraiment l'accès au matériel. Les programmeurs avaient donc totalement accès au matériel et mapper les entrées/sorties en mémoire rendait la programmation des périphériques très simple. <gallery widths=200px heights=500px> N5200mk2 memory map.svg|Adressage mémoire (carte mémoire) du N5200mk2. PC-9801VM memory map.svg|Adressage mémoire (carte mémoire) du PC-9801VM. </gallery> ===La commutation de banques (''bank switching'')=== Le '''''bank switching''''', aussi appelé '''commutation de banque''', permet d'utiliser plusieurs espaces d'adressage sur un même processeur, sans attribuer chaque espace d'adressage pour une raison précise. L'espace d'adressage est présent en plusieurs exemplaires appelés des '''banques'''. Les banques sont numérotées, chaque numéro de banque permettant de l'identifier et de le sélectionner. Le but de cette technique est d'augmenter la mémoire disponible pour l'ordinateur. Par exemple, supposons que j'ai besoin d'adresser une mémoire ROM de 4 kibioctets, une RAM de 8 kibioctets, et divers périphériques. Le processeur a un bus d'adresse de 12 bits, ce qui limite l'espace d'adressage à 4 kibioctets. Dans ce cas, je peux réserver 4 banques : une pour la ROM, une pour les périphériques, et deux banques qui contiennent chacune la moitié de la RAM. La simplicité et l'efficacité de cette technique font qu'elle est beaucoup utilisée dans l'informatique embarquée. [[File:PC-8801MemoryMap.gif|centre|vignette|upright=2|exemple de Bank switching.]] Cette technique demande d'utiliser un bus d'adresse plus grand que les adresses du processeur. L'adresse réelle se calcule en concaténant le numéro de banque avec l'adresse accédée. Le numéro de la banque actuellement en cours d'utilisation est mémorisé dans un registre appelé le '''registre de banque'''. On peut changer de banque en changeant le contenu de ce registre. Le processeur dispose souvent d'instructions spécialisées qui en sont capables. {| class="wikitable flexible" |[[File:Banque mémoire.png|Banque mémoire.]] |[[File:Registre de banque.png|Registre de banque.]] |} ==Le placement des programmes dans l'espace d'adressage== Les ordinateurs modernes sont des architectures Von Neumann, avec un seul espace d'adressage, qui est partagé suivant les besoins. Et un point important est que le système d'exploitation et les programmes sont copiés en mémoire RAM avant d'être exécutés. Ils ne sont pas exécutés depuis une mémoire ROM, cela est réservé aux systèmes embarqués simples. Et cela a une influence sur l'espace d'adressage. Les programmes et le système d'exploitation doivent se partager l'espace d'adressage. Voyons voir comment ce partage se fait. Dans ce chapitre, nous allons volontairement mettre de côté les ordinateurs qui supportent la mémoire virtuelle. Nous en parlerons dans le prochain chapitre. Le principe est de voir comment un processeur avec un seul espace d'adressage gèrent la présence d'un ou de plusieurs programmes. ===Un seul espace d'adressage, non-partagé=== Le cas le plus simple est celui où il n'y a pas de système d'exploitation et où un seul programme s'exécute sur l'ordinateur. Le programme a alors accès à tout l'espace d'adressage, sauf pour ce qui est réservé aux périphériques et à la mémoire ROM. Le programme organise la mémoire en plusieurs sections, dans lesquelles le programme range des données différentes. Typiquement, on trouve quatre blocs de mémoire, appelés des segments, chacun étant spécialisé pour les données, le code, la pile, etc. Voici ces trois sections : * Le segment '''''text''''' 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. * Le segment pour la '''pile''', de taille variable. * le reste est appelé le '''tas''', de taille variable. [[File:Organisation d'un espace d'adressage unique utilisé par un programme unique.png|centre|vignette|upright=2|Organisation d'un espace d'adressage unique utilisé par un programme unique]] [[File:Typical computer data memory arrangement.png|vignette|upright=0.5|Typical computer data memory arrangement]] La pile et le tas sont de taille variable, ce qui veut dire qu'ils peuvent grandir ou diminuer à volonté, contrairement au reste. Entre le tas et la pile, on trouve un espace de mémoire inutilisée, qui peut être réquisitionné selon les besoins. La pile commence généralement à l'adresse la plus haute et grandit en descendant, alors que le tas grandit en remontant vers les adresses hautes. Il s'agit là d'une convention, rien de plus. Il est possible d'inverser la pile et le tas sans problème, c'est juste que cette organisation est rentrée dans les usages. Avant de poursuivre, précisons qu'il est possible de regrouper plusieurs programmes distincts dans un seul, afin qu'ils partagent le même morceau de mémoire. Les programmes portent alors le nom de '''threads'''. Les ''threads'' d'un même processus partagent le même espace d'adressage. Ils partagent généralement certains segments : ils se partagent le code, le tas et les données statiques. Par contre, chaque thread dispose de sa propre pile d'appel. [[File:Single vs multithreaded processes.jpg|centre|vignette|upright=2.0|Distinction entre processus mono et multi-thread.]] Il va de soi que cette vision de l'espace d'adressage ne tient pas compte des périphériques. C'est-à-dire que les schémas précédents partent du principe qu'on a un espace d'adressage séparé pour les périphériques. Dans le cas où les entrées-sorties sont mappées en mémoire, l'organisation est plus compliquée. Généralement, les adresses associées aux périphériques sont placées juste au-dessus de la pile, dans les adresses hautes. ===Un seul espace d'adressage, partagé avec le système d'exploitation=== [[File:Gestion de la mémoire sur les OS monoprogrammés.png|vignette|upright=1.0|Gestion de la mémoire sur les OS monoprogrammés.]] Maintenant, étudions le cas où le programme partage la mémoire avec le système d'exploitation. Sur les systèmes d'exploitation les plus simples, on ne peut lancer qu'un seul programme à la fois. Le système d'exploitation réserve une portion de taille fixe réservée au système d'exploitation, alors que le reste de la mémoire est utilisé pour le programme à exécuter. Le programme est placé à un endroit en RAM qui est toujours le même. Le système d'exploitation peut être soit placé dans une ROM, soit copié en mémoire RAM depuis une mémoire de masse, comme le programme à lancer. Si le système d'exploitation est copié en mémoire RAM, il est généralement placé dans les premières adresses, les adresses basses. A l'inverse, un OS en mémoire ROM est généralement placé à la fin de la mémoire, dans les adresses hautes. Mais tout cela n'est qu'une convention, et les exceptions sont monnaie courante. [[File:Méthodes d'allocation de la mémoire avec un espace d'adressage unique.png|centre|vignette|upright=2.0|Méthodes d'allocation de la mémoire avec un espace d'adressage unique]] Sur de tels systèmes, il faut protéger l'OS contre une erreur ou malveillance d'un programme utilisateur. Notons qu'il s'agit d'une protection en écriture, pas en lecture. Le programme peut parfaitement lire les données de l'OS sans problèmes, au moins en partie. Dans ce qui suit, on part du principe que l'OS est dans les adresses basses. La solution la plus courante interdit au programme d'écrire au-delà d'une limite au-delà de laquelle se trouve le système d’exploitation. Pour cela, le processeur incorpore un '''registre limite''', qui contient l'adresse limite au-delà de laquelle un programme peut pas écrire. Pour toute écriture en espace utilisateur, l'adresse écrite est comparée au registre limite. Si l'adresse est inférieure au registre limite, le programme cherche écrire dans la mémoire réservée à l'OS. L’accès mémoire est interdit, une exception matérielle est levée et l'OS affiche un message d'erreur. [[File:Circuit total.png|centre|vignette|upright=2.0|Protection mémoire avec un registre limite.]] ===Un seul espace d'adressage, partagé entre plusieurs programmes=== Les systèmes d’exploitation modernes implémentent la '''multiprogrammation''', le fait de pouvoir lancer 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. Et il faut alors résoudre un problème : le partage de la RAM entre les différents programmes. Les différents programmes et leurs données sont placés en mémoire RAM, et il faut trouver un moyen pour répartir les différents programmes en RAM. [[File:Partitions mémoire.png|vignette|upright=1|Partitions mémoire]] Il y a plusieurs solutions pour cela, mais l'une d'entre elle est l'usage de segments mémoire. Avec elle, chaque programme se voit attribuer un ou plusieurs blocs de mémoire. Ils sont appelés des '''partitions mémoire''', ou encore des '''segments'''. Dans ce qui va suivre, nous partons du principe qu'un programme est égal à un segment, pour simplifier les explications. Mais sachez qu'un programme peut être éclaté en plusieurs segments dispersés dans la mémoire, et même être conçu pour ! Nous en reparlerons dans le chapitre sur le mémoire virtuelle. Le système d'exploitation mémorise une liste des segments importants en mémoire RAM, appelée la ''table des segments''. Il s'agit d'un tableau dont chaque entrée mémorise les informations pour un segment. Une entrée de ce tableau est appelée un '''descripteur de segment''' et contient son adresse de base, sa taille, et des autorisations liées à la protection mémoire. Précisément, l'entrée numéro N mémorise les informations du segment numéro N. A partir de l'adresse de base de la table des segments et du numéro de segment, on peut lire ou écrire un descripteur de segment. [[File:Global Descriptor table.png|centre|vignette|upright=2|Global Descriptor table]] Toutefois, l'usage de segments amène un paquet de problèmes qu'il faut résoudre au mieux. Le premier problème est tout simplement de placer les segments dans l'espace d'adressage, mais c'est quelque chose qui est du ressort du système d'exploitation. Par contre, cela implique qu'un segment peut être placé n'importe où en RAM et sa position en RAM change à chaque exécution. En conséquence, les adresses des branchements et des données ne sont jamais les mêmes d'une exécution à l'autre. Pour résoudre ce problème, le compilateur considère que le segment commence à l'adresse zéro, puis les adresses sont corrigées avant ou pendant l'exécution du programme. Cette correction est en général réalisée par l'OS lors de la copie du programme en mémoire, mais il existe aussi des techniques matérielles, que nous verrons dans le chapitre suivant. Il faut aussi prendre en compte le phénomène de l''''allocation mémoire'''. Derrière ce nom barbare, se cache quelque chose de simple : les programmes peuvent réclamer de la mémoire au système d'exploitation, pour y placer des données. Les langages de programmation bas niveau supportent des fonctions comme malloc(), qui permettent de demander un bloc de mémoire de N octets à l'OS, qui doit alors accommoder la demande. S'il n'y a pas assez de mémoire, l'appel échoue et le programme doit gérer la situation. De même, un programme peut libérer de la mémoire qu'il n'utilise plus avec des fonctions comme free(). Avec des segments, cela revient à changer la taille d'un segment, plus précisément à la fin du segment. Et tout cela est géré par le système d'exploitation, aussi on laisse cela pour le prochain chapitre. Enfin, sans protection particulière, les programmes peuvent techniquement lire ou écrire les données des autres. Pour éviter cela, il suffit de vérifier qu'un programme accède bien à son segment et pas ailleurs. Il suffit de vérifier si l'adresse lue/écrite est dans le bon intervalle. Il suffit alors d'utiliser deux registres limites, rien de plus. Mais au-delà de cette simple vérification, si un programme pouvait modifier les données d'un autre programme, on se retrouverait rapidement avec une situation non prévue par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses. Il faut donc introduire des mécanismes de protection mémoire, pour isoler les programmes les uns des autres, et éviter toute modification problématique. La protection mémoire regroupe plusieurs techniques assez variées, qui ont des objectifs différents. Elles ont pour point commun de faire intervenir à des niveaux divers le système d'exploitation et le processeur. ==La ''memory map'' des PC IBM x86== Dans ce qui suit, nous allons étudier l'espace d'adressage des processeurs x86. L'organisation de leur espace d'adressage a évolué dans le temps et l'espace d'adressage s'est adapté. Mais il s'est adapté sans modifier l'existant, pour des raisons de compatibilité. Aussi, les processeurs x86 modernes peuvent fonctionner dans plusieurs modes, appelés mode réel, protégé, virtuel 8086, long, ''system management mode'', et compatibilité 32 bits. [[File:AMD64StateDiagram.svg|centre|vignette|upright=2|AMD64StateDiagram]] Le ''system management mode'' a déjà été abordé dans le chapitre sur les interruptions. Pour simplifier, il ne s'agit pas d'un véritable mode au sens "adressage". Dans ce mode, le système d'exploitation est mis en pause et le BIOS ou un autre firmware s'exécute sans restriction. On y rentre grâce à une interruption dédiée, lancée par le noyau de l'OS. Le système d'exploitation exécute cette interruption afin de laisser la main au BIOS, pour lui déléguer certaines tâches. Par contre, les autres modes sont bien plus intéressant, car ils ont chacun un espace d'adressage différent. Voyons-les dans le détail. ===Le mode réel : l'adressage sur 20 bits=== Le tout premier mode était le '''mode réel''', qui utilisait des adresses de 20 bits, ce qui fait 1 mébioctet de mémoire adressable. Il était utilisé sur des processeurs 16 bits, qui géraient donc des adresses de 16 bits capables d'adresser 64 kibioctets de mémoire. Pour contourner cette limitation, le processeur utilisait la segmentation, que nous verrons dans le prochain chapitre. Pour le moment, nous allons dire que la mémoire en mode réel était composée de 16 blocs de 64 kibioctets. C'est une simplification qui ne fera pas de mal. Le processeur démarre systématiquement en mode réel, y compris sur les ordinateurs modernes. Le BIOS s'exécute dans ce mode, avant de passer en mode protégé et de laisser la main à l'UEFI et au système d'exploitation. Autrefois, le système d'exploitation s'exécutait aussi en mode réel, c'est le cas du DOS. En mode réel, l'espace d'adressage est peuplé par de la RAM, la ROM du BIOS et des périphériques. La RAM prenait les adresses basses, la ROM était au sommet de l'espace d'adressage, et les périphériques entre les deux. Les premiers 640 kibioctets sont réservés à la RAM et sont appelés la '''mémoire conventionnelle'''. Les octets restants forment la '''mémoire haute''', réservée aux ROM et aux périphériques. Les deux premiers kibioctets de la mémoire conventionnelle sont initialisés au démarrage de l'ordinateur. Ils sont utilisés pour stocker le vecteur d'interruption (on expliquera cela dans quelques chapitres) et servent aussi au BIOS. La portion réservée au BIOS, la ''BIOS Data Area'', mémorise des informations en RAM. Elle commence à l'adresse 0040:0000h, a une taille de 255 octets, et est initialisée lors du démarrage de l'ordinateur. Le reste de la mémoire conventionnelle est réservée au le système d'exploitation (MS-DOS, avant sa version 5.0) et au programme en cours d’exécution. La mémoire haute est sectionnée en deux portions. Le sommet de la mémoire haute est réservé au BIOS, alors que le bas de la mémoire haute est réservé aux périphériques. Dans cette dernière, on trouve les BIOS des périphériques (dont celui de la carte vidéo, s'il existe), qui sont nécessaires pour les initialiser et pour communiquer avec eux. De plus, on y trouve la mémoire de la carte vidéo et éventuellement la mémoire d'autres périphériques comme la carte son. {| class="wikitable" style="text-align:center;" |+ Espace d'adressage en mode réel |- ! Numéro du bloc !! Contenu du bloc |- | 1024 - 960 Kio || class="f_jaune" | BIOS principal (ROM) |- | 960 Kio - 640 Kio | class="f_vert" | ROM d'extension des périphériques (XT, EGA, 3270 PC) </br> Mémoire vidéo des cartes EGA, MDA ou CGA |- | 640 Kio - 0 || class="f_rouge" | Mémoire RAM, mémoire conventionnelle |} Pour détailler un peu plus, il faut tenir compte du découpage de l'espace d'adressage en blocs de 64 kibioctets. Le découpage en blocs de 64 kibioctets est particulièrement important pour la mémoire haute. Des blocs sont attribués à un périphérique spécifique. Par exemple, le 16ème bloc est réservé au BIOS, le 12ème aux ROM des périphériques, le 10ème et le 11ème à la mémoire vidéo, etc. {| class="wikitable" style="text-align:center;" |+ Espace d'adressage en mode réel |- ! Numéro du bloc !! Contenu du bloc |- | 0 || rowspan="10" class="f_rouge" | Mémoire RAM, mémoire conventionnelle |- | 1 |- | 2 |- | 3 |- | 4 |- | 5 |- | 6 |- | 7 |- | 8 |- | 9 |- | 10 || class="f_vert" | Mémoire vidéo des cartes EGA |- | 11 || class="f_vert" | Mémoire vidéo des cartes MDA ou CGA |- | 12 || class="f_vert" | ROM d'extension des périphériques (XT, EGA, 3270 PC) |- | 13 || class="f_gris" | Autre, non-réservé |- | 14 || class="f_gris" | Autre, non-réservé |- | 15 || class="f_jaune" | ROM du BIOS |} Vous remarquerez que les blocs 13 et 14 sont non-réservés. Ils peuvent être utilisés pour mapper des périphériques en mémoire. Mais si aucun périphérique n'est mappé dedans, les blocs sont laissés libres. Les OS n'hésitaient pas à les utiliser pour adresser de la RAM. Par exemple, si l'ordinateur n'a pas de carte vidéo EGA, le bloc qui leur était réservé était réutilisé comme mémoire RAM. La mémoire conventionnelle passait alors de 640 à 704 kibioctets de RAM disponibles. L'espace d'adressage correspond à la ROM du BIOS. Mais avec le temps, la ROM est devenue plus lente au point qu'exécuter du code directement depuis la ROM du BIOS était un problème niveau performance. Je rappelle que le BIOS contient de nombreuses routines d'interruptions qui étaient appelées par les systèmes d'exploitation de l'époque, notamment le DOS. Heureusement, diverses optimisations permettaient de copier le BIOS dans la mémoire RAM, plus rapide que la ROM du BIOS. Il s'agit des techniques de '''''BIOS Shadowing'''''. Elles s'appliquaient au BIOS proprement dit, mais le BIOS vidéo pouvait aussi être optimisé ainsi, mais les ROM d'extension ne le sont généralement pas. Le ''BIOS Shadowing'' peut s'implémenter de plusieurs manières, mais la plus courante mappait temporairement de la RAM dans le même espace d'adressage que la ROM du BIOS. Le code du BIOS copiait le BIOS avec une boucle, dont chaque itération lisait une adresse du BIOS et écrivait dans cette même adresse. Juste avant la boucle, le ''chipset'' était configuré par le BIOS pour que les lectures atterrissent dans la ROM du BIOS, mais que les écritures tombaient dans une mémoire RAM mappée. Une fois la copie/boucle terminée, l'adressage tombait dans la RAM aussi bien pour les lectures et écritures. Elle demandait un support du ''chipset'' et du logiciel, pour détourner les adresses temporairement. ===Interlude : les technologies d'expanded memory=== Déjà à l'époque, 1 mébioctet était une limite assez faible, même si cela n'a pas empêché à un grand dirigeant de l'industrie informatique de dire que "640K ought to be enough for anybody". Aussi, diverses solutions matérielles ont vu le jour. Elles étaient peu utilisées et ont concrètement servi à des applications spécifiques. La principale est l''''''expanded memory''''' (EMS), une solution utilisant une carte d'extension avec plusieurs modules de RAM installés dessus. Les modules étaient accédés via la technique de la commutation de banque. [[File:Expanded memory.svg|centre|vignette|upright=2|L'espace d'adressage avec ''Expanded memory''. La carte d'extension et le matériel requis ne sont pas représentés.]] Le terme ''expanded memory'' regroupe plusieurs technologies propriétaires semblables sur le principe mais différentes dans l'exécution. Les premières utilisaient des banques de 64 kibioctets, qui étaient mappées dans un bloc de 64 kibioctets libre en mémoire haute. L'usage de banques de 64 kibioctets collait bien avec l'adressage particulier du mode réel, où la mémoire était gérée par blocs de 64 kibioctets. Les technologies ultérieures utilisaient 2 à 4 pages de 16 kibioctets, qui pouvaient être placée n'importe où en mémoire conventionnelle. [[File:EmulexPersyst 4M ISA.jpeg|centre|vignette|upright=2|Carte d'extension ''Expanded memory'' de marque Emulex Persyst, de 4 mébioctets.]] L'''expanded memory'' demandait que les programmes soient codés pour utiliser cette fonctionnalité. Pour cela, ils devaient utiliser une technique de programmation nommée ''Overlay programming''. L'idée est de découper le programme en plusieurs blocs séparés, appelés des '''''overlays'''''. Certains blocs sont en permanence en RAM, mais d'autres sont soit chargés en RAM, soit stockés ailleurs. Le ailleurs est souvent le disque dur, ou dans la RAM ''expanded memory'' si elle est disponible. Le va-et-vient des ''overlays'' entre RAM et disque dur était réalisé en logiciel, par le programme lui-même. [[File:Overlay Programming.svg|centre|vignette|upright=1.5|''Overlay Programming''.]] L'utilisation de l'''expanded memory'' utilisait l'interruption logicielle 67h, une interruption peu utilisée à l'époque, qui était libre. La carte d'extension était utilisée à travers un pilote de périphérique dédié, qui était appelé via l'interruption 67h. ===Le mode protégé : l'adressage sur 24 et 32 bits=== Par la suite, le mode réel a été remplacé par le '''mode protégé'''. Il utilise la mémoire virtuelle, ce qui fait qu'on devrait le détailler dans le chapitre suivant. Il permettait aussi de dépasser la limite du mébioctet de mémoire. L'espace d'adressage en mode protégé est passé de 24 bits sur le CPU 286, à 32 bits sur le 386 et les CPU suivants. Les programmes conçus pour le mode réel ne pouvaient pas s'exécuter en mode protégé. Pour corriger cela, le 286 a introduit le '''mode virtuel 8086''', aussi appelé mode V86. Il s'agit d'une technique de virtualisation, ce qui fait qu'on en parlera en détail dans le chapitre sur la virtualisation assistée en matériel. Tout ce que l'on peut dire ici est que le mode V86 a été détourné pour que les applications DOS utilisent la mémoire au-delà du mébioctet, appelée la '''mémoire étendue'''. Les logiciels DOS accédaient à la mémoire étendue à travers un driver DOS dédié (HIMEM.SYS). Les logiciels DOS déplaçaient des données dans la mémoire étendue, puis les rapatriaient en mémoire conventionnelle quand ils en avaient besoin. Il ne faut pas confondre mémoire étendue et ''expanded memory''. La technique de ''expanded memory'' repose sur de la commutation de banque, demande une carte d'extension dédiée, fonctionne sans mode V86. A l'inverse, la mémoire étendue ne gère pas de commutation de banque, ne demande pas de carte d'extension, mais utilise le mode V86. Par contre, il est possible d'émuler l'''expanded memory'' sans carte d'extension, avec la mémoire étendue. Quelques cartes mères intégraient des techniques pour, mais une émulation logicielle était aussi possible en réécrivant l'interruption 67h. ===Le mode long : l'adressage 64 bits=== Par la suite, les processeurs ont introduit le '''mode long''', dans lequel les adresses font 64 bits. Le CPU en mode long a aussi un jeu d'instruction totalement différent, des registres en plus et d'autres fonctionnalités actives uniquement dans ce mode. Les programmes compilés pour le 64 bits s'exécutent nativement dans ce mode, alors que les programmes 32 et 16 bits s'exécutent dans un mode de compatibilité dédié. Sur les systèmes 64 bits, le bus d'adresse fait seulement 48 bits afin d'économiser des interconnexions. Avec 48 bits, il manque 64 - 48 = 16 bits d'adresses, qui ne peuvent pas être utilisés pour adresser quoi que ce soit. La règle est que ces 16 bits doivent être tous égaux : soit ils valent tous 0, soient ils sont tous à 1. Les adresses qui respectent cette contrainte sont appelées des '''adresses canoniques'''. Le résultat est que l'espace d'adressage est coupé en trois sections, comme illustré ci-dessous. * Les '''adresses canoniques basses''' ont leurs 16 bits de poids fort à 0 et sont situées en bas de l'espace d'adressage, dans les premières adresses. * Les '''adresses canoniques hautes''' ont leurs 16 bits de poids fort à 1 et sont situées au sommet de l'espace d'adressage, dans les dernières adresses. * Entre les deux, se trouvent des '''adresses non-canoniques''', qui ne sont pas accessibles. Y accéder déclenche la levée d'une exception matérielle. {| |[[File:AMD64-canonical--48-bit.png|vignette|Espace d'adressage x86-64 bits, avec des adresses physiques de 48 bits.]] |[[File:AMD64-canonical--56-bit.png|vignette|Espace d'adressage x86-64 bits, avec des adresses physiques de 57 bits.]] |} : Les futurs systèmes x86 devraient passer à des adresses de 57 bits, les trois sections auront donc des frontières différentes. Les adresses non-canoniques sont censées être inutilisables. Mais les programmeurs aimeraient bien pouvoir les utiliser pour des ''pointeurs tagués'', à savoir des pointeurs/adresses associées à des informations. L'idée serait d'utiliser les 16 bits de poids fort pour stocker des informations liées au pointeur, seuls les 48 bits restant codant l'adresse. Les 16 bits peuvent être utilisés de manières très diverses. Un exemple est la technique du '''''memory tagging''''', qui crée une somme de contrôle au pointeur. La somme de contrôle est générée par un algorithme cryptographique, à partir de l'adresse du pointeur. Elle est vérifiée à chaque utilisation du pointeur. Si la somme de contrôle ne correspond pas au pointeur, une erreur est levée. Si jamais un virus ou un code malveillant modifie un pointeur, il ne saura pas comment calculer la somme de contrôle, la somme de contrôle a énormément de chances d'être incorrecte. Quelques fonctionnalités des processeurs visent à autoriser l'utilisation des adresses non-canoniques. L'idée est que les 16 bits de poids fort sont ignorées lors des accès mémoire, ce qui permet d'utiliser les 16 bits de poids fort à volonté. Il s'agit des techniques ''Top Byte Ignore'' d'ARM, ''Upper Address Ignore'' d'AMD, et ''Linear Address Masking'' d'Intel. ==La z/architecture d'IBM== IBM a sa propre gamme de ''mainframes'', qui utilisent des processeurs fait maison par IBM lui-même. Les CPU IBM ont leur propre jeu d'instruction CISC, appelé '''''z/architecture'''''. Et ce jeu d'instruction a des possibilités en termes d'adressage qui sont extrêmement intéressantes, en plus d'être assez inédites. Pour ce chapitre, nous allons nous concentrer sur une fonctionnalité : les programmes ont accès à plusieurs espaces d'adressage naturellement. L'avantage pour les programmes utilisateur n'est pas très évident, mais il l'est nettement plus pour les systèmes d'exploitation. Le système d'exploitation de ces ''mainframes'', le z/OS, profite de cette possibilité, idem pour l'hyperviseur z/VM. Il utilise cette possibilité pour donner un espace d'adressage séparé pour chaque programme en cours d'exécution. Les programmes sont alors isolés les uns des autres. Cette '''isolation des processus''' n'est pas spécifique à IBM : tous les systèmes d'exploitation modernes l'utilisent. Mais les processeurs x86 doivent se débrouiller seulement avec des techniques dites de mémoire virtuelle, alors que les ''mainframes'' IBM complètent la mémoire virtuelle avec des facilités en plus. Pour les programmes utilisateurs, un intérêt est que cela permet de partager des données entre plusieurs programmes. Ils ont un espace d'adressage rien qu'à eux, mais les données partagées sont placées dans un espace d'adressage commun, auquel les deux programmes ont accès. L'espace d'adressage partagé peu même être configuré de manière à ne contenir que des données, il est impossible d'exécuter du code depuis cet espace d'adressage. Ce qui empêche certains ''malwares'' de fonctionner. ===Les espaces d'adressage par défaut de la ''z/architecture''=== La ''z/architecture'' supporte 65536 espaces d'adressage simultanés. Les espaces d'adressage sont numérotés, ils ont chacun un identifiant codé sur 16 bits, appelé un '''''Address-Space Number'''''. Nous utiliserons l’abréviation ASN dans ce qui suit. Par contre, un programme ne peut pas utiliser tout ces espaces d'adressages, il ne peut en utiliser que 16 en même temps, maximum. De plus, ils ne sont accessibles que quand le processeur est dans un mode de fonctionnement bien précis. Pour utiliser un espace d'adressage, un programme doit connaitre l'ASN associé. Par défaut, un programme exécuté sur la ''z/architecture'' utilise deux espaces d'adressages : l''''espace d'adressage primaire''' où se situent le programme exécuté et les données importantes, un '''espace d'adressage secondaire''' utilisé pour des tâches/données annexes. : Ils ont aussi accès à un '''espace d'adressage ''home''''' unique qui est surtout utilisé par le système d'exploitation, qui a pour particularité de ne pas avoir d'ASN. Il n'est concrètement accesible qu'en mode superviseur, donc par le système d'exploitation. Nous n'en parlerons pas dans ce qui suit. L'ASN de ces deux espaces d'adressage est mémorisé dans les registres de contrôle du processeur. L'ASN de l'espace primaire est dans le registre de contrôle n°3, l'ASN de l'espace secondaire est dans le registre de contrôle n°4 (ils sont dans les 16 bits de poids faible, dans les deux cas). Les deux registres de controle sont initialisés au démarrage de l'application, par le système d'exploitation, ce qui fait que ce sont des espaces d'adressage par défaut. Le programme a donc accès à ces deux espaces d'adressage sans rien faire. Mais comment le processeur pour utiliser ces espaces d'adressages ? Par exemple, s'il veut faire une lecture, comment précise-t-il l'espace d'adressage courant ? La réponse est que le processeur peut être dans deux états : soit primaire, soit secondaire. Dans le premier état, toutes les lectures/écritures se font dans l'espace d'adressage primaire. Dans le second, elles se font toutes dans l'espace d'adressage secondaire. Il peut passer de l'un à l'autre en utilisant l'instruction ''Set Address Space Control''. Le processeur dispose d'instructions ''MOVE TO PRIMARY'' et ''MOVE TO SECONDARY'' pour échanger des données dans les deux espaces d'adressage. ===Les registres d'accès de la ''z/architecture''=== Un programme peut donc passer de l'espace d'adressage primaire à secondaire (ou inversement) en utilisant l’instruction ''SET ADDRESS SPACE CONTROL''. Mais il peut en profiter pour passer dans un mode où il a accès à 16 espaces d'adressage différents, en même temps. Le mode en question est appelé le '''mode ''access register'''''. Le processeur a donc quatre modes de fonctionnement : primaire, secondaire, ''home'' et ''access register''. Qui dit 16 espaces d'adressage dit 16 ASNs à mémoriser quelque part. Pour cela, le processeur dispose de 16 '''registres d'accès''', chacun mémorisant un ASN. Un accès mémoire précise quel registre d'accès utiliser, ce qui lui permet de sélectionner l'espace d'adressage voulu. Pas besoin d'utiliser ''SET ADDRESS SPACE CONTROL'' pour passer de l'un à l'autre, le programme peut gérer 16 espaces d’adressage en même temps. Ce qui est une différence importante par rapport au mode de fonctionnement précédent. : Une précision importante est que le premier registre d'accès mémorise l'ASN de l'espace d'adressage primaire. De plus, les instructions sont chargées depuis l'espace d'adressage primaire, sauf quand le CPU est en mode ''home''. Les CPU ''z/architecture'' disposent de 16 registres généraux de 64 bits, de 16 registres flottants eux aussi de 64 bits, de 16 registres de contrôle eux aussi de 64 bits, et de '''registres d'accès''' qui gèrent les espaces d'adressage. Chaque registre entier est associé à un espace d'adressage particulier, qui est indiqué par son registre d'accès. Les registres d'accès sont utilisés lors des lectures/écritures indirectes. L'adresse est lue depuis un registre général, et son registre d'accès est consulté pour déterminer l'espace d'adressage. Une implémentation simple numéroterait les espaces d'adressage et mettrait les numéros dans les registres d'accès. Les registres d'accès seraient idéalement protégés en écriture et seulement accessibles par le système d'exploitation. Mais ce n'est pas ce qui est fait, pour des raisons de sécurité. Les registres d'accès mémorisent bien un identifiant, mais celui-ci ne sélectionne pas l'espace d'adressage directement. L'identifiant est envoyée à une table de correspondance matérielle, qui mémorise les correspondances entre un identifiant et un espace d'adressage. Il y a une table de correspondance par programme, ce qui fait qu'un identifiant ne correspondra pas au même espace d'adressage dans deux programmes différents. La table de correspondance mémorise aussi des droits d'accès, qui sont différents pour chaque programme. Par exemple, tel programme aura accès en lecture seule à tel espace d'adressage, alors qu'un autre programme y aura accès en lecture et écriture, etc. Un espace d'adressage peut ainsi être partagé entre deux programmes plus facilement, tout en ajoutant des garanties de sécurité. L'usage des registres d'accès est aussi plus flexible qu'on ne pourrait le croire. Le programme peut lire et écrire dans les registres d'accès, copier un registre d'accès dans un autre, etc. Par contre, la table de correspondance empêche à un programme d’accéder à un espace d'adressage arbitraire. Le système d'exploitation remplit la table de manière à n'autoriser l'accès qu'aux espaces d'adressage adéquats, il y enregistre seulement les correspondances utiles. Si un programme forge un identifiant arbitraire, et l'envoie à la table de correspondance, celle-ci n'aura pas la correspondance associée et l'accès mémoire sera invalidé. La table de correspondance est en réalité une table à deux niveaux. Une première table reçoit l'ASN et fournit l'adresse de la seconde table. La seconde table récupère alors l'adresse. Vu que le processeur utilise la mémoire virtuelle, c'est une adresse dite virtuelle, mais laissons cela pour le prochain chapitre. La première table est appelée l'''''access list''''', alors que la seconde est appelée l''''''ASN-Second-Table-Entry Sequence Number'''''. Les deux tables sont accessibles en écriture via une instruction ''LOAD ADDRESS SPACE PARAMETERS'', seulement accesible en mode noyau. : Il faut noter que si l'''access list'' a une taille limitée à 64 kibioctets, la seconde n'est pas limitée, ce qui permet de dépasser la limite des 655536 ASNs. La limite de 65535 ASNs est uen limite par programme, pas totale. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs 8 bits et moins | prevText=Les processeurs 8 bits et moins | next=L'abstraction mémoire et la mémoire virtuelle | nextText=L'abstraction mémoire et la mémoire virtuelle }} </noinclude> a8yppleyiz3jra1cccn6zadmfegz7fj 774368 774367 2026-10-07T14:53:44Z Mewtow 31375 /* Les espaces d'adressage par défaut de la z/architecture */ 774368 wikitext text/x-wiki L''''espace d'adressage''' du processeur est l'ensemble des adresses utilisables par le processeur. Par exemple, un processeur 16 bits peut adresser 2^16 = 65536 adresses, l'ensemble de ces adresses forme son espace d'adressage. L'espace d'adressage n'est pas toujours égal à la mémoire réellement installée. S'il n'y a pas assez de RAM installée, des adresses seront inoccupées. De plus, une partie de l'espace d'adressage peut être détourné pour communiquer avec les périphériques, comme nous le verrons plus bas. Nous verrons aussi qu'un processeur peut avoir plusieurs espaces d'adressages séparés. ==L'adressage de la RAM et de la ROM== Avoir plusieurs espaces d'adressage spécialisés est quelque chose que nous avons déjà rencontré dans les chapitres précédents, mais sans le dire ouvertement. Aussi, nous allons faire quelques rappels sur les cas déjà rencontrés. En premier lieu, nous allons rappeler la différence entre architectures Von Neumann et Hardvard. La différence entre les deux tient dans l'adressage des mémoires RAM et ROM : est-ce qu'elles sont dans un seul espace d'adressage, ou dans des espaces d'adressage séparés. L''''architecture Von Neumann''' a un seul espace d'adressage, découpé entre la mémoire RAM d'un côté et la mémoire ROM de l'autre. Une adresse correspond soit à la mémoire RAM, soit à la mémoire ROM, mais pas aux deux. Typiquement, la mémoire ROM est placée dans les adresses hautes, les plus élevées, alors que la RAM est placée dans les adresses basses en commençant par l'adresse 0. C'est une convention qui n'est pas toujours respectée, aussi mieux vaut éviter de la tenir pour acquise. [[File:Vision de la mémoire par un processeur sur une architecture Von Neumann.png|centre|vignette|upright=2|Vision de la mémoire par un processeur sur une architecture Von Neumann.]] L''''architecture Harvard''' utilise des espaces d'adressage séparés pour la RAM et la ROM. Une même adresse peut correspondre soit à la ROM, soit à la RAM. Le processeur voit bien deux mémoires séparées, chacune dans son propre espace d'adressage. Les deux espaces d'adressage n'ont pas forcément la même taille. Il est possible d'avoir un plus gros espace d'adressage pour la RAM que pour la ROM. Cela implique que les adresses des instructions et des données soient de taille différentes. C'est peu pratique et c'est rarement implémenté, ce qui fait que le cas le plus courant est celui où les deux espaces d'adressages ont la même taille. [[File:Vision de la mémoire par un processeur sur une architecture Harvard.png|centre|vignette|upright=2|Vision de la mémoire par un processeur sur une architecture Harvard.]] Sur une '''architecture Harvard modifiée''', le programme est placé dans un espace d'adressage à part du reste. Le problème, c'est que cet espace d'adressage ne contient pas que le code machine à exécuter. Il contient aussi des constantes, à savoir des données qui gardent la même valeur lors de l'exécution du programme. Elles peuvent être lues, mais pas modifiées durant l'exécution du programme. L'accès à ces constantes demande d'aller lire celles-ci dans l'autre espace d'adressage, pour les copier dans les registres généraux. Le processeur doit pour cela avoir des instructions de lecture dédiées. En clair, il y a une instruction LOAD pour lire dans la mémoire ROM, une autre pour lire dans la RAM. [[File:Espaces d'adressage sur une archi harvard modifiée.png|centre|vignette|upright=3|Espaces d'adressage sur une archi harvard modifiée]] Une autre solution utilise des instructions de copie, qui copient les constantes en mémoire RAM, donc d'un espace d'adressage à l'autre. [[File:Organisation des espaces d'adressage sur une archi harvard modifiée.png|centre|vignette|upright=3|Organisation des espaces d'adressage sur une archi harvard modifiée]] ==L'adressage des périphériques== Passons maintenant à l'adressage des périphériques. La communication avec les périphériques se fait par l'intermédiaire de registres d’interfaçage. Et ces registres peuvent soit avoir un espace d'adressage séparé, soit être inclus dans l'espace d'adressage des mémoires. Dans ce qui suit, nous allons supposer que l'architecture des de type Von Neumann pour simplifier les explications. ===Les entrées-sorties mappées en mémoire=== Une partie de l'espace d'adressage peut être détourné pour communiquer avec les périphériques. Le périphérique se retrouve inclus dans l'ensemble des adresses utilisées pour manipuler la mémoire : on dit qu'il est mappé en mémoire. Les adresses mémoires associées à un périphérique sont redirigées automatiquement vers les registres du périphérique en question, voire vers sa mémoire intégrée. On parle alors d''''entrées-sorties mappées en mémoire'''. [[File:IO mappées en mémoire.png|centre|vignette|upright=2.0|IO mappées en mémoire]] On remarque un défaut inhérent à cette technique : les adresses utilisées pour les périphériques ne sont plus disponibles pour la RAM. On ne peut plus adresser autant de RAM qu'avant. La perte dépend des périphériques installés et de leur gourmandise en adresses mémoires. C'est ce qui causait un problème assez connu sur les ordinateurs 32 bits, capables d'adresser 4 gibioctets de mémoire. Certaines personnes installaient 4 gigaoctets de mémoire sur leur ordinateur 32 bits et se retrouvaient avec « seulement » 3,5 à 3,8 gigaoctets de mémoire, les périphériques prenant le reste. Il est possible que la RAM d'un périphérique soit mappée en RAM. Un exemple classique est celui des cartes graphiques qui incorporent une mémoire RAM appelée la mémoire vidéo. La mémoire vidéo est mappée en mémoire, ce qui permet au processeur d'écrire directement dedans. Toute lecture ou écriture dans les adresses associées est redirigée vers le bus PCI/AGP/PCI-Express. Nous verrons plus bas des exemples d'ordinateurs où la mémoire vidéo est mappée en mémoire, que ce soit totalement ou partiellement. Si je dis totalement ou partiellement, c'est parce que les cartes graphiques modernes disposent de tellement de mémoire qu'on ne peut pas la mapper totalement dans l'espace d'adressage. Sur les systèmes d'avant 2008, seuls 256 mégaoctets de mémoire vidéo sont mappés en mémoire RAM. Le reste de la mémoire vidéo est invisible du point de vue du processeur, mais manipulable par le GPU à sa guise. Après 2008, la spécification du PCI-Express ajouta un support de la technologie ''Resizable Bar'', qui permet au processeur d’accéder directement à plus de 256 mégaoctets de mémoire vidéo, voire à la totalité de la mémoire vidéo. ===L'espace d'adressage séparé pour les entrées-sorties=== Les entrées-sorties et périphériques peuvent avoir leur propre espace d'adressage dédié, séparé de celui utilisé pour les mémoires RAM et ROM. [[File:Espaces_d'adressages_séparés_entre_mémoire_et_périphérique.png|centre|vignette|upright=3|Bit IO.]] Une même adresse peut donc adresser soit une entrée-sortie, soit une case mémoire. Et pour faire la différence, le processeur doit avoir des instructions séparées pour adresser les périphériques et la mémoire. Il a des instructions de lecture/écriture pour lire/écrire en mémoire, et d'autres pour lire/écrire les registres d’interfaçage. Sans cela, le processeur ne saurait pas si une adresse est destinée à un périphérique ou à la mémoire. Cela élimine aussi les problèmes avec les caches : les accès à l'espace d'adressage de la RAM passent par l'intermédiaire de la mémoire cache, alors les accès dans l'espace d'adressage des périphériques le contournent totalement. Là encore, les deux espaces d'adressage n'ont pas forcément la même taille. Il arrive que les deux espaces d'adressage aient la même taille, le plus souvent sur des ordinateurs complexes avec beaucoup de périphériques. Mais les systèmes embarqués ont souvent des espaces d'adressage plus petits pour les périphériques que pour la ou les mémoires. L'implémentation varie grandement suivant le cas, la première méthode imposant d'avoir deux bus séparés pour les mémoires et les périphériques, l'autre permettant un certain partage du bus d'adresse. Nous reviendrons dessus plus en détail dans le chapitre sur l'adressage des périphériques. ==La ''memory map'' d'un ordinateur== Les deux sections précédentes nous ont appris que l'on peut utiliser un espace d'adressage séparé pour la ROM et un autre pour les périphériques. En tout, cela donne quatre possibilités distinctes. {|class="wikitable" |- ! ! IO mappées en mémoire ! IO séparées |- ! ROM mappée en mémoire (architecture Von Neumann) | Un seul espace d'adressage | Deux espaces d'adressage : * un pour les mémoires RAM/ROM ; * un pour les IO. |- ! ROM séparée (architecture Harvard) | Deux espaces d'adressage : * un pour la RAM et les IO ; * un pour la ROM. | Trois espaces d'adressages : * un pour la RAM ; * un pour la ROM ; * un pour les IO. |} Les quatre solutions ont des avantages et inconvénients divers, mais il est intéressant de contraster un espace d'adressage unique avec plusieurs espaces d'adressages. ===L'espace d'adressage unique=== Avec un espace d'adressage unique, la ROM est au sommet de l'espace d'adressage, les périphériques sont juste en-dessous, la RAM commence à l'adresse 0 et prend les adresses basses. Faire simplifie grandement l'implémentation matérielle de l'adressage. Notons que d'autres composants que les périphériques ou les mémoires peuvent se trouver dans l'espace d'adressage. On peut y trouver les horloges temps réels, des timers, des senseurs de température, ou d'autres composants placés sur la carte mère. Un exemple un peu original est le suivant : la console de jeu Nintendo DS incorporait une unité de calcul spécialisée dans les divisions et racines carrées, séparée du processeur, qui était justement mappée en mémoire ! [[File:Espace d'adressage classique avec entrées-sorties mappées en mémoire.png|centre|vignette|upright=2|Espace d'adressage classique avec entrées-sorties mappées en mémoire]] L'espace d'adressage unique est plus simple pour les programmeurs, malgré un désavantage dont il faut parler. Le problème est que des adresses censées être disponibles pour la RAM sont détournées vers la ROM ou les périphériques, ce qui limite la quantité de RAM qui peut être réellement adressée en pratique. C'est un défaut qui se manifeste seulement pour les petits espaces d'adressages, de 8 à 16 bits, qui ne permettent pas d'adresser beaucoup de RAM. Avec des adresses codées sur 16 bits, un espace d'adressage de 64 kibioctets de RAM est grignoté par des entrées-sorties assez rapidement. Par contre, pour des adresses de 32 à 64 bits, une bonne partie de l'espace d'adressage est inutilisé, car il n'y a pas assez de RAM. L'espace d'adressage unique n'a donc pas de désavantages. Les vielles consoles de jeux et certains vieux ordinateurs (Commodores et Amiga), utilisaient un espace d'adressage unique. Elles n'avaient pas une variété de cartes graphiques ou de cartes sons différentes, comme sur les PCs modernes. Au contraire, elles avaient la même configuration matérielle, le matériel ne pouvait pas être changé ni upgradé. Le système d'exploitation était rudimentaire et ne contrôlait pas vraiment l'accès au matériel. Les programmeurs avaient donc totalement accès au matériel et mapper les entrées/sorties en mémoire rendait la programmation des périphériques très simple. <gallery widths=200px heights=500px> N5200mk2 memory map.svg|Adressage mémoire (carte mémoire) du N5200mk2. PC-9801VM memory map.svg|Adressage mémoire (carte mémoire) du PC-9801VM. </gallery> ===La commutation de banques (''bank switching'')=== Le '''''bank switching''''', aussi appelé '''commutation de banque''', permet d'utiliser plusieurs espaces d'adressage sur un même processeur, sans attribuer chaque espace d'adressage pour une raison précise. L'espace d'adressage est présent en plusieurs exemplaires appelés des '''banques'''. Les banques sont numérotées, chaque numéro de banque permettant de l'identifier et de le sélectionner. Le but de cette technique est d'augmenter la mémoire disponible pour l'ordinateur. Par exemple, supposons que j'ai besoin d'adresser une mémoire ROM de 4 kibioctets, une RAM de 8 kibioctets, et divers périphériques. Le processeur a un bus d'adresse de 12 bits, ce qui limite l'espace d'adressage à 4 kibioctets. Dans ce cas, je peux réserver 4 banques : une pour la ROM, une pour les périphériques, et deux banques qui contiennent chacune la moitié de la RAM. La simplicité et l'efficacité de cette technique font qu'elle est beaucoup utilisée dans l'informatique embarquée. [[File:PC-8801MemoryMap.gif|centre|vignette|upright=2|exemple de Bank switching.]] Cette technique demande d'utiliser un bus d'adresse plus grand que les adresses du processeur. L'adresse réelle se calcule en concaténant le numéro de banque avec l'adresse accédée. Le numéro de la banque actuellement en cours d'utilisation est mémorisé dans un registre appelé le '''registre de banque'''. On peut changer de banque en changeant le contenu de ce registre. Le processeur dispose souvent d'instructions spécialisées qui en sont capables. {| class="wikitable flexible" |[[File:Banque mémoire.png|Banque mémoire.]] |[[File:Registre de banque.png|Registre de banque.]] |} ==Le placement des programmes dans l'espace d'adressage== Les ordinateurs modernes sont des architectures Von Neumann, avec un seul espace d'adressage, qui est partagé suivant les besoins. Et un point important est que le système d'exploitation et les programmes sont copiés en mémoire RAM avant d'être exécutés. Ils ne sont pas exécutés depuis une mémoire ROM, cela est réservé aux systèmes embarqués simples. Et cela a une influence sur l'espace d'adressage. Les programmes et le système d'exploitation doivent se partager l'espace d'adressage. Voyons voir comment ce partage se fait. Dans ce chapitre, nous allons volontairement mettre de côté les ordinateurs qui supportent la mémoire virtuelle. Nous en parlerons dans le prochain chapitre. Le principe est de voir comment un processeur avec un seul espace d'adressage gèrent la présence d'un ou de plusieurs programmes. ===Un seul espace d'adressage, non-partagé=== Le cas le plus simple est celui où il n'y a pas de système d'exploitation et où un seul programme s'exécute sur l'ordinateur. Le programme a alors accès à tout l'espace d'adressage, sauf pour ce qui est réservé aux périphériques et à la mémoire ROM. Le programme organise la mémoire en plusieurs sections, dans lesquelles le programme range des données différentes. Typiquement, on trouve quatre blocs de mémoire, appelés des segments, chacun étant spécialisé pour les données, le code, la pile, etc. Voici ces trois sections : * Le segment '''''text''''' 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. * Le segment pour la '''pile''', de taille variable. * le reste est appelé le '''tas''', de taille variable. [[File:Organisation d'un espace d'adressage unique utilisé par un programme unique.png|centre|vignette|upright=2|Organisation d'un espace d'adressage unique utilisé par un programme unique]] [[File:Typical computer data memory arrangement.png|vignette|upright=0.5|Typical computer data memory arrangement]] La pile et le tas sont de taille variable, ce qui veut dire qu'ils peuvent grandir ou diminuer à volonté, contrairement au reste. Entre le tas et la pile, on trouve un espace de mémoire inutilisée, qui peut être réquisitionné selon les besoins. La pile commence généralement à l'adresse la plus haute et grandit en descendant, alors que le tas grandit en remontant vers les adresses hautes. Il s'agit là d'une convention, rien de plus. Il est possible d'inverser la pile et le tas sans problème, c'est juste que cette organisation est rentrée dans les usages. Avant de poursuivre, précisons qu'il est possible de regrouper plusieurs programmes distincts dans un seul, afin qu'ils partagent le même morceau de mémoire. Les programmes portent alors le nom de '''threads'''. Les ''threads'' d'un même processus partagent le même espace d'adressage. Ils partagent généralement certains segments : ils se partagent le code, le tas et les données statiques. Par contre, chaque thread dispose de sa propre pile d'appel. [[File:Single vs multithreaded processes.jpg|centre|vignette|upright=2.0|Distinction entre processus mono et multi-thread.]] Il va de soi que cette vision de l'espace d'adressage ne tient pas compte des périphériques. C'est-à-dire que les schémas précédents partent du principe qu'on a un espace d'adressage séparé pour les périphériques. Dans le cas où les entrées-sorties sont mappées en mémoire, l'organisation est plus compliquée. Généralement, les adresses associées aux périphériques sont placées juste au-dessus de la pile, dans les adresses hautes. ===Un seul espace d'adressage, partagé avec le système d'exploitation=== [[File:Gestion de la mémoire sur les OS monoprogrammés.png|vignette|upright=1.0|Gestion de la mémoire sur les OS monoprogrammés.]] Maintenant, étudions le cas où le programme partage la mémoire avec le système d'exploitation. Sur les systèmes d'exploitation les plus simples, on ne peut lancer qu'un seul programme à la fois. Le système d'exploitation réserve une portion de taille fixe réservée au système d'exploitation, alors que le reste de la mémoire est utilisé pour le programme à exécuter. Le programme est placé à un endroit en RAM qui est toujours le même. Le système d'exploitation peut être soit placé dans une ROM, soit copié en mémoire RAM depuis une mémoire de masse, comme le programme à lancer. Si le système d'exploitation est copié en mémoire RAM, il est généralement placé dans les premières adresses, les adresses basses. A l'inverse, un OS en mémoire ROM est généralement placé à la fin de la mémoire, dans les adresses hautes. Mais tout cela n'est qu'une convention, et les exceptions sont monnaie courante. [[File:Méthodes d'allocation de la mémoire avec un espace d'adressage unique.png|centre|vignette|upright=2.0|Méthodes d'allocation de la mémoire avec un espace d'adressage unique]] Sur de tels systèmes, il faut protéger l'OS contre une erreur ou malveillance d'un programme utilisateur. Notons qu'il s'agit d'une protection en écriture, pas en lecture. Le programme peut parfaitement lire les données de l'OS sans problèmes, au moins en partie. Dans ce qui suit, on part du principe que l'OS est dans les adresses basses. La solution la plus courante interdit au programme d'écrire au-delà d'une limite au-delà de laquelle se trouve le système d’exploitation. Pour cela, le processeur incorpore un '''registre limite''', qui contient l'adresse limite au-delà de laquelle un programme peut pas écrire. Pour toute écriture en espace utilisateur, l'adresse écrite est comparée au registre limite. Si l'adresse est inférieure au registre limite, le programme cherche écrire dans la mémoire réservée à l'OS. L’accès mémoire est interdit, une exception matérielle est levée et l'OS affiche un message d'erreur. [[File:Circuit total.png|centre|vignette|upright=2.0|Protection mémoire avec un registre limite.]] ===Un seul espace d'adressage, partagé entre plusieurs programmes=== Les systèmes d’exploitation modernes implémentent la '''multiprogrammation''', le fait de pouvoir lancer 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. Et il faut alors résoudre un problème : le partage de la RAM entre les différents programmes. Les différents programmes et leurs données sont placés en mémoire RAM, et il faut trouver un moyen pour répartir les différents programmes en RAM. [[File:Partitions mémoire.png|vignette|upright=1|Partitions mémoire]] Il y a plusieurs solutions pour cela, mais l'une d'entre elle est l'usage de segments mémoire. Avec elle, chaque programme se voit attribuer un ou plusieurs blocs de mémoire. Ils sont appelés des '''partitions mémoire''', ou encore des '''segments'''. Dans ce qui va suivre, nous partons du principe qu'un programme est égal à un segment, pour simplifier les explications. Mais sachez qu'un programme peut être éclaté en plusieurs segments dispersés dans la mémoire, et même être conçu pour ! Nous en reparlerons dans le chapitre sur le mémoire virtuelle. Le système d'exploitation mémorise une liste des segments importants en mémoire RAM, appelée la ''table des segments''. Il s'agit d'un tableau dont chaque entrée mémorise les informations pour un segment. Une entrée de ce tableau est appelée un '''descripteur de segment''' et contient son adresse de base, sa taille, et des autorisations liées à la protection mémoire. Précisément, l'entrée numéro N mémorise les informations du segment numéro N. A partir de l'adresse de base de la table des segments et du numéro de segment, on peut lire ou écrire un descripteur de segment. [[File:Global Descriptor table.png|centre|vignette|upright=2|Global Descriptor table]] Toutefois, l'usage de segments amène un paquet de problèmes qu'il faut résoudre au mieux. Le premier problème est tout simplement de placer les segments dans l'espace d'adressage, mais c'est quelque chose qui est du ressort du système d'exploitation. Par contre, cela implique qu'un segment peut être placé n'importe où en RAM et sa position en RAM change à chaque exécution. En conséquence, les adresses des branchements et des données ne sont jamais les mêmes d'une exécution à l'autre. Pour résoudre ce problème, le compilateur considère que le segment commence à l'adresse zéro, puis les adresses sont corrigées avant ou pendant l'exécution du programme. Cette correction est en général réalisée par l'OS lors de la copie du programme en mémoire, mais il existe aussi des techniques matérielles, que nous verrons dans le chapitre suivant. Il faut aussi prendre en compte le phénomène de l''''allocation mémoire'''. Derrière ce nom barbare, se cache quelque chose de simple : les programmes peuvent réclamer de la mémoire au système d'exploitation, pour y placer des données. Les langages de programmation bas niveau supportent des fonctions comme malloc(), qui permettent de demander un bloc de mémoire de N octets à l'OS, qui doit alors accommoder la demande. S'il n'y a pas assez de mémoire, l'appel échoue et le programme doit gérer la situation. De même, un programme peut libérer de la mémoire qu'il n'utilise plus avec des fonctions comme free(). Avec des segments, cela revient à changer la taille d'un segment, plus précisément à la fin du segment. Et tout cela est géré par le système d'exploitation, aussi on laisse cela pour le prochain chapitre. Enfin, sans protection particulière, les programmes peuvent techniquement lire ou écrire les données des autres. Pour éviter cela, il suffit de vérifier qu'un programme accède bien à son segment et pas ailleurs. Il suffit de vérifier si l'adresse lue/écrite est dans le bon intervalle. Il suffit alors d'utiliser deux registres limites, rien de plus. Mais au-delà de cette simple vérification, si un programme pouvait modifier les données d'un autre programme, on se retrouverait rapidement avec une situation non prévue par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses. Il faut donc introduire des mécanismes de protection mémoire, pour isoler les programmes les uns des autres, et éviter toute modification problématique. La protection mémoire regroupe plusieurs techniques assez variées, qui ont des objectifs différents. Elles ont pour point commun de faire intervenir à des niveaux divers le système d'exploitation et le processeur. ==La ''memory map'' des PC IBM x86== Dans ce qui suit, nous allons étudier l'espace d'adressage des processeurs x86. L'organisation de leur espace d'adressage a évolué dans le temps et l'espace d'adressage s'est adapté. Mais il s'est adapté sans modifier l'existant, pour des raisons de compatibilité. Aussi, les processeurs x86 modernes peuvent fonctionner dans plusieurs modes, appelés mode réel, protégé, virtuel 8086, long, ''system management mode'', et compatibilité 32 bits. [[File:AMD64StateDiagram.svg|centre|vignette|upright=2|AMD64StateDiagram]] Le ''system management mode'' a déjà été abordé dans le chapitre sur les interruptions. Pour simplifier, il ne s'agit pas d'un véritable mode au sens "adressage". Dans ce mode, le système d'exploitation est mis en pause et le BIOS ou un autre firmware s'exécute sans restriction. On y rentre grâce à une interruption dédiée, lancée par le noyau de l'OS. Le système d'exploitation exécute cette interruption afin de laisser la main au BIOS, pour lui déléguer certaines tâches. Par contre, les autres modes sont bien plus intéressant, car ils ont chacun un espace d'adressage différent. Voyons-les dans le détail. ===Le mode réel : l'adressage sur 20 bits=== Le tout premier mode était le '''mode réel''', qui utilisait des adresses de 20 bits, ce qui fait 1 mébioctet de mémoire adressable. Il était utilisé sur des processeurs 16 bits, qui géraient donc des adresses de 16 bits capables d'adresser 64 kibioctets de mémoire. Pour contourner cette limitation, le processeur utilisait la segmentation, que nous verrons dans le prochain chapitre. Pour le moment, nous allons dire que la mémoire en mode réel était composée de 16 blocs de 64 kibioctets. C'est une simplification qui ne fera pas de mal. Le processeur démarre systématiquement en mode réel, y compris sur les ordinateurs modernes. Le BIOS s'exécute dans ce mode, avant de passer en mode protégé et de laisser la main à l'UEFI et au système d'exploitation. Autrefois, le système d'exploitation s'exécutait aussi en mode réel, c'est le cas du DOS. En mode réel, l'espace d'adressage est peuplé par de la RAM, la ROM du BIOS et des périphériques. La RAM prenait les adresses basses, la ROM était au sommet de l'espace d'adressage, et les périphériques entre les deux. Les premiers 640 kibioctets sont réservés à la RAM et sont appelés la '''mémoire conventionnelle'''. Les octets restants forment la '''mémoire haute''', réservée aux ROM et aux périphériques. Les deux premiers kibioctets de la mémoire conventionnelle sont initialisés au démarrage de l'ordinateur. Ils sont utilisés pour stocker le vecteur d'interruption (on expliquera cela dans quelques chapitres) et servent aussi au BIOS. La portion réservée au BIOS, la ''BIOS Data Area'', mémorise des informations en RAM. Elle commence à l'adresse 0040:0000h, a une taille de 255 octets, et est initialisée lors du démarrage de l'ordinateur. Le reste de la mémoire conventionnelle est réservée au le système d'exploitation (MS-DOS, avant sa version 5.0) et au programme en cours d’exécution. La mémoire haute est sectionnée en deux portions. Le sommet de la mémoire haute est réservé au BIOS, alors que le bas de la mémoire haute est réservé aux périphériques. Dans cette dernière, on trouve les BIOS des périphériques (dont celui de la carte vidéo, s'il existe), qui sont nécessaires pour les initialiser et pour communiquer avec eux. De plus, on y trouve la mémoire de la carte vidéo et éventuellement la mémoire d'autres périphériques comme la carte son. {| class="wikitable" style="text-align:center;" |+ Espace d'adressage en mode réel |- ! Numéro du bloc !! Contenu du bloc |- | 1024 - 960 Kio || class="f_jaune" | BIOS principal (ROM) |- | 960 Kio - 640 Kio | class="f_vert" | ROM d'extension des périphériques (XT, EGA, 3270 PC) </br> Mémoire vidéo des cartes EGA, MDA ou CGA |- | 640 Kio - 0 || class="f_rouge" | Mémoire RAM, mémoire conventionnelle |} Pour détailler un peu plus, il faut tenir compte du découpage de l'espace d'adressage en blocs de 64 kibioctets. Le découpage en blocs de 64 kibioctets est particulièrement important pour la mémoire haute. Des blocs sont attribués à un périphérique spécifique. Par exemple, le 16ème bloc est réservé au BIOS, le 12ème aux ROM des périphériques, le 10ème et le 11ème à la mémoire vidéo, etc. {| class="wikitable" style="text-align:center;" |+ Espace d'adressage en mode réel |- ! Numéro du bloc !! Contenu du bloc |- | 0 || rowspan="10" class="f_rouge" | Mémoire RAM, mémoire conventionnelle |- | 1 |- | 2 |- | 3 |- | 4 |- | 5 |- | 6 |- | 7 |- | 8 |- | 9 |- | 10 || class="f_vert" | Mémoire vidéo des cartes EGA |- | 11 || class="f_vert" | Mémoire vidéo des cartes MDA ou CGA |- | 12 || class="f_vert" | ROM d'extension des périphériques (XT, EGA, 3270 PC) |- | 13 || class="f_gris" | Autre, non-réservé |- | 14 || class="f_gris" | Autre, non-réservé |- | 15 || class="f_jaune" | ROM du BIOS |} Vous remarquerez que les blocs 13 et 14 sont non-réservés. Ils peuvent être utilisés pour mapper des périphériques en mémoire. Mais si aucun périphérique n'est mappé dedans, les blocs sont laissés libres. Les OS n'hésitaient pas à les utiliser pour adresser de la RAM. Par exemple, si l'ordinateur n'a pas de carte vidéo EGA, le bloc qui leur était réservé était réutilisé comme mémoire RAM. La mémoire conventionnelle passait alors de 640 à 704 kibioctets de RAM disponibles. L'espace d'adressage correspond à la ROM du BIOS. Mais avec le temps, la ROM est devenue plus lente au point qu'exécuter du code directement depuis la ROM du BIOS était un problème niveau performance. Je rappelle que le BIOS contient de nombreuses routines d'interruptions qui étaient appelées par les systèmes d'exploitation de l'époque, notamment le DOS. Heureusement, diverses optimisations permettaient de copier le BIOS dans la mémoire RAM, plus rapide que la ROM du BIOS. Il s'agit des techniques de '''''BIOS Shadowing'''''. Elles s'appliquaient au BIOS proprement dit, mais le BIOS vidéo pouvait aussi être optimisé ainsi, mais les ROM d'extension ne le sont généralement pas. Le ''BIOS Shadowing'' peut s'implémenter de plusieurs manières, mais la plus courante mappait temporairement de la RAM dans le même espace d'adressage que la ROM du BIOS. Le code du BIOS copiait le BIOS avec une boucle, dont chaque itération lisait une adresse du BIOS et écrivait dans cette même adresse. Juste avant la boucle, le ''chipset'' était configuré par le BIOS pour que les lectures atterrissent dans la ROM du BIOS, mais que les écritures tombaient dans une mémoire RAM mappée. Une fois la copie/boucle terminée, l'adressage tombait dans la RAM aussi bien pour les lectures et écritures. Elle demandait un support du ''chipset'' et du logiciel, pour détourner les adresses temporairement. ===Interlude : les technologies d'expanded memory=== Déjà à l'époque, 1 mébioctet était une limite assez faible, même si cela n'a pas empêché à un grand dirigeant de l'industrie informatique de dire que "640K ought to be enough for anybody". Aussi, diverses solutions matérielles ont vu le jour. Elles étaient peu utilisées et ont concrètement servi à des applications spécifiques. La principale est l''''''expanded memory''''' (EMS), une solution utilisant une carte d'extension avec plusieurs modules de RAM installés dessus. Les modules étaient accédés via la technique de la commutation de banque. [[File:Expanded memory.svg|centre|vignette|upright=2|L'espace d'adressage avec ''Expanded memory''. La carte d'extension et le matériel requis ne sont pas représentés.]] Le terme ''expanded memory'' regroupe plusieurs technologies propriétaires semblables sur le principe mais différentes dans l'exécution. Les premières utilisaient des banques de 64 kibioctets, qui étaient mappées dans un bloc de 64 kibioctets libre en mémoire haute. L'usage de banques de 64 kibioctets collait bien avec l'adressage particulier du mode réel, où la mémoire était gérée par blocs de 64 kibioctets. Les technologies ultérieures utilisaient 2 à 4 pages de 16 kibioctets, qui pouvaient être placée n'importe où en mémoire conventionnelle. [[File:EmulexPersyst 4M ISA.jpeg|centre|vignette|upright=2|Carte d'extension ''Expanded memory'' de marque Emulex Persyst, de 4 mébioctets.]] L'''expanded memory'' demandait que les programmes soient codés pour utiliser cette fonctionnalité. Pour cela, ils devaient utiliser une technique de programmation nommée ''Overlay programming''. L'idée est de découper le programme en plusieurs blocs séparés, appelés des '''''overlays'''''. Certains blocs sont en permanence en RAM, mais d'autres sont soit chargés en RAM, soit stockés ailleurs. Le ailleurs est souvent le disque dur, ou dans la RAM ''expanded memory'' si elle est disponible. Le va-et-vient des ''overlays'' entre RAM et disque dur était réalisé en logiciel, par le programme lui-même. [[File:Overlay Programming.svg|centre|vignette|upright=1.5|''Overlay Programming''.]] L'utilisation de l'''expanded memory'' utilisait l'interruption logicielle 67h, une interruption peu utilisée à l'époque, qui était libre. La carte d'extension était utilisée à travers un pilote de périphérique dédié, qui était appelé via l'interruption 67h. ===Le mode protégé : l'adressage sur 24 et 32 bits=== Par la suite, le mode réel a été remplacé par le '''mode protégé'''. Il utilise la mémoire virtuelle, ce qui fait qu'on devrait le détailler dans le chapitre suivant. Il permettait aussi de dépasser la limite du mébioctet de mémoire. L'espace d'adressage en mode protégé est passé de 24 bits sur le CPU 286, à 32 bits sur le 386 et les CPU suivants. Les programmes conçus pour le mode réel ne pouvaient pas s'exécuter en mode protégé. Pour corriger cela, le 286 a introduit le '''mode virtuel 8086''', aussi appelé mode V86. Il s'agit d'une technique de virtualisation, ce qui fait qu'on en parlera en détail dans le chapitre sur la virtualisation assistée en matériel. Tout ce que l'on peut dire ici est que le mode V86 a été détourné pour que les applications DOS utilisent la mémoire au-delà du mébioctet, appelée la '''mémoire étendue'''. Les logiciels DOS accédaient à la mémoire étendue à travers un driver DOS dédié (HIMEM.SYS). Les logiciels DOS déplaçaient des données dans la mémoire étendue, puis les rapatriaient en mémoire conventionnelle quand ils en avaient besoin. Il ne faut pas confondre mémoire étendue et ''expanded memory''. La technique de ''expanded memory'' repose sur de la commutation de banque, demande une carte d'extension dédiée, fonctionne sans mode V86. A l'inverse, la mémoire étendue ne gère pas de commutation de banque, ne demande pas de carte d'extension, mais utilise le mode V86. Par contre, il est possible d'émuler l'''expanded memory'' sans carte d'extension, avec la mémoire étendue. Quelques cartes mères intégraient des techniques pour, mais une émulation logicielle était aussi possible en réécrivant l'interruption 67h. ===Le mode long : l'adressage 64 bits=== Par la suite, les processeurs ont introduit le '''mode long''', dans lequel les adresses font 64 bits. Le CPU en mode long a aussi un jeu d'instruction totalement différent, des registres en plus et d'autres fonctionnalités actives uniquement dans ce mode. Les programmes compilés pour le 64 bits s'exécutent nativement dans ce mode, alors que les programmes 32 et 16 bits s'exécutent dans un mode de compatibilité dédié. Sur les systèmes 64 bits, le bus d'adresse fait seulement 48 bits afin d'économiser des interconnexions. Avec 48 bits, il manque 64 - 48 = 16 bits d'adresses, qui ne peuvent pas être utilisés pour adresser quoi que ce soit. La règle est que ces 16 bits doivent être tous égaux : soit ils valent tous 0, soient ils sont tous à 1. Les adresses qui respectent cette contrainte sont appelées des '''adresses canoniques'''. Le résultat est que l'espace d'adressage est coupé en trois sections, comme illustré ci-dessous. * Les '''adresses canoniques basses''' ont leurs 16 bits de poids fort à 0 et sont situées en bas de l'espace d'adressage, dans les premières adresses. * Les '''adresses canoniques hautes''' ont leurs 16 bits de poids fort à 1 et sont situées au sommet de l'espace d'adressage, dans les dernières adresses. * Entre les deux, se trouvent des '''adresses non-canoniques''', qui ne sont pas accessibles. Y accéder déclenche la levée d'une exception matérielle. {| |[[File:AMD64-canonical--48-bit.png|vignette|Espace d'adressage x86-64 bits, avec des adresses physiques de 48 bits.]] |[[File:AMD64-canonical--56-bit.png|vignette|Espace d'adressage x86-64 bits, avec des adresses physiques de 57 bits.]] |} : Les futurs systèmes x86 devraient passer à des adresses de 57 bits, les trois sections auront donc des frontières différentes. Les adresses non-canoniques sont censées être inutilisables. Mais les programmeurs aimeraient bien pouvoir les utiliser pour des ''pointeurs tagués'', à savoir des pointeurs/adresses associées à des informations. L'idée serait d'utiliser les 16 bits de poids fort pour stocker des informations liées au pointeur, seuls les 48 bits restant codant l'adresse. Les 16 bits peuvent être utilisés de manières très diverses. Un exemple est la technique du '''''memory tagging''''', qui crée une somme de contrôle au pointeur. La somme de contrôle est générée par un algorithme cryptographique, à partir de l'adresse du pointeur. Elle est vérifiée à chaque utilisation du pointeur. Si la somme de contrôle ne correspond pas au pointeur, une erreur est levée. Si jamais un virus ou un code malveillant modifie un pointeur, il ne saura pas comment calculer la somme de contrôle, la somme de contrôle a énormément de chances d'être incorrecte. Quelques fonctionnalités des processeurs visent à autoriser l'utilisation des adresses non-canoniques. L'idée est que les 16 bits de poids fort sont ignorées lors des accès mémoire, ce qui permet d'utiliser les 16 bits de poids fort à volonté. Il s'agit des techniques ''Top Byte Ignore'' d'ARM, ''Upper Address Ignore'' d'AMD, et ''Linear Address Masking'' d'Intel. ==La z/architecture d'IBM== IBM a sa propre gamme de ''mainframes'', qui utilisent des processeurs fait maison par IBM lui-même. Les CPU IBM ont leur propre jeu d'instruction CISC, appelé '''''z/architecture'''''. Et ce jeu d'instruction a des possibilités en termes d'adressage qui sont extrêmement intéressantes, en plus d'être assez inédites. Pour ce chapitre, nous allons nous concentrer sur une fonctionnalité : les programmes ont accès à plusieurs espaces d'adressage naturellement. L'avantage pour les programmes utilisateur n'est pas très évident, mais il l'est nettement plus pour les systèmes d'exploitation. Le système d'exploitation de ces ''mainframes'', le z/OS, profite de cette possibilité, idem pour l'hyperviseur z/VM. Il utilise cette possibilité pour donner un espace d'adressage séparé pour chaque programme en cours d'exécution. Les programmes sont alors isolés les uns des autres. Cette '''isolation des processus''' n'est pas spécifique à IBM : tous les systèmes d'exploitation modernes l'utilisent. Mais les processeurs x86 doivent se débrouiller seulement avec des techniques dites de mémoire virtuelle, alors que les ''mainframes'' IBM complètent la mémoire virtuelle avec des facilités en plus. Pour les programmes utilisateurs, un intérêt est que cela permet de partager des données entre plusieurs programmes. Ils ont un espace d'adressage rien qu'à eux, mais les données partagées sont placées dans un espace d'adressage commun, auquel les deux programmes ont accès. L'espace d'adressage partagé peu même être configuré de manière à ne contenir que des données, il est impossible d'exécuter du code depuis cet espace d'adressage. Ce qui empêche certains ''malwares'' de fonctionner. ===Les espaces d'adressage par défaut de la ''z/architecture''=== La ''z/architecture'' supporte 65536 espaces d'adressage simultanés. Les espaces d'adressage sont numérotés, ils ont chacun un identifiant codé sur 16 bits, appelé un '''''Address-Space Number'''''. Nous utiliserons l’abréviation ASN dans ce qui suit. Par contre, un programme ne peut pas utiliser tout ces espaces d'adressages, il ne peut en utiliser que 16 en même temps, maximum. De plus, ils ne sont accessibles que quand le processeur est dans un mode de fonctionnement bien précis. Pour utiliser un espace d'adressage, un programme doit connaitre l'ASN associé. Par défaut, un programme exécuté sur la ''z/architecture'' utilise deux espaces d'adressages : l''''espace d'adressage primaire''' où se situent le programme exécuté et les données importantes, un '''espace d'adressage secondaire''' utilisé pour des tâches/données annexes. : Un troisième espace d'adressage l''''espace d'adressage ''home''''', est réservé au système d'exploitation. Il a pour particularité de ne pas avoir d'ASN. Il n'est concrètement accesible qu'en mode superviseur, donc par le système d'exploitation. Nous n'en parlerons pas dans ce qui suit. L'ASN de ces deux espaces d'adressage est mémorisé dans les registres de contrôle du processeur. L'ASN de l'espace primaire est dans le registre de contrôle n°3, l'ASN de l'espace secondaire est dans le registre de contrôle n°4 (ils sont dans les 16 bits de poids faible, dans les deux cas). Les deux registres de contrôle sont initialisés au démarrage de l'application, par le système d'exploitation, ce qui fait que ce sont des espaces d'adressage par défaut. Le programme a donc accès à ces deux espaces d'adressage sans rien faire. Mais comment le processeur pour utiliser ces espaces d'adressages ? Par exemple, s'il veut faire une lecture, comment précise-t-il l'espace d'adressage courant ? La réponse est que le processeur peut être dans deux états : soit primaire, soit secondaire. Dans le premier état, toutes les lectures/écritures se font dans l'espace d'adressage primaire. Dans le second, elles se font toutes dans l'espace d'adressage secondaire. Il peut passer de l'un à l'autre en utilisant l'instruction ''Set Address Space Control''. Le processeur dispose d'instructions ''MOVE TO PRIMARY'' et ''MOVE TO SECONDARY'' pour échanger des données dans les deux espaces d'adressage. ===Les registres d'accès de la ''z/architecture''=== Un programme peut donc passer de l'espace d'adressage primaire à secondaire (ou inversement) en utilisant l’instruction ''SET ADDRESS SPACE CONTROL''. Mais il peut en profiter pour passer dans un mode où il a accès à 16 espaces d'adressage différents, en même temps. Le mode en question est appelé le '''mode ''access register'''''. Le processeur a donc quatre modes de fonctionnement : primaire, secondaire, ''home'' et ''access register''. Qui dit 16 espaces d'adressage dit 16 ASNs à mémoriser quelque part. Pour cela, le processeur dispose de 16 '''registres d'accès''', chacun mémorisant un ASN. Un accès mémoire précise quel registre d'accès utiliser, ce qui lui permet de sélectionner l'espace d'adressage voulu. Pas besoin d'utiliser ''SET ADDRESS SPACE CONTROL'' pour passer de l'un à l'autre, le programme peut gérer 16 espaces d’adressage en même temps. Ce qui est une différence importante par rapport au mode de fonctionnement précédent. : Une précision importante est que le premier registre d'accès mémorise l'ASN de l'espace d'adressage primaire. De plus, les instructions sont chargées depuis l'espace d'adressage primaire, sauf quand le CPU est en mode ''home''. Les CPU ''z/architecture'' disposent de 16 registres généraux de 64 bits, de 16 registres flottants eux aussi de 64 bits, de 16 registres de contrôle eux aussi de 64 bits, et de '''registres d'accès''' qui gèrent les espaces d'adressage. Chaque registre entier est associé à un espace d'adressage particulier, qui est indiqué par son registre d'accès. Les registres d'accès sont utilisés lors des lectures/écritures indirectes. L'adresse est lue depuis un registre général, et son registre d'accès est consulté pour déterminer l'espace d'adressage. Une implémentation simple numéroterait les espaces d'adressage et mettrait les numéros dans les registres d'accès. Les registres d'accès seraient idéalement protégés en écriture et seulement accessibles par le système d'exploitation. Mais ce n'est pas ce qui est fait, pour des raisons de sécurité. Les registres d'accès mémorisent bien un identifiant, mais celui-ci ne sélectionne pas l'espace d'adressage directement. L'identifiant est envoyée à une table de correspondance matérielle, qui mémorise les correspondances entre un identifiant et un espace d'adressage. Il y a une table de correspondance par programme, ce qui fait qu'un identifiant ne correspondra pas au même espace d'adressage dans deux programmes différents. La table de correspondance mémorise aussi des droits d'accès, qui sont différents pour chaque programme. Par exemple, tel programme aura accès en lecture seule à tel espace d'adressage, alors qu'un autre programme y aura accès en lecture et écriture, etc. Un espace d'adressage peut ainsi être partagé entre deux programmes plus facilement, tout en ajoutant des garanties de sécurité. L'usage des registres d'accès est aussi plus flexible qu'on ne pourrait le croire. Le programme peut lire et écrire dans les registres d'accès, copier un registre d'accès dans un autre, etc. Par contre, la table de correspondance empêche à un programme d’accéder à un espace d'adressage arbitraire. Le système d'exploitation remplit la table de manière à n'autoriser l'accès qu'aux espaces d'adressage adéquats, il y enregistre seulement les correspondances utiles. Si un programme forge un identifiant arbitraire, et l'envoie à la table de correspondance, celle-ci n'aura pas la correspondance associée et l'accès mémoire sera invalidé. La table de correspondance est en réalité une table à deux niveaux. Une première table reçoit l'ASN et fournit l'adresse de la seconde table. La seconde table récupère alors l'adresse. Vu que le processeur utilise la mémoire virtuelle, c'est une adresse dite virtuelle, mais laissons cela pour le prochain chapitre. La première table est appelée l'''''access list''''', alors que la seconde est appelée l''''''ASN-Second-Table-Entry Sequence Number'''''. Les deux tables sont accessibles en écriture via une instruction ''LOAD ADDRESS SPACE PARAMETERS'', seulement accesible en mode noyau. : Il faut noter que si l'''access list'' a une taille limitée à 64 kibioctets, la seconde n'est pas limitée, ce qui permet de dépasser la limite des 655536 ASNs. La limite de 65535 ASNs est uen limite par programme, pas totale. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs 8 bits et moins | prevText=Les processeurs 8 bits et moins | next=L'abstraction mémoire et la mémoire virtuelle | nextText=L'abstraction mémoire et la mémoire virtuelle }} </noinclude> ibt3dtzv0gtfo4p5smcbwkgd4dctjvd 774375 774368 2026-10-07T16:54:07Z Mewtow 31375 /* Les espaces d'adressage par défaut de la z/architecture */ 774375 wikitext text/x-wiki L''''espace d'adressage''' du processeur est l'ensemble des adresses utilisables par le processeur. Par exemple, un processeur 16 bits peut adresser 2^16 = 65536 adresses, l'ensemble de ces adresses forme son espace d'adressage. L'espace d'adressage n'est pas toujours égal à la mémoire réellement installée. S'il n'y a pas assez de RAM installée, des adresses seront inoccupées. De plus, une partie de l'espace d'adressage peut être détourné pour communiquer avec les périphériques, comme nous le verrons plus bas. Nous verrons aussi qu'un processeur peut avoir plusieurs espaces d'adressages séparés. ==L'adressage de la RAM et de la ROM== Avoir plusieurs espaces d'adressage spécialisés est quelque chose que nous avons déjà rencontré dans les chapitres précédents, mais sans le dire ouvertement. Aussi, nous allons faire quelques rappels sur les cas déjà rencontrés. En premier lieu, nous allons rappeler la différence entre architectures Von Neumann et Hardvard. La différence entre les deux tient dans l'adressage des mémoires RAM et ROM : est-ce qu'elles sont dans un seul espace d'adressage, ou dans des espaces d'adressage séparés. L''''architecture Von Neumann''' a un seul espace d'adressage, découpé entre la mémoire RAM d'un côté et la mémoire ROM de l'autre. Une adresse correspond soit à la mémoire RAM, soit à la mémoire ROM, mais pas aux deux. Typiquement, la mémoire ROM est placée dans les adresses hautes, les plus élevées, alors que la RAM est placée dans les adresses basses en commençant par l'adresse 0. C'est une convention qui n'est pas toujours respectée, aussi mieux vaut éviter de la tenir pour acquise. [[File:Vision de la mémoire par un processeur sur une architecture Von Neumann.png|centre|vignette|upright=2|Vision de la mémoire par un processeur sur une architecture Von Neumann.]] L''''architecture Harvard''' utilise des espaces d'adressage séparés pour la RAM et la ROM. Une même adresse peut correspondre soit à la ROM, soit à la RAM. Le processeur voit bien deux mémoires séparées, chacune dans son propre espace d'adressage. Les deux espaces d'adressage n'ont pas forcément la même taille. Il est possible d'avoir un plus gros espace d'adressage pour la RAM que pour la ROM. Cela implique que les adresses des instructions et des données soient de taille différentes. C'est peu pratique et c'est rarement implémenté, ce qui fait que le cas le plus courant est celui où les deux espaces d'adressages ont la même taille. [[File:Vision de la mémoire par un processeur sur une architecture Harvard.png|centre|vignette|upright=2|Vision de la mémoire par un processeur sur une architecture Harvard.]] Sur une '''architecture Harvard modifiée''', le programme est placé dans un espace d'adressage à part du reste. Le problème, c'est que cet espace d'adressage ne contient pas que le code machine à exécuter. Il contient aussi des constantes, à savoir des données qui gardent la même valeur lors de l'exécution du programme. Elles peuvent être lues, mais pas modifiées durant l'exécution du programme. L'accès à ces constantes demande d'aller lire celles-ci dans l'autre espace d'adressage, pour les copier dans les registres généraux. Le processeur doit pour cela avoir des instructions de lecture dédiées. En clair, il y a une instruction LOAD pour lire dans la mémoire ROM, une autre pour lire dans la RAM. [[File:Espaces d'adressage sur une archi harvard modifiée.png|centre|vignette|upright=3|Espaces d'adressage sur une archi harvard modifiée]] Une autre solution utilise des instructions de copie, qui copient les constantes en mémoire RAM, donc d'un espace d'adressage à l'autre. [[File:Organisation des espaces d'adressage sur une archi harvard modifiée.png|centre|vignette|upright=3|Organisation des espaces d'adressage sur une archi harvard modifiée]] ==L'adressage des périphériques== Passons maintenant à l'adressage des périphériques. La communication avec les périphériques se fait par l'intermédiaire de registres d’interfaçage. Et ces registres peuvent soit avoir un espace d'adressage séparé, soit être inclus dans l'espace d'adressage des mémoires. Dans ce qui suit, nous allons supposer que l'architecture des de type Von Neumann pour simplifier les explications. ===Les entrées-sorties mappées en mémoire=== Une partie de l'espace d'adressage peut être détourné pour communiquer avec les périphériques. Le périphérique se retrouve inclus dans l'ensemble des adresses utilisées pour manipuler la mémoire : on dit qu'il est mappé en mémoire. Les adresses mémoires associées à un périphérique sont redirigées automatiquement vers les registres du périphérique en question, voire vers sa mémoire intégrée. On parle alors d''''entrées-sorties mappées en mémoire'''. [[File:IO mappées en mémoire.png|centre|vignette|upright=2.0|IO mappées en mémoire]] On remarque un défaut inhérent à cette technique : les adresses utilisées pour les périphériques ne sont plus disponibles pour la RAM. On ne peut plus adresser autant de RAM qu'avant. La perte dépend des périphériques installés et de leur gourmandise en adresses mémoires. C'est ce qui causait un problème assez connu sur les ordinateurs 32 bits, capables d'adresser 4 gibioctets de mémoire. Certaines personnes installaient 4 gigaoctets de mémoire sur leur ordinateur 32 bits et se retrouvaient avec « seulement » 3,5 à 3,8 gigaoctets de mémoire, les périphériques prenant le reste. Il est possible que la RAM d'un périphérique soit mappée en RAM. Un exemple classique est celui des cartes graphiques qui incorporent une mémoire RAM appelée la mémoire vidéo. La mémoire vidéo est mappée en mémoire, ce qui permet au processeur d'écrire directement dedans. Toute lecture ou écriture dans les adresses associées est redirigée vers le bus PCI/AGP/PCI-Express. Nous verrons plus bas des exemples d'ordinateurs où la mémoire vidéo est mappée en mémoire, que ce soit totalement ou partiellement. Si je dis totalement ou partiellement, c'est parce que les cartes graphiques modernes disposent de tellement de mémoire qu'on ne peut pas la mapper totalement dans l'espace d'adressage. Sur les systèmes d'avant 2008, seuls 256 mégaoctets de mémoire vidéo sont mappés en mémoire RAM. Le reste de la mémoire vidéo est invisible du point de vue du processeur, mais manipulable par le GPU à sa guise. Après 2008, la spécification du PCI-Express ajouta un support de la technologie ''Resizable Bar'', qui permet au processeur d’accéder directement à plus de 256 mégaoctets de mémoire vidéo, voire à la totalité de la mémoire vidéo. ===L'espace d'adressage séparé pour les entrées-sorties=== Les entrées-sorties et périphériques peuvent avoir leur propre espace d'adressage dédié, séparé de celui utilisé pour les mémoires RAM et ROM. [[File:Espaces_d'adressages_séparés_entre_mémoire_et_périphérique.png|centre|vignette|upright=3|Bit IO.]] Une même adresse peut donc adresser soit une entrée-sortie, soit une case mémoire. Et pour faire la différence, le processeur doit avoir des instructions séparées pour adresser les périphériques et la mémoire. Il a des instructions de lecture/écriture pour lire/écrire en mémoire, et d'autres pour lire/écrire les registres d’interfaçage. Sans cela, le processeur ne saurait pas si une adresse est destinée à un périphérique ou à la mémoire. Cela élimine aussi les problèmes avec les caches : les accès à l'espace d'adressage de la RAM passent par l'intermédiaire de la mémoire cache, alors les accès dans l'espace d'adressage des périphériques le contournent totalement. Là encore, les deux espaces d'adressage n'ont pas forcément la même taille. Il arrive que les deux espaces d'adressage aient la même taille, le plus souvent sur des ordinateurs complexes avec beaucoup de périphériques. Mais les systèmes embarqués ont souvent des espaces d'adressage plus petits pour les périphériques que pour la ou les mémoires. L'implémentation varie grandement suivant le cas, la première méthode imposant d'avoir deux bus séparés pour les mémoires et les périphériques, l'autre permettant un certain partage du bus d'adresse. Nous reviendrons dessus plus en détail dans le chapitre sur l'adressage des périphériques. ==La ''memory map'' d'un ordinateur== Les deux sections précédentes nous ont appris que l'on peut utiliser un espace d'adressage séparé pour la ROM et un autre pour les périphériques. En tout, cela donne quatre possibilités distinctes. {|class="wikitable" |- ! ! IO mappées en mémoire ! IO séparées |- ! ROM mappée en mémoire (architecture Von Neumann) | Un seul espace d'adressage | Deux espaces d'adressage : * un pour les mémoires RAM/ROM ; * un pour les IO. |- ! ROM séparée (architecture Harvard) | Deux espaces d'adressage : * un pour la RAM et les IO ; * un pour la ROM. | Trois espaces d'adressages : * un pour la RAM ; * un pour la ROM ; * un pour les IO. |} Les quatre solutions ont des avantages et inconvénients divers, mais il est intéressant de contraster un espace d'adressage unique avec plusieurs espaces d'adressages. ===L'espace d'adressage unique=== Avec un espace d'adressage unique, la ROM est au sommet de l'espace d'adressage, les périphériques sont juste en-dessous, la RAM commence à l'adresse 0 et prend les adresses basses. Faire simplifie grandement l'implémentation matérielle de l'adressage. Notons que d'autres composants que les périphériques ou les mémoires peuvent se trouver dans l'espace d'adressage. On peut y trouver les horloges temps réels, des timers, des senseurs de température, ou d'autres composants placés sur la carte mère. Un exemple un peu original est le suivant : la console de jeu Nintendo DS incorporait une unité de calcul spécialisée dans les divisions et racines carrées, séparée du processeur, qui était justement mappée en mémoire ! [[File:Espace d'adressage classique avec entrées-sorties mappées en mémoire.png|centre|vignette|upright=2|Espace d'adressage classique avec entrées-sorties mappées en mémoire]] L'espace d'adressage unique est plus simple pour les programmeurs, malgré un désavantage dont il faut parler. Le problème est que des adresses censées être disponibles pour la RAM sont détournées vers la ROM ou les périphériques, ce qui limite la quantité de RAM qui peut être réellement adressée en pratique. C'est un défaut qui se manifeste seulement pour les petits espaces d'adressages, de 8 à 16 bits, qui ne permettent pas d'adresser beaucoup de RAM. Avec des adresses codées sur 16 bits, un espace d'adressage de 64 kibioctets de RAM est grignoté par des entrées-sorties assez rapidement. Par contre, pour des adresses de 32 à 64 bits, une bonne partie de l'espace d'adressage est inutilisé, car il n'y a pas assez de RAM. L'espace d'adressage unique n'a donc pas de désavantages. Les vielles consoles de jeux et certains vieux ordinateurs (Commodores et Amiga), utilisaient un espace d'adressage unique. Elles n'avaient pas une variété de cartes graphiques ou de cartes sons différentes, comme sur les PCs modernes. Au contraire, elles avaient la même configuration matérielle, le matériel ne pouvait pas être changé ni upgradé. Le système d'exploitation était rudimentaire et ne contrôlait pas vraiment l'accès au matériel. Les programmeurs avaient donc totalement accès au matériel et mapper les entrées/sorties en mémoire rendait la programmation des périphériques très simple. <gallery widths=200px heights=500px> N5200mk2 memory map.svg|Adressage mémoire (carte mémoire) du N5200mk2. PC-9801VM memory map.svg|Adressage mémoire (carte mémoire) du PC-9801VM. </gallery> ===La commutation de banques (''bank switching'')=== Le '''''bank switching''''', aussi appelé '''commutation de banque''', permet d'utiliser plusieurs espaces d'adressage sur un même processeur, sans attribuer chaque espace d'adressage pour une raison précise. L'espace d'adressage est présent en plusieurs exemplaires appelés des '''banques'''. Les banques sont numérotées, chaque numéro de banque permettant de l'identifier et de le sélectionner. Le but de cette technique est d'augmenter la mémoire disponible pour l'ordinateur. Par exemple, supposons que j'ai besoin d'adresser une mémoire ROM de 4 kibioctets, une RAM de 8 kibioctets, et divers périphériques. Le processeur a un bus d'adresse de 12 bits, ce qui limite l'espace d'adressage à 4 kibioctets. Dans ce cas, je peux réserver 4 banques : une pour la ROM, une pour les périphériques, et deux banques qui contiennent chacune la moitié de la RAM. La simplicité et l'efficacité de cette technique font qu'elle est beaucoup utilisée dans l'informatique embarquée. [[File:PC-8801MemoryMap.gif|centre|vignette|upright=2|exemple de Bank switching.]] Cette technique demande d'utiliser un bus d'adresse plus grand que les adresses du processeur. L'adresse réelle se calcule en concaténant le numéro de banque avec l'adresse accédée. Le numéro de la banque actuellement en cours d'utilisation est mémorisé dans un registre appelé le '''registre de banque'''. On peut changer de banque en changeant le contenu de ce registre. Le processeur dispose souvent d'instructions spécialisées qui en sont capables. {| class="wikitable flexible" |[[File:Banque mémoire.png|Banque mémoire.]] |[[File:Registre de banque.png|Registre de banque.]] |} ==Le placement des programmes dans l'espace d'adressage== Les ordinateurs modernes sont des architectures Von Neumann, avec un seul espace d'adressage, qui est partagé suivant les besoins. Et un point important est que le système d'exploitation et les programmes sont copiés en mémoire RAM avant d'être exécutés. Ils ne sont pas exécutés depuis une mémoire ROM, cela est réservé aux systèmes embarqués simples. Et cela a une influence sur l'espace d'adressage. Les programmes et le système d'exploitation doivent se partager l'espace d'adressage. Voyons voir comment ce partage se fait. Dans ce chapitre, nous allons volontairement mettre de côté les ordinateurs qui supportent la mémoire virtuelle. Nous en parlerons dans le prochain chapitre. Le principe est de voir comment un processeur avec un seul espace d'adressage gèrent la présence d'un ou de plusieurs programmes. ===Un seul espace d'adressage, non-partagé=== Le cas le plus simple est celui où il n'y a pas de système d'exploitation et où un seul programme s'exécute sur l'ordinateur. Le programme a alors accès à tout l'espace d'adressage, sauf pour ce qui est réservé aux périphériques et à la mémoire ROM. Le programme organise la mémoire en plusieurs sections, dans lesquelles le programme range des données différentes. Typiquement, on trouve quatre blocs de mémoire, appelés des segments, chacun étant spécialisé pour les données, le code, la pile, etc. Voici ces trois sections : * Le segment '''''text''''' 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. * Le segment pour la '''pile''', de taille variable. * le reste est appelé le '''tas''', de taille variable. [[File:Organisation d'un espace d'adressage unique utilisé par un programme unique.png|centre|vignette|upright=2|Organisation d'un espace d'adressage unique utilisé par un programme unique]] [[File:Typical computer data memory arrangement.png|vignette|upright=0.5|Typical computer data memory arrangement]] La pile et le tas sont de taille variable, ce qui veut dire qu'ils peuvent grandir ou diminuer à volonté, contrairement au reste. Entre le tas et la pile, on trouve un espace de mémoire inutilisée, qui peut être réquisitionné selon les besoins. La pile commence généralement à l'adresse la plus haute et grandit en descendant, alors que le tas grandit en remontant vers les adresses hautes. Il s'agit là d'une convention, rien de plus. Il est possible d'inverser la pile et le tas sans problème, c'est juste que cette organisation est rentrée dans les usages. Avant de poursuivre, précisons qu'il est possible de regrouper plusieurs programmes distincts dans un seul, afin qu'ils partagent le même morceau de mémoire. Les programmes portent alors le nom de '''threads'''. Les ''threads'' d'un même processus partagent le même espace d'adressage. Ils partagent généralement certains segments : ils se partagent le code, le tas et les données statiques. Par contre, chaque thread dispose de sa propre pile d'appel. [[File:Single vs multithreaded processes.jpg|centre|vignette|upright=2.0|Distinction entre processus mono et multi-thread.]] Il va de soi que cette vision de l'espace d'adressage ne tient pas compte des périphériques. C'est-à-dire que les schémas précédents partent du principe qu'on a un espace d'adressage séparé pour les périphériques. Dans le cas où les entrées-sorties sont mappées en mémoire, l'organisation est plus compliquée. Généralement, les adresses associées aux périphériques sont placées juste au-dessus de la pile, dans les adresses hautes. ===Un seul espace d'adressage, partagé avec le système d'exploitation=== [[File:Gestion de la mémoire sur les OS monoprogrammés.png|vignette|upright=1.0|Gestion de la mémoire sur les OS monoprogrammés.]] Maintenant, étudions le cas où le programme partage la mémoire avec le système d'exploitation. Sur les systèmes d'exploitation les plus simples, on ne peut lancer qu'un seul programme à la fois. Le système d'exploitation réserve une portion de taille fixe réservée au système d'exploitation, alors que le reste de la mémoire est utilisé pour le programme à exécuter. Le programme est placé à un endroit en RAM qui est toujours le même. Le système d'exploitation peut être soit placé dans une ROM, soit copié en mémoire RAM depuis une mémoire de masse, comme le programme à lancer. Si le système d'exploitation est copié en mémoire RAM, il est généralement placé dans les premières adresses, les adresses basses. A l'inverse, un OS en mémoire ROM est généralement placé à la fin de la mémoire, dans les adresses hautes. Mais tout cela n'est qu'une convention, et les exceptions sont monnaie courante. [[File:Méthodes d'allocation de la mémoire avec un espace d'adressage unique.png|centre|vignette|upright=2.0|Méthodes d'allocation de la mémoire avec un espace d'adressage unique]] Sur de tels systèmes, il faut protéger l'OS contre une erreur ou malveillance d'un programme utilisateur. Notons qu'il s'agit d'une protection en écriture, pas en lecture. Le programme peut parfaitement lire les données de l'OS sans problèmes, au moins en partie. Dans ce qui suit, on part du principe que l'OS est dans les adresses basses. La solution la plus courante interdit au programme d'écrire au-delà d'une limite au-delà de laquelle se trouve le système d’exploitation. Pour cela, le processeur incorpore un '''registre limite''', qui contient l'adresse limite au-delà de laquelle un programme peut pas écrire. Pour toute écriture en espace utilisateur, l'adresse écrite est comparée au registre limite. Si l'adresse est inférieure au registre limite, le programme cherche écrire dans la mémoire réservée à l'OS. L’accès mémoire est interdit, une exception matérielle est levée et l'OS affiche un message d'erreur. [[File:Circuit total.png|centre|vignette|upright=2.0|Protection mémoire avec un registre limite.]] ===Un seul espace d'adressage, partagé entre plusieurs programmes=== Les systèmes d’exploitation modernes implémentent la '''multiprogrammation''', le fait de pouvoir lancer 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. Et il faut alors résoudre un problème : le partage de la RAM entre les différents programmes. Les différents programmes et leurs données sont placés en mémoire RAM, et il faut trouver un moyen pour répartir les différents programmes en RAM. [[File:Partitions mémoire.png|vignette|upright=1|Partitions mémoire]] Il y a plusieurs solutions pour cela, mais l'une d'entre elle est l'usage de segments mémoire. Avec elle, chaque programme se voit attribuer un ou plusieurs blocs de mémoire. Ils sont appelés des '''partitions mémoire''', ou encore des '''segments'''. Dans ce qui va suivre, nous partons du principe qu'un programme est égal à un segment, pour simplifier les explications. Mais sachez qu'un programme peut être éclaté en plusieurs segments dispersés dans la mémoire, et même être conçu pour ! Nous en reparlerons dans le chapitre sur le mémoire virtuelle. Le système d'exploitation mémorise une liste des segments importants en mémoire RAM, appelée la ''table des segments''. Il s'agit d'un tableau dont chaque entrée mémorise les informations pour un segment. Une entrée de ce tableau est appelée un '''descripteur de segment''' et contient son adresse de base, sa taille, et des autorisations liées à la protection mémoire. Précisément, l'entrée numéro N mémorise les informations du segment numéro N. A partir de l'adresse de base de la table des segments et du numéro de segment, on peut lire ou écrire un descripteur de segment. [[File:Global Descriptor table.png|centre|vignette|upright=2|Global Descriptor table]] Toutefois, l'usage de segments amène un paquet de problèmes qu'il faut résoudre au mieux. Le premier problème est tout simplement de placer les segments dans l'espace d'adressage, mais c'est quelque chose qui est du ressort du système d'exploitation. Par contre, cela implique qu'un segment peut être placé n'importe où en RAM et sa position en RAM change à chaque exécution. En conséquence, les adresses des branchements et des données ne sont jamais les mêmes d'une exécution à l'autre. Pour résoudre ce problème, le compilateur considère que le segment commence à l'adresse zéro, puis les adresses sont corrigées avant ou pendant l'exécution du programme. Cette correction est en général réalisée par l'OS lors de la copie du programme en mémoire, mais il existe aussi des techniques matérielles, que nous verrons dans le chapitre suivant. Il faut aussi prendre en compte le phénomène de l''''allocation mémoire'''. Derrière ce nom barbare, se cache quelque chose de simple : les programmes peuvent réclamer de la mémoire au système d'exploitation, pour y placer des données. Les langages de programmation bas niveau supportent des fonctions comme malloc(), qui permettent de demander un bloc de mémoire de N octets à l'OS, qui doit alors accommoder la demande. S'il n'y a pas assez de mémoire, l'appel échoue et le programme doit gérer la situation. De même, un programme peut libérer de la mémoire qu'il n'utilise plus avec des fonctions comme free(). Avec des segments, cela revient à changer la taille d'un segment, plus précisément à la fin du segment. Et tout cela est géré par le système d'exploitation, aussi on laisse cela pour le prochain chapitre. Enfin, sans protection particulière, les programmes peuvent techniquement lire ou écrire les données des autres. Pour éviter cela, il suffit de vérifier qu'un programme accède bien à son segment et pas ailleurs. Il suffit de vérifier si l'adresse lue/écrite est dans le bon intervalle. Il suffit alors d'utiliser deux registres limites, rien de plus. Mais au-delà de cette simple vérification, si un programme pouvait modifier les données d'un autre programme, on se retrouverait rapidement avec une situation non prévue par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses. Il faut donc introduire des mécanismes de protection mémoire, pour isoler les programmes les uns des autres, et éviter toute modification problématique. La protection mémoire regroupe plusieurs techniques assez variées, qui ont des objectifs différents. Elles ont pour point commun de faire intervenir à des niveaux divers le système d'exploitation et le processeur. ==La ''memory map'' des PC IBM x86== Dans ce qui suit, nous allons étudier l'espace d'adressage des processeurs x86. L'organisation de leur espace d'adressage a évolué dans le temps et l'espace d'adressage s'est adapté. Mais il s'est adapté sans modifier l'existant, pour des raisons de compatibilité. Aussi, les processeurs x86 modernes peuvent fonctionner dans plusieurs modes, appelés mode réel, protégé, virtuel 8086, long, ''system management mode'', et compatibilité 32 bits. [[File:AMD64StateDiagram.svg|centre|vignette|upright=2|AMD64StateDiagram]] Le ''system management mode'' a déjà été abordé dans le chapitre sur les interruptions. Pour simplifier, il ne s'agit pas d'un véritable mode au sens "adressage". Dans ce mode, le système d'exploitation est mis en pause et le BIOS ou un autre firmware s'exécute sans restriction. On y rentre grâce à une interruption dédiée, lancée par le noyau de l'OS. Le système d'exploitation exécute cette interruption afin de laisser la main au BIOS, pour lui déléguer certaines tâches. Par contre, les autres modes sont bien plus intéressant, car ils ont chacun un espace d'adressage différent. Voyons-les dans le détail. ===Le mode réel : l'adressage sur 20 bits=== Le tout premier mode était le '''mode réel''', qui utilisait des adresses de 20 bits, ce qui fait 1 mébioctet de mémoire adressable. Il était utilisé sur des processeurs 16 bits, qui géraient donc des adresses de 16 bits capables d'adresser 64 kibioctets de mémoire. Pour contourner cette limitation, le processeur utilisait la segmentation, que nous verrons dans le prochain chapitre. Pour le moment, nous allons dire que la mémoire en mode réel était composée de 16 blocs de 64 kibioctets. C'est une simplification qui ne fera pas de mal. Le processeur démarre systématiquement en mode réel, y compris sur les ordinateurs modernes. Le BIOS s'exécute dans ce mode, avant de passer en mode protégé et de laisser la main à l'UEFI et au système d'exploitation. Autrefois, le système d'exploitation s'exécutait aussi en mode réel, c'est le cas du DOS. En mode réel, l'espace d'adressage est peuplé par de la RAM, la ROM du BIOS et des périphériques. La RAM prenait les adresses basses, la ROM était au sommet de l'espace d'adressage, et les périphériques entre les deux. Les premiers 640 kibioctets sont réservés à la RAM et sont appelés la '''mémoire conventionnelle'''. Les octets restants forment la '''mémoire haute''', réservée aux ROM et aux périphériques. Les deux premiers kibioctets de la mémoire conventionnelle sont initialisés au démarrage de l'ordinateur. Ils sont utilisés pour stocker le vecteur d'interruption (on expliquera cela dans quelques chapitres) et servent aussi au BIOS. La portion réservée au BIOS, la ''BIOS Data Area'', mémorise des informations en RAM. Elle commence à l'adresse 0040:0000h, a une taille de 255 octets, et est initialisée lors du démarrage de l'ordinateur. Le reste de la mémoire conventionnelle est réservée au le système d'exploitation (MS-DOS, avant sa version 5.0) et au programme en cours d’exécution. La mémoire haute est sectionnée en deux portions. Le sommet de la mémoire haute est réservé au BIOS, alors que le bas de la mémoire haute est réservé aux périphériques. Dans cette dernière, on trouve les BIOS des périphériques (dont celui de la carte vidéo, s'il existe), qui sont nécessaires pour les initialiser et pour communiquer avec eux. De plus, on y trouve la mémoire de la carte vidéo et éventuellement la mémoire d'autres périphériques comme la carte son. {| class="wikitable" style="text-align:center;" |+ Espace d'adressage en mode réel |- ! Numéro du bloc !! Contenu du bloc |- | 1024 - 960 Kio || class="f_jaune" | BIOS principal (ROM) |- | 960 Kio - 640 Kio | class="f_vert" | ROM d'extension des périphériques (XT, EGA, 3270 PC) </br> Mémoire vidéo des cartes EGA, MDA ou CGA |- | 640 Kio - 0 || class="f_rouge" | Mémoire RAM, mémoire conventionnelle |} Pour détailler un peu plus, il faut tenir compte du découpage de l'espace d'adressage en blocs de 64 kibioctets. Le découpage en blocs de 64 kibioctets est particulièrement important pour la mémoire haute. Des blocs sont attribués à un périphérique spécifique. Par exemple, le 16ème bloc est réservé au BIOS, le 12ème aux ROM des périphériques, le 10ème et le 11ème à la mémoire vidéo, etc. {| class="wikitable" style="text-align:center;" |+ Espace d'adressage en mode réel |- ! Numéro du bloc !! Contenu du bloc |- | 0 || rowspan="10" class="f_rouge" | Mémoire RAM, mémoire conventionnelle |- | 1 |- | 2 |- | 3 |- | 4 |- | 5 |- | 6 |- | 7 |- | 8 |- | 9 |- | 10 || class="f_vert" | Mémoire vidéo des cartes EGA |- | 11 || class="f_vert" | Mémoire vidéo des cartes MDA ou CGA |- | 12 || class="f_vert" | ROM d'extension des périphériques (XT, EGA, 3270 PC) |- | 13 || class="f_gris" | Autre, non-réservé |- | 14 || class="f_gris" | Autre, non-réservé |- | 15 || class="f_jaune" | ROM du BIOS |} Vous remarquerez que les blocs 13 et 14 sont non-réservés. Ils peuvent être utilisés pour mapper des périphériques en mémoire. Mais si aucun périphérique n'est mappé dedans, les blocs sont laissés libres. Les OS n'hésitaient pas à les utiliser pour adresser de la RAM. Par exemple, si l'ordinateur n'a pas de carte vidéo EGA, le bloc qui leur était réservé était réutilisé comme mémoire RAM. La mémoire conventionnelle passait alors de 640 à 704 kibioctets de RAM disponibles. L'espace d'adressage correspond à la ROM du BIOS. Mais avec le temps, la ROM est devenue plus lente au point qu'exécuter du code directement depuis la ROM du BIOS était un problème niveau performance. Je rappelle que le BIOS contient de nombreuses routines d'interruptions qui étaient appelées par les systèmes d'exploitation de l'époque, notamment le DOS. Heureusement, diverses optimisations permettaient de copier le BIOS dans la mémoire RAM, plus rapide que la ROM du BIOS. Il s'agit des techniques de '''''BIOS Shadowing'''''. Elles s'appliquaient au BIOS proprement dit, mais le BIOS vidéo pouvait aussi être optimisé ainsi, mais les ROM d'extension ne le sont généralement pas. Le ''BIOS Shadowing'' peut s'implémenter de plusieurs manières, mais la plus courante mappait temporairement de la RAM dans le même espace d'adressage que la ROM du BIOS. Le code du BIOS copiait le BIOS avec une boucle, dont chaque itération lisait une adresse du BIOS et écrivait dans cette même adresse. Juste avant la boucle, le ''chipset'' était configuré par le BIOS pour que les lectures atterrissent dans la ROM du BIOS, mais que les écritures tombaient dans une mémoire RAM mappée. Une fois la copie/boucle terminée, l'adressage tombait dans la RAM aussi bien pour les lectures et écritures. Elle demandait un support du ''chipset'' et du logiciel, pour détourner les adresses temporairement. ===Interlude : les technologies d'expanded memory=== Déjà à l'époque, 1 mébioctet était une limite assez faible, même si cela n'a pas empêché à un grand dirigeant de l'industrie informatique de dire que "640K ought to be enough for anybody". Aussi, diverses solutions matérielles ont vu le jour. Elles étaient peu utilisées et ont concrètement servi à des applications spécifiques. La principale est l''''''expanded memory''''' (EMS), une solution utilisant une carte d'extension avec plusieurs modules de RAM installés dessus. Les modules étaient accédés via la technique de la commutation de banque. [[File:Expanded memory.svg|centre|vignette|upright=2|L'espace d'adressage avec ''Expanded memory''. La carte d'extension et le matériel requis ne sont pas représentés.]] Le terme ''expanded memory'' regroupe plusieurs technologies propriétaires semblables sur le principe mais différentes dans l'exécution. Les premières utilisaient des banques de 64 kibioctets, qui étaient mappées dans un bloc de 64 kibioctets libre en mémoire haute. L'usage de banques de 64 kibioctets collait bien avec l'adressage particulier du mode réel, où la mémoire était gérée par blocs de 64 kibioctets. Les technologies ultérieures utilisaient 2 à 4 pages de 16 kibioctets, qui pouvaient être placée n'importe où en mémoire conventionnelle. [[File:EmulexPersyst 4M ISA.jpeg|centre|vignette|upright=2|Carte d'extension ''Expanded memory'' de marque Emulex Persyst, de 4 mébioctets.]] L'''expanded memory'' demandait que les programmes soient codés pour utiliser cette fonctionnalité. Pour cela, ils devaient utiliser une technique de programmation nommée ''Overlay programming''. L'idée est de découper le programme en plusieurs blocs séparés, appelés des '''''overlays'''''. Certains blocs sont en permanence en RAM, mais d'autres sont soit chargés en RAM, soit stockés ailleurs. Le ailleurs est souvent le disque dur, ou dans la RAM ''expanded memory'' si elle est disponible. Le va-et-vient des ''overlays'' entre RAM et disque dur était réalisé en logiciel, par le programme lui-même. [[File:Overlay Programming.svg|centre|vignette|upright=1.5|''Overlay Programming''.]] L'utilisation de l'''expanded memory'' utilisait l'interruption logicielle 67h, une interruption peu utilisée à l'époque, qui était libre. La carte d'extension était utilisée à travers un pilote de périphérique dédié, qui était appelé via l'interruption 67h. ===Le mode protégé : l'adressage sur 24 et 32 bits=== Par la suite, le mode réel a été remplacé par le '''mode protégé'''. Il utilise la mémoire virtuelle, ce qui fait qu'on devrait le détailler dans le chapitre suivant. Il permettait aussi de dépasser la limite du mébioctet de mémoire. L'espace d'adressage en mode protégé est passé de 24 bits sur le CPU 286, à 32 bits sur le 386 et les CPU suivants. Les programmes conçus pour le mode réel ne pouvaient pas s'exécuter en mode protégé. Pour corriger cela, le 286 a introduit le '''mode virtuel 8086''', aussi appelé mode V86. Il s'agit d'une technique de virtualisation, ce qui fait qu'on en parlera en détail dans le chapitre sur la virtualisation assistée en matériel. Tout ce que l'on peut dire ici est que le mode V86 a été détourné pour que les applications DOS utilisent la mémoire au-delà du mébioctet, appelée la '''mémoire étendue'''. Les logiciels DOS accédaient à la mémoire étendue à travers un driver DOS dédié (HIMEM.SYS). Les logiciels DOS déplaçaient des données dans la mémoire étendue, puis les rapatriaient en mémoire conventionnelle quand ils en avaient besoin. Il ne faut pas confondre mémoire étendue et ''expanded memory''. La technique de ''expanded memory'' repose sur de la commutation de banque, demande une carte d'extension dédiée, fonctionne sans mode V86. A l'inverse, la mémoire étendue ne gère pas de commutation de banque, ne demande pas de carte d'extension, mais utilise le mode V86. Par contre, il est possible d'émuler l'''expanded memory'' sans carte d'extension, avec la mémoire étendue. Quelques cartes mères intégraient des techniques pour, mais une émulation logicielle était aussi possible en réécrivant l'interruption 67h. ===Le mode long : l'adressage 64 bits=== Par la suite, les processeurs ont introduit le '''mode long''', dans lequel les adresses font 64 bits. Le CPU en mode long a aussi un jeu d'instruction totalement différent, des registres en plus et d'autres fonctionnalités actives uniquement dans ce mode. Les programmes compilés pour le 64 bits s'exécutent nativement dans ce mode, alors que les programmes 32 et 16 bits s'exécutent dans un mode de compatibilité dédié. Sur les systèmes 64 bits, le bus d'adresse fait seulement 48 bits afin d'économiser des interconnexions. Avec 48 bits, il manque 64 - 48 = 16 bits d'adresses, qui ne peuvent pas être utilisés pour adresser quoi que ce soit. La règle est que ces 16 bits doivent être tous égaux : soit ils valent tous 0, soient ils sont tous à 1. Les adresses qui respectent cette contrainte sont appelées des '''adresses canoniques'''. Le résultat est que l'espace d'adressage est coupé en trois sections, comme illustré ci-dessous. * Les '''adresses canoniques basses''' ont leurs 16 bits de poids fort à 0 et sont situées en bas de l'espace d'adressage, dans les premières adresses. * Les '''adresses canoniques hautes''' ont leurs 16 bits de poids fort à 1 et sont situées au sommet de l'espace d'adressage, dans les dernières adresses. * Entre les deux, se trouvent des '''adresses non-canoniques''', qui ne sont pas accessibles. Y accéder déclenche la levée d'une exception matérielle. {| |[[File:AMD64-canonical--48-bit.png|vignette|Espace d'adressage x86-64 bits, avec des adresses physiques de 48 bits.]] |[[File:AMD64-canonical--56-bit.png|vignette|Espace d'adressage x86-64 bits, avec des adresses physiques de 57 bits.]] |} : Les futurs systèmes x86 devraient passer à des adresses de 57 bits, les trois sections auront donc des frontières différentes. Les adresses non-canoniques sont censées être inutilisables. Mais les programmeurs aimeraient bien pouvoir les utiliser pour des ''pointeurs tagués'', à savoir des pointeurs/adresses associées à des informations. L'idée serait d'utiliser les 16 bits de poids fort pour stocker des informations liées au pointeur, seuls les 48 bits restant codant l'adresse. Les 16 bits peuvent être utilisés de manières très diverses. Un exemple est la technique du '''''memory tagging''''', qui crée une somme de contrôle au pointeur. La somme de contrôle est générée par un algorithme cryptographique, à partir de l'adresse du pointeur. Elle est vérifiée à chaque utilisation du pointeur. Si la somme de contrôle ne correspond pas au pointeur, une erreur est levée. Si jamais un virus ou un code malveillant modifie un pointeur, il ne saura pas comment calculer la somme de contrôle, la somme de contrôle a énormément de chances d'être incorrecte. Quelques fonctionnalités des processeurs visent à autoriser l'utilisation des adresses non-canoniques. L'idée est que les 16 bits de poids fort sont ignorées lors des accès mémoire, ce qui permet d'utiliser les 16 bits de poids fort à volonté. Il s'agit des techniques ''Top Byte Ignore'' d'ARM, ''Upper Address Ignore'' d'AMD, et ''Linear Address Masking'' d'Intel. ==La z/architecture d'IBM== IBM a sa propre gamme de ''mainframes'', qui utilisent des processeurs fait maison par IBM lui-même. Les CPU IBM ont leur propre jeu d'instruction CISC, appelé '''''z/architecture'''''. Et ce jeu d'instruction a des possibilités en termes d'adressage qui sont extrêmement intéressantes, en plus d'être assez inédites. Pour ce chapitre, nous allons nous concentrer sur une fonctionnalité : les programmes ont accès à plusieurs espaces d'adressage naturellement. L'avantage pour les programmes utilisateur n'est pas très évident, mais il l'est nettement plus pour les systèmes d'exploitation. Le système d'exploitation de ces ''mainframes'', le z/OS, profite de cette possibilité, idem pour l'hyperviseur z/VM. Il utilise cette possibilité pour donner un espace d'adressage séparé pour chaque programme en cours d'exécution. Les programmes sont alors isolés les uns des autres. Cette '''isolation des processus''' n'est pas spécifique à IBM : tous les systèmes d'exploitation modernes l'utilisent. Mais les processeurs x86 doivent se débrouiller seulement avec des techniques dites de mémoire virtuelle, alors que les ''mainframes'' IBM complètent la mémoire virtuelle avec des facilités en plus. Pour les programmes utilisateurs, un intérêt est que cela permet de partager des données entre plusieurs programmes. Ils ont un espace d'adressage rien qu'à eux, mais les données partagées sont placées dans un espace d'adressage commun, auquel les deux programmes ont accès. L'espace d'adressage partagé peu même être configuré de manière à ne contenir que des données, il est impossible d'exécuter du code depuis cet espace d'adressage. Ce qui empêche certains ''malwares'' de fonctionner. La ''z/architecture'' supporte 65536 espaces d'adressage simultanés. Les espaces d'adressage sont numérotés, ils ont chacun un '''''Address-Space Number''''' codé sur 16 bits. Nous utiliserons l’abréviation ASN dans ce qui suit. Par contre, un programme ne peut pas utiliser tout ces espaces d'adressages, il ne peut en utiliser que 1024 en même temps. De plus, ils ne sont accessibles que quand le processeur est dans un mode de fonctionnement bien précis. Un ^processeur ''z/architecture'' peut en effet fonctionner dans deux modes, qu'on va appeler "standard" et "étendu". ===Le mode standard de la ''z/architecture''=== En mode standard, un programme exécuté sur la ''z/architecture'' utilise deux espaces d'adressages : l''''espace d'adressage primaire''' où se situent le programme exécuté et les données importantes, un '''espace d'adressage secondaire''' utilisé pour des tâches/données annexes. : Un troisième espace d'adressage l''''espace d'adressage ''home''''', est réservé au système d'exploitation. Il a pour particularité de ne pas avoir d'ASN. Il n'est concrètement accesible qu'en mode superviseur, donc par le système d'exploitation. Nous n'en parlerons pas dans ce qui suit. Mais comment le processeur pour utiliser ces espaces d'adressages ? Par exemple, s'il veut faire une lecture, comment précise-t-il l'espace d'adressage courant ? La réponse est que le processeur peut être dans deux états : soit primaire, soit secondaire. Dans le premier état, toutes les lectures/écritures se font dans l'espace d'adressage primaire. Dans le second, elles se font toutes dans l'espace d'adressage secondaire. Il peut passer de l'un à l'autre en utilisant l'instruction ''Set Address Space Control''. Le processeur dispose aussi d'instructions ''MOVE TO PRIMARY'' et ''MOVE TO SECONDARY'' pour échanger des données dans les deux espaces d'adressage. L'ASN de ces deux espaces d'adressage est mémorisé dans les registres de contrôle du processeur. L'ASN de l'espace primaire est dans le registre de contrôle n°3, l'ASN de l'espace secondaire est dans le registre de contrôle n°4 (ils sont dans les 16 bits de poids faible, dans les deux cas). Les deux registres de contrôle sont initialisés au démarrage de l'application, par le système d'exploitation, ce qui fait que ce sont des espaces d'adressage par défaut. Le programme a donc accès à ces deux espaces d'adressage sans rien faire. ===Les registres d'accès de la ''z/architecture''=== Un programme peut donc passer de l'espace d'adressage primaire à secondaire (ou inversement) en utilisant l’instruction ''SET ADDRESS SPACE CONTROL''. Mais il peut en profiter pour passer dans un mode où il a accès à 16 espaces d'adressage différents, en même temps. Le mode en question est appelé le '''mode ''access register'''''. Le processeur a donc quatre modes de fonctionnement : primaire, secondaire, ''home'' et ''access register''. Qui dit 16 espaces d'adressage dit 16 ASNs à mémoriser quelque part. Pour cela, le processeur dispose de 16 '''registres d'accès''', chacun mémorisant un ASN. Un accès mémoire précise quel registre d'accès utiliser, ce qui lui permet de sélectionner l'espace d'adressage voulu. Pas besoin d'utiliser ''SET ADDRESS SPACE CONTROL'' pour passer de l'un à l'autre, le programme peut gérer 16 espaces d’adressage en même temps. Ce qui est une différence importante par rapport au mode de fonctionnement précédent. : Une précision importante est que le premier registre d'accès mémorise l'ASN de l'espace d'adressage primaire. De plus, les instructions sont chargées depuis l'espace d'adressage primaire, sauf quand le CPU est en mode ''home''. Les CPU ''z/architecture'' disposent de 16 registres généraux de 64 bits, de 16 registres flottants eux aussi de 64 bits, de 16 registres de contrôle eux aussi de 64 bits, et de '''registres d'accès''' qui gèrent les espaces d'adressage. Chaque registre entier est associé à un espace d'adressage particulier, qui est indiqué par son registre d'accès. Les registres d'accès sont utilisés lors des lectures/écritures indirectes. L'adresse est lue depuis un registre général, et son registre d'accès est consulté pour déterminer l'espace d'adressage. Une implémentation simple numéroterait les espaces d'adressage et mettrait les numéros dans les registres d'accès. Les registres d'accès seraient idéalement protégés en écriture et seulement accessibles par le système d'exploitation. Mais ce n'est pas ce qui est fait, pour des raisons de sécurité. Les registres d'accès mémorisent bien un identifiant, mais celui-ci ne sélectionne pas l'espace d'adressage directement. L'identifiant est envoyée à une table de correspondance matérielle, qui mémorise les correspondances entre un identifiant et un espace d'adressage. Il y a une table de correspondance par programme, ce qui fait qu'un identifiant ne correspondra pas au même espace d'adressage dans deux programmes différents. La table de correspondance mémorise aussi des droits d'accès, qui sont différents pour chaque programme. Par exemple, tel programme aura accès en lecture seule à tel espace d'adressage, alors qu'un autre programme y aura accès en lecture et écriture, etc. Un espace d'adressage peut ainsi être partagé entre deux programmes plus facilement, tout en ajoutant des garanties de sécurité. L'usage des registres d'accès est aussi plus flexible qu'on ne pourrait le croire. Le programme peut lire et écrire dans les registres d'accès, copier un registre d'accès dans un autre, etc. Par contre, la table de correspondance empêche à un programme d’accéder à un espace d'adressage arbitraire. Le système d'exploitation remplit la table de manière à n'autoriser l'accès qu'aux espaces d'adressage adéquats, il y enregistre seulement les correspondances utiles. Si un programme forge un identifiant arbitraire, et l'envoie à la table de correspondance, celle-ci n'aura pas la correspondance associée et l'accès mémoire sera invalidé. La table de correspondance est en réalité une table à deux niveaux. Une première table reçoit l'ASN et fournit l'adresse de la seconde table. La seconde table récupère alors l'adresse. Vu que le processeur utilise la mémoire virtuelle, c'est une adresse dite virtuelle, mais laissons cela pour le prochain chapitre. La première table est appelée l'''''access list''''', alors que la seconde est appelée l''''''ASN-Second-Table-Entry Sequence Number'''''. Les deux tables sont accessibles en écriture via une instruction ''LOAD ADDRESS SPACE PARAMETERS'', seulement accesible en mode noyau. : Il faut noter que si l'''access list'' a une taille limitée à 64 kibioctets, la seconde n'est pas limitée, ce qui permet de dépasser la limite des 655536 ASNs. La limite de 65535 ASNs est uen limite par programme, pas totale. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs 8 bits et moins | prevText=Les processeurs 8 bits et moins | next=L'abstraction mémoire et la mémoire virtuelle | nextText=L'abstraction mémoire et la mémoire virtuelle }} </noinclude> 0frbzi396u66e61pmp8k80vwl7mxinm 774376 774375 2026-10-07T16:54:30Z Mewtow 31375 /* Le mode standard de la z/architecture */ 774376 wikitext text/x-wiki L''''espace d'adressage''' du processeur est l'ensemble des adresses utilisables par le processeur. Par exemple, un processeur 16 bits peut adresser 2^16 = 65536 adresses, l'ensemble de ces adresses forme son espace d'adressage. L'espace d'adressage n'est pas toujours égal à la mémoire réellement installée. S'il n'y a pas assez de RAM installée, des adresses seront inoccupées. De plus, une partie de l'espace d'adressage peut être détourné pour communiquer avec les périphériques, comme nous le verrons plus bas. Nous verrons aussi qu'un processeur peut avoir plusieurs espaces d'adressages séparés. ==L'adressage de la RAM et de la ROM== Avoir plusieurs espaces d'adressage spécialisés est quelque chose que nous avons déjà rencontré dans les chapitres précédents, mais sans le dire ouvertement. Aussi, nous allons faire quelques rappels sur les cas déjà rencontrés. En premier lieu, nous allons rappeler la différence entre architectures Von Neumann et Hardvard. La différence entre les deux tient dans l'adressage des mémoires RAM et ROM : est-ce qu'elles sont dans un seul espace d'adressage, ou dans des espaces d'adressage séparés. L''''architecture Von Neumann''' a un seul espace d'adressage, découpé entre la mémoire RAM d'un côté et la mémoire ROM de l'autre. Une adresse correspond soit à la mémoire RAM, soit à la mémoire ROM, mais pas aux deux. Typiquement, la mémoire ROM est placée dans les adresses hautes, les plus élevées, alors que la RAM est placée dans les adresses basses en commençant par l'adresse 0. C'est une convention qui n'est pas toujours respectée, aussi mieux vaut éviter de la tenir pour acquise. [[File:Vision de la mémoire par un processeur sur une architecture Von Neumann.png|centre|vignette|upright=2|Vision de la mémoire par un processeur sur une architecture Von Neumann.]] L''''architecture Harvard''' utilise des espaces d'adressage séparés pour la RAM et la ROM. Une même adresse peut correspondre soit à la ROM, soit à la RAM. Le processeur voit bien deux mémoires séparées, chacune dans son propre espace d'adressage. Les deux espaces d'adressage n'ont pas forcément la même taille. Il est possible d'avoir un plus gros espace d'adressage pour la RAM que pour la ROM. Cela implique que les adresses des instructions et des données soient de taille différentes. C'est peu pratique et c'est rarement implémenté, ce qui fait que le cas le plus courant est celui où les deux espaces d'adressages ont la même taille. [[File:Vision de la mémoire par un processeur sur une architecture Harvard.png|centre|vignette|upright=2|Vision de la mémoire par un processeur sur une architecture Harvard.]] Sur une '''architecture Harvard modifiée''', le programme est placé dans un espace d'adressage à part du reste. Le problème, c'est que cet espace d'adressage ne contient pas que le code machine à exécuter. Il contient aussi des constantes, à savoir des données qui gardent la même valeur lors de l'exécution du programme. Elles peuvent être lues, mais pas modifiées durant l'exécution du programme. L'accès à ces constantes demande d'aller lire celles-ci dans l'autre espace d'adressage, pour les copier dans les registres généraux. Le processeur doit pour cela avoir des instructions de lecture dédiées. En clair, il y a une instruction LOAD pour lire dans la mémoire ROM, une autre pour lire dans la RAM. [[File:Espaces d'adressage sur une archi harvard modifiée.png|centre|vignette|upright=3|Espaces d'adressage sur une archi harvard modifiée]] Une autre solution utilise des instructions de copie, qui copient les constantes en mémoire RAM, donc d'un espace d'adressage à l'autre. [[File:Organisation des espaces d'adressage sur une archi harvard modifiée.png|centre|vignette|upright=3|Organisation des espaces d'adressage sur une archi harvard modifiée]] ==L'adressage des périphériques== Passons maintenant à l'adressage des périphériques. La communication avec les périphériques se fait par l'intermédiaire de registres d’interfaçage. Et ces registres peuvent soit avoir un espace d'adressage séparé, soit être inclus dans l'espace d'adressage des mémoires. Dans ce qui suit, nous allons supposer que l'architecture des de type Von Neumann pour simplifier les explications. ===Les entrées-sorties mappées en mémoire=== Une partie de l'espace d'adressage peut être détourné pour communiquer avec les périphériques. Le périphérique se retrouve inclus dans l'ensemble des adresses utilisées pour manipuler la mémoire : on dit qu'il est mappé en mémoire. Les adresses mémoires associées à un périphérique sont redirigées automatiquement vers les registres du périphérique en question, voire vers sa mémoire intégrée. On parle alors d''''entrées-sorties mappées en mémoire'''. [[File:IO mappées en mémoire.png|centre|vignette|upright=2.0|IO mappées en mémoire]] On remarque un défaut inhérent à cette technique : les adresses utilisées pour les périphériques ne sont plus disponibles pour la RAM. On ne peut plus adresser autant de RAM qu'avant. La perte dépend des périphériques installés et de leur gourmandise en adresses mémoires. C'est ce qui causait un problème assez connu sur les ordinateurs 32 bits, capables d'adresser 4 gibioctets de mémoire. Certaines personnes installaient 4 gigaoctets de mémoire sur leur ordinateur 32 bits et se retrouvaient avec « seulement » 3,5 à 3,8 gigaoctets de mémoire, les périphériques prenant le reste. Il est possible que la RAM d'un périphérique soit mappée en RAM. Un exemple classique est celui des cartes graphiques qui incorporent une mémoire RAM appelée la mémoire vidéo. La mémoire vidéo est mappée en mémoire, ce qui permet au processeur d'écrire directement dedans. Toute lecture ou écriture dans les adresses associées est redirigée vers le bus PCI/AGP/PCI-Express. Nous verrons plus bas des exemples d'ordinateurs où la mémoire vidéo est mappée en mémoire, que ce soit totalement ou partiellement. Si je dis totalement ou partiellement, c'est parce que les cartes graphiques modernes disposent de tellement de mémoire qu'on ne peut pas la mapper totalement dans l'espace d'adressage. Sur les systèmes d'avant 2008, seuls 256 mégaoctets de mémoire vidéo sont mappés en mémoire RAM. Le reste de la mémoire vidéo est invisible du point de vue du processeur, mais manipulable par le GPU à sa guise. Après 2008, la spécification du PCI-Express ajouta un support de la technologie ''Resizable Bar'', qui permet au processeur d’accéder directement à plus de 256 mégaoctets de mémoire vidéo, voire à la totalité de la mémoire vidéo. ===L'espace d'adressage séparé pour les entrées-sorties=== Les entrées-sorties et périphériques peuvent avoir leur propre espace d'adressage dédié, séparé de celui utilisé pour les mémoires RAM et ROM. [[File:Espaces_d'adressages_séparés_entre_mémoire_et_périphérique.png|centre|vignette|upright=3|Bit IO.]] Une même adresse peut donc adresser soit une entrée-sortie, soit une case mémoire. Et pour faire la différence, le processeur doit avoir des instructions séparées pour adresser les périphériques et la mémoire. Il a des instructions de lecture/écriture pour lire/écrire en mémoire, et d'autres pour lire/écrire les registres d’interfaçage. Sans cela, le processeur ne saurait pas si une adresse est destinée à un périphérique ou à la mémoire. Cela élimine aussi les problèmes avec les caches : les accès à l'espace d'adressage de la RAM passent par l'intermédiaire de la mémoire cache, alors les accès dans l'espace d'adressage des périphériques le contournent totalement. Là encore, les deux espaces d'adressage n'ont pas forcément la même taille. Il arrive que les deux espaces d'adressage aient la même taille, le plus souvent sur des ordinateurs complexes avec beaucoup de périphériques. Mais les systèmes embarqués ont souvent des espaces d'adressage plus petits pour les périphériques que pour la ou les mémoires. L'implémentation varie grandement suivant le cas, la première méthode imposant d'avoir deux bus séparés pour les mémoires et les périphériques, l'autre permettant un certain partage du bus d'adresse. Nous reviendrons dessus plus en détail dans le chapitre sur l'adressage des périphériques. ==La ''memory map'' d'un ordinateur== Les deux sections précédentes nous ont appris que l'on peut utiliser un espace d'adressage séparé pour la ROM et un autre pour les périphériques. En tout, cela donne quatre possibilités distinctes. {|class="wikitable" |- ! ! IO mappées en mémoire ! IO séparées |- ! ROM mappée en mémoire (architecture Von Neumann) | Un seul espace d'adressage | Deux espaces d'adressage : * un pour les mémoires RAM/ROM ; * un pour les IO. |- ! ROM séparée (architecture Harvard) | Deux espaces d'adressage : * un pour la RAM et les IO ; * un pour la ROM. | Trois espaces d'adressages : * un pour la RAM ; * un pour la ROM ; * un pour les IO. |} Les quatre solutions ont des avantages et inconvénients divers, mais il est intéressant de contraster un espace d'adressage unique avec plusieurs espaces d'adressages. ===L'espace d'adressage unique=== Avec un espace d'adressage unique, la ROM est au sommet de l'espace d'adressage, les périphériques sont juste en-dessous, la RAM commence à l'adresse 0 et prend les adresses basses. Faire simplifie grandement l'implémentation matérielle de l'adressage. Notons que d'autres composants que les périphériques ou les mémoires peuvent se trouver dans l'espace d'adressage. On peut y trouver les horloges temps réels, des timers, des senseurs de température, ou d'autres composants placés sur la carte mère. Un exemple un peu original est le suivant : la console de jeu Nintendo DS incorporait une unité de calcul spécialisée dans les divisions et racines carrées, séparée du processeur, qui était justement mappée en mémoire ! [[File:Espace d'adressage classique avec entrées-sorties mappées en mémoire.png|centre|vignette|upright=2|Espace d'adressage classique avec entrées-sorties mappées en mémoire]] L'espace d'adressage unique est plus simple pour les programmeurs, malgré un désavantage dont il faut parler. Le problème est que des adresses censées être disponibles pour la RAM sont détournées vers la ROM ou les périphériques, ce qui limite la quantité de RAM qui peut être réellement adressée en pratique. C'est un défaut qui se manifeste seulement pour les petits espaces d'adressages, de 8 à 16 bits, qui ne permettent pas d'adresser beaucoup de RAM. Avec des adresses codées sur 16 bits, un espace d'adressage de 64 kibioctets de RAM est grignoté par des entrées-sorties assez rapidement. Par contre, pour des adresses de 32 à 64 bits, une bonne partie de l'espace d'adressage est inutilisé, car il n'y a pas assez de RAM. L'espace d'adressage unique n'a donc pas de désavantages. Les vielles consoles de jeux et certains vieux ordinateurs (Commodores et Amiga), utilisaient un espace d'adressage unique. Elles n'avaient pas une variété de cartes graphiques ou de cartes sons différentes, comme sur les PCs modernes. Au contraire, elles avaient la même configuration matérielle, le matériel ne pouvait pas être changé ni upgradé. Le système d'exploitation était rudimentaire et ne contrôlait pas vraiment l'accès au matériel. Les programmeurs avaient donc totalement accès au matériel et mapper les entrées/sorties en mémoire rendait la programmation des périphériques très simple. <gallery widths=200px heights=500px> N5200mk2 memory map.svg|Adressage mémoire (carte mémoire) du N5200mk2. PC-9801VM memory map.svg|Adressage mémoire (carte mémoire) du PC-9801VM. </gallery> ===La commutation de banques (''bank switching'')=== Le '''''bank switching''''', aussi appelé '''commutation de banque''', permet d'utiliser plusieurs espaces d'adressage sur un même processeur, sans attribuer chaque espace d'adressage pour une raison précise. L'espace d'adressage est présent en plusieurs exemplaires appelés des '''banques'''. Les banques sont numérotées, chaque numéro de banque permettant de l'identifier et de le sélectionner. Le but de cette technique est d'augmenter la mémoire disponible pour l'ordinateur. Par exemple, supposons que j'ai besoin d'adresser une mémoire ROM de 4 kibioctets, une RAM de 8 kibioctets, et divers périphériques. Le processeur a un bus d'adresse de 12 bits, ce qui limite l'espace d'adressage à 4 kibioctets. Dans ce cas, je peux réserver 4 banques : une pour la ROM, une pour les périphériques, et deux banques qui contiennent chacune la moitié de la RAM. La simplicité et l'efficacité de cette technique font qu'elle est beaucoup utilisée dans l'informatique embarquée. [[File:PC-8801MemoryMap.gif|centre|vignette|upright=2|exemple de Bank switching.]] Cette technique demande d'utiliser un bus d'adresse plus grand que les adresses du processeur. L'adresse réelle se calcule en concaténant le numéro de banque avec l'adresse accédée. Le numéro de la banque actuellement en cours d'utilisation est mémorisé dans un registre appelé le '''registre de banque'''. On peut changer de banque en changeant le contenu de ce registre. Le processeur dispose souvent d'instructions spécialisées qui en sont capables. {| class="wikitable flexible" |[[File:Banque mémoire.png|Banque mémoire.]] |[[File:Registre de banque.png|Registre de banque.]] |} ==Le placement des programmes dans l'espace d'adressage== Les ordinateurs modernes sont des architectures Von Neumann, avec un seul espace d'adressage, qui est partagé suivant les besoins. Et un point important est que le système d'exploitation et les programmes sont copiés en mémoire RAM avant d'être exécutés. Ils ne sont pas exécutés depuis une mémoire ROM, cela est réservé aux systèmes embarqués simples. Et cela a une influence sur l'espace d'adressage. Les programmes et le système d'exploitation doivent se partager l'espace d'adressage. Voyons voir comment ce partage se fait. Dans ce chapitre, nous allons volontairement mettre de côté les ordinateurs qui supportent la mémoire virtuelle. Nous en parlerons dans le prochain chapitre. Le principe est de voir comment un processeur avec un seul espace d'adressage gèrent la présence d'un ou de plusieurs programmes. ===Un seul espace d'adressage, non-partagé=== Le cas le plus simple est celui où il n'y a pas de système d'exploitation et où un seul programme s'exécute sur l'ordinateur. Le programme a alors accès à tout l'espace d'adressage, sauf pour ce qui est réservé aux périphériques et à la mémoire ROM. Le programme organise la mémoire en plusieurs sections, dans lesquelles le programme range des données différentes. Typiquement, on trouve quatre blocs de mémoire, appelés des segments, chacun étant spécialisé pour les données, le code, la pile, etc. Voici ces trois sections : * Le segment '''''text''''' 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. * Le segment pour la '''pile''', de taille variable. * le reste est appelé le '''tas''', de taille variable. [[File:Organisation d'un espace d'adressage unique utilisé par un programme unique.png|centre|vignette|upright=2|Organisation d'un espace d'adressage unique utilisé par un programme unique]] [[File:Typical computer data memory arrangement.png|vignette|upright=0.5|Typical computer data memory arrangement]] La pile et le tas sont de taille variable, ce qui veut dire qu'ils peuvent grandir ou diminuer à volonté, contrairement au reste. Entre le tas et la pile, on trouve un espace de mémoire inutilisée, qui peut être réquisitionné selon les besoins. La pile commence généralement à l'adresse la plus haute et grandit en descendant, alors que le tas grandit en remontant vers les adresses hautes. Il s'agit là d'une convention, rien de plus. Il est possible d'inverser la pile et le tas sans problème, c'est juste que cette organisation est rentrée dans les usages. Avant de poursuivre, précisons qu'il est possible de regrouper plusieurs programmes distincts dans un seul, afin qu'ils partagent le même morceau de mémoire. Les programmes portent alors le nom de '''threads'''. Les ''threads'' d'un même processus partagent le même espace d'adressage. Ils partagent généralement certains segments : ils se partagent le code, le tas et les données statiques. Par contre, chaque thread dispose de sa propre pile d'appel. [[File:Single vs multithreaded processes.jpg|centre|vignette|upright=2.0|Distinction entre processus mono et multi-thread.]] Il va de soi que cette vision de l'espace d'adressage ne tient pas compte des périphériques. C'est-à-dire que les schémas précédents partent du principe qu'on a un espace d'adressage séparé pour les périphériques. Dans le cas où les entrées-sorties sont mappées en mémoire, l'organisation est plus compliquée. Généralement, les adresses associées aux périphériques sont placées juste au-dessus de la pile, dans les adresses hautes. ===Un seul espace d'adressage, partagé avec le système d'exploitation=== [[File:Gestion de la mémoire sur les OS monoprogrammés.png|vignette|upright=1.0|Gestion de la mémoire sur les OS monoprogrammés.]] Maintenant, étudions le cas où le programme partage la mémoire avec le système d'exploitation. Sur les systèmes d'exploitation les plus simples, on ne peut lancer qu'un seul programme à la fois. Le système d'exploitation réserve une portion de taille fixe réservée au système d'exploitation, alors que le reste de la mémoire est utilisé pour le programme à exécuter. Le programme est placé à un endroit en RAM qui est toujours le même. Le système d'exploitation peut être soit placé dans une ROM, soit copié en mémoire RAM depuis une mémoire de masse, comme le programme à lancer. Si le système d'exploitation est copié en mémoire RAM, il est généralement placé dans les premières adresses, les adresses basses. A l'inverse, un OS en mémoire ROM est généralement placé à la fin de la mémoire, dans les adresses hautes. Mais tout cela n'est qu'une convention, et les exceptions sont monnaie courante. [[File:Méthodes d'allocation de la mémoire avec un espace d'adressage unique.png|centre|vignette|upright=2.0|Méthodes d'allocation de la mémoire avec un espace d'adressage unique]] Sur de tels systèmes, il faut protéger l'OS contre une erreur ou malveillance d'un programme utilisateur. Notons qu'il s'agit d'une protection en écriture, pas en lecture. Le programme peut parfaitement lire les données de l'OS sans problèmes, au moins en partie. Dans ce qui suit, on part du principe que l'OS est dans les adresses basses. La solution la plus courante interdit au programme d'écrire au-delà d'une limite au-delà de laquelle se trouve le système d’exploitation. Pour cela, le processeur incorpore un '''registre limite''', qui contient l'adresse limite au-delà de laquelle un programme peut pas écrire. Pour toute écriture en espace utilisateur, l'adresse écrite est comparée au registre limite. Si l'adresse est inférieure au registre limite, le programme cherche écrire dans la mémoire réservée à l'OS. L’accès mémoire est interdit, une exception matérielle est levée et l'OS affiche un message d'erreur. [[File:Circuit total.png|centre|vignette|upright=2.0|Protection mémoire avec un registre limite.]] ===Un seul espace d'adressage, partagé entre plusieurs programmes=== Les systèmes d’exploitation modernes implémentent la '''multiprogrammation''', le fait de pouvoir lancer 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. Et il faut alors résoudre un problème : le partage de la RAM entre les différents programmes. Les différents programmes et leurs données sont placés en mémoire RAM, et il faut trouver un moyen pour répartir les différents programmes en RAM. [[File:Partitions mémoire.png|vignette|upright=1|Partitions mémoire]] Il y a plusieurs solutions pour cela, mais l'une d'entre elle est l'usage de segments mémoire. Avec elle, chaque programme se voit attribuer un ou plusieurs blocs de mémoire. Ils sont appelés des '''partitions mémoire''', ou encore des '''segments'''. Dans ce qui va suivre, nous partons du principe qu'un programme est égal à un segment, pour simplifier les explications. Mais sachez qu'un programme peut être éclaté en plusieurs segments dispersés dans la mémoire, et même être conçu pour ! Nous en reparlerons dans le chapitre sur le mémoire virtuelle. Le système d'exploitation mémorise une liste des segments importants en mémoire RAM, appelée la ''table des segments''. Il s'agit d'un tableau dont chaque entrée mémorise les informations pour un segment. Une entrée de ce tableau est appelée un '''descripteur de segment''' et contient son adresse de base, sa taille, et des autorisations liées à la protection mémoire. Précisément, l'entrée numéro N mémorise les informations du segment numéro N. A partir de l'adresse de base de la table des segments et du numéro de segment, on peut lire ou écrire un descripteur de segment. [[File:Global Descriptor table.png|centre|vignette|upright=2|Global Descriptor table]] Toutefois, l'usage de segments amène un paquet de problèmes qu'il faut résoudre au mieux. Le premier problème est tout simplement de placer les segments dans l'espace d'adressage, mais c'est quelque chose qui est du ressort du système d'exploitation. Par contre, cela implique qu'un segment peut être placé n'importe où en RAM et sa position en RAM change à chaque exécution. En conséquence, les adresses des branchements et des données ne sont jamais les mêmes d'une exécution à l'autre. Pour résoudre ce problème, le compilateur considère que le segment commence à l'adresse zéro, puis les adresses sont corrigées avant ou pendant l'exécution du programme. Cette correction est en général réalisée par l'OS lors de la copie du programme en mémoire, mais il existe aussi des techniques matérielles, que nous verrons dans le chapitre suivant. Il faut aussi prendre en compte le phénomène de l''''allocation mémoire'''. Derrière ce nom barbare, se cache quelque chose de simple : les programmes peuvent réclamer de la mémoire au système d'exploitation, pour y placer des données. Les langages de programmation bas niveau supportent des fonctions comme malloc(), qui permettent de demander un bloc de mémoire de N octets à l'OS, qui doit alors accommoder la demande. S'il n'y a pas assez de mémoire, l'appel échoue et le programme doit gérer la situation. De même, un programme peut libérer de la mémoire qu'il n'utilise plus avec des fonctions comme free(). Avec des segments, cela revient à changer la taille d'un segment, plus précisément à la fin du segment. Et tout cela est géré par le système d'exploitation, aussi on laisse cela pour le prochain chapitre. Enfin, sans protection particulière, les programmes peuvent techniquement lire ou écrire les données des autres. Pour éviter cela, il suffit de vérifier qu'un programme accède bien à son segment et pas ailleurs. Il suffit de vérifier si l'adresse lue/écrite est dans le bon intervalle. Il suffit alors d'utiliser deux registres limites, rien de plus. Mais au-delà de cette simple vérification, si un programme pouvait modifier les données d'un autre programme, on se retrouverait rapidement avec une situation non prévue par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses. Il faut donc introduire des mécanismes de protection mémoire, pour isoler les programmes les uns des autres, et éviter toute modification problématique. La protection mémoire regroupe plusieurs techniques assez variées, qui ont des objectifs différents. Elles ont pour point commun de faire intervenir à des niveaux divers le système d'exploitation et le processeur. ==La ''memory map'' des PC IBM x86== Dans ce qui suit, nous allons étudier l'espace d'adressage des processeurs x86. L'organisation de leur espace d'adressage a évolué dans le temps et l'espace d'adressage s'est adapté. Mais il s'est adapté sans modifier l'existant, pour des raisons de compatibilité. Aussi, les processeurs x86 modernes peuvent fonctionner dans plusieurs modes, appelés mode réel, protégé, virtuel 8086, long, ''system management mode'', et compatibilité 32 bits. [[File:AMD64StateDiagram.svg|centre|vignette|upright=2|AMD64StateDiagram]] Le ''system management mode'' a déjà été abordé dans le chapitre sur les interruptions. Pour simplifier, il ne s'agit pas d'un véritable mode au sens "adressage". Dans ce mode, le système d'exploitation est mis en pause et le BIOS ou un autre firmware s'exécute sans restriction. On y rentre grâce à une interruption dédiée, lancée par le noyau de l'OS. Le système d'exploitation exécute cette interruption afin de laisser la main au BIOS, pour lui déléguer certaines tâches. Par contre, les autres modes sont bien plus intéressant, car ils ont chacun un espace d'adressage différent. Voyons-les dans le détail. ===Le mode réel : l'adressage sur 20 bits=== Le tout premier mode était le '''mode réel''', qui utilisait des adresses de 20 bits, ce qui fait 1 mébioctet de mémoire adressable. Il était utilisé sur des processeurs 16 bits, qui géraient donc des adresses de 16 bits capables d'adresser 64 kibioctets de mémoire. Pour contourner cette limitation, le processeur utilisait la segmentation, que nous verrons dans le prochain chapitre. Pour le moment, nous allons dire que la mémoire en mode réel était composée de 16 blocs de 64 kibioctets. C'est une simplification qui ne fera pas de mal. Le processeur démarre systématiquement en mode réel, y compris sur les ordinateurs modernes. Le BIOS s'exécute dans ce mode, avant de passer en mode protégé et de laisser la main à l'UEFI et au système d'exploitation. Autrefois, le système d'exploitation s'exécutait aussi en mode réel, c'est le cas du DOS. En mode réel, l'espace d'adressage est peuplé par de la RAM, la ROM du BIOS et des périphériques. La RAM prenait les adresses basses, la ROM était au sommet de l'espace d'adressage, et les périphériques entre les deux. Les premiers 640 kibioctets sont réservés à la RAM et sont appelés la '''mémoire conventionnelle'''. Les octets restants forment la '''mémoire haute''', réservée aux ROM et aux périphériques. Les deux premiers kibioctets de la mémoire conventionnelle sont initialisés au démarrage de l'ordinateur. Ils sont utilisés pour stocker le vecteur d'interruption (on expliquera cela dans quelques chapitres) et servent aussi au BIOS. La portion réservée au BIOS, la ''BIOS Data Area'', mémorise des informations en RAM. Elle commence à l'adresse 0040:0000h, a une taille de 255 octets, et est initialisée lors du démarrage de l'ordinateur. Le reste de la mémoire conventionnelle est réservée au le système d'exploitation (MS-DOS, avant sa version 5.0) et au programme en cours d’exécution. La mémoire haute est sectionnée en deux portions. Le sommet de la mémoire haute est réservé au BIOS, alors que le bas de la mémoire haute est réservé aux périphériques. Dans cette dernière, on trouve les BIOS des périphériques (dont celui de la carte vidéo, s'il existe), qui sont nécessaires pour les initialiser et pour communiquer avec eux. De plus, on y trouve la mémoire de la carte vidéo et éventuellement la mémoire d'autres périphériques comme la carte son. {| class="wikitable" style="text-align:center;" |+ Espace d'adressage en mode réel |- ! Numéro du bloc !! Contenu du bloc |- | 1024 - 960 Kio || class="f_jaune" | BIOS principal (ROM) |- | 960 Kio - 640 Kio | class="f_vert" | ROM d'extension des périphériques (XT, EGA, 3270 PC) </br> Mémoire vidéo des cartes EGA, MDA ou CGA |- | 640 Kio - 0 || class="f_rouge" | Mémoire RAM, mémoire conventionnelle |} Pour détailler un peu plus, il faut tenir compte du découpage de l'espace d'adressage en blocs de 64 kibioctets. Le découpage en blocs de 64 kibioctets est particulièrement important pour la mémoire haute. Des blocs sont attribués à un périphérique spécifique. Par exemple, le 16ème bloc est réservé au BIOS, le 12ème aux ROM des périphériques, le 10ème et le 11ème à la mémoire vidéo, etc. {| class="wikitable" style="text-align:center;" |+ Espace d'adressage en mode réel |- ! Numéro du bloc !! Contenu du bloc |- | 0 || rowspan="10" class="f_rouge" | Mémoire RAM, mémoire conventionnelle |- | 1 |- | 2 |- | 3 |- | 4 |- | 5 |- | 6 |- | 7 |- | 8 |- | 9 |- | 10 || class="f_vert" | Mémoire vidéo des cartes EGA |- | 11 || class="f_vert" | Mémoire vidéo des cartes MDA ou CGA |- | 12 || class="f_vert" | ROM d'extension des périphériques (XT, EGA, 3270 PC) |- | 13 || class="f_gris" | Autre, non-réservé |- | 14 || class="f_gris" | Autre, non-réservé |- | 15 || class="f_jaune" | ROM du BIOS |} Vous remarquerez que les blocs 13 et 14 sont non-réservés. Ils peuvent être utilisés pour mapper des périphériques en mémoire. Mais si aucun périphérique n'est mappé dedans, les blocs sont laissés libres. Les OS n'hésitaient pas à les utiliser pour adresser de la RAM. Par exemple, si l'ordinateur n'a pas de carte vidéo EGA, le bloc qui leur était réservé était réutilisé comme mémoire RAM. La mémoire conventionnelle passait alors de 640 à 704 kibioctets de RAM disponibles. L'espace d'adressage correspond à la ROM du BIOS. Mais avec le temps, la ROM est devenue plus lente au point qu'exécuter du code directement depuis la ROM du BIOS était un problème niveau performance. Je rappelle que le BIOS contient de nombreuses routines d'interruptions qui étaient appelées par les systèmes d'exploitation de l'époque, notamment le DOS. Heureusement, diverses optimisations permettaient de copier le BIOS dans la mémoire RAM, plus rapide que la ROM du BIOS. Il s'agit des techniques de '''''BIOS Shadowing'''''. Elles s'appliquaient au BIOS proprement dit, mais le BIOS vidéo pouvait aussi être optimisé ainsi, mais les ROM d'extension ne le sont généralement pas. Le ''BIOS Shadowing'' peut s'implémenter de plusieurs manières, mais la plus courante mappait temporairement de la RAM dans le même espace d'adressage que la ROM du BIOS. Le code du BIOS copiait le BIOS avec une boucle, dont chaque itération lisait une adresse du BIOS et écrivait dans cette même adresse. Juste avant la boucle, le ''chipset'' était configuré par le BIOS pour que les lectures atterrissent dans la ROM du BIOS, mais que les écritures tombaient dans une mémoire RAM mappée. Une fois la copie/boucle terminée, l'adressage tombait dans la RAM aussi bien pour les lectures et écritures. Elle demandait un support du ''chipset'' et du logiciel, pour détourner les adresses temporairement. ===Interlude : les technologies d'expanded memory=== Déjà à l'époque, 1 mébioctet était une limite assez faible, même si cela n'a pas empêché à un grand dirigeant de l'industrie informatique de dire que "640K ought to be enough for anybody". Aussi, diverses solutions matérielles ont vu le jour. Elles étaient peu utilisées et ont concrètement servi à des applications spécifiques. La principale est l''''''expanded memory''''' (EMS), une solution utilisant une carte d'extension avec plusieurs modules de RAM installés dessus. Les modules étaient accédés via la technique de la commutation de banque. [[File:Expanded memory.svg|centre|vignette|upright=2|L'espace d'adressage avec ''Expanded memory''. La carte d'extension et le matériel requis ne sont pas représentés.]] Le terme ''expanded memory'' regroupe plusieurs technologies propriétaires semblables sur le principe mais différentes dans l'exécution. Les premières utilisaient des banques de 64 kibioctets, qui étaient mappées dans un bloc de 64 kibioctets libre en mémoire haute. L'usage de banques de 64 kibioctets collait bien avec l'adressage particulier du mode réel, où la mémoire était gérée par blocs de 64 kibioctets. Les technologies ultérieures utilisaient 2 à 4 pages de 16 kibioctets, qui pouvaient être placée n'importe où en mémoire conventionnelle. [[File:EmulexPersyst 4M ISA.jpeg|centre|vignette|upright=2|Carte d'extension ''Expanded memory'' de marque Emulex Persyst, de 4 mébioctets.]] L'''expanded memory'' demandait que les programmes soient codés pour utiliser cette fonctionnalité. Pour cela, ils devaient utiliser une technique de programmation nommée ''Overlay programming''. L'idée est de découper le programme en plusieurs blocs séparés, appelés des '''''overlays'''''. Certains blocs sont en permanence en RAM, mais d'autres sont soit chargés en RAM, soit stockés ailleurs. Le ailleurs est souvent le disque dur, ou dans la RAM ''expanded memory'' si elle est disponible. Le va-et-vient des ''overlays'' entre RAM et disque dur était réalisé en logiciel, par le programme lui-même. [[File:Overlay Programming.svg|centre|vignette|upright=1.5|''Overlay Programming''.]] L'utilisation de l'''expanded memory'' utilisait l'interruption logicielle 67h, une interruption peu utilisée à l'époque, qui était libre. La carte d'extension était utilisée à travers un pilote de périphérique dédié, qui était appelé via l'interruption 67h. ===Le mode protégé : l'adressage sur 24 et 32 bits=== Par la suite, le mode réel a été remplacé par le '''mode protégé'''. Il utilise la mémoire virtuelle, ce qui fait qu'on devrait le détailler dans le chapitre suivant. Il permettait aussi de dépasser la limite du mébioctet de mémoire. L'espace d'adressage en mode protégé est passé de 24 bits sur le CPU 286, à 32 bits sur le 386 et les CPU suivants. Les programmes conçus pour le mode réel ne pouvaient pas s'exécuter en mode protégé. Pour corriger cela, le 286 a introduit le '''mode virtuel 8086''', aussi appelé mode V86. Il s'agit d'une technique de virtualisation, ce qui fait qu'on en parlera en détail dans le chapitre sur la virtualisation assistée en matériel. Tout ce que l'on peut dire ici est que le mode V86 a été détourné pour que les applications DOS utilisent la mémoire au-delà du mébioctet, appelée la '''mémoire étendue'''. Les logiciels DOS accédaient à la mémoire étendue à travers un driver DOS dédié (HIMEM.SYS). Les logiciels DOS déplaçaient des données dans la mémoire étendue, puis les rapatriaient en mémoire conventionnelle quand ils en avaient besoin. Il ne faut pas confondre mémoire étendue et ''expanded memory''. La technique de ''expanded memory'' repose sur de la commutation de banque, demande une carte d'extension dédiée, fonctionne sans mode V86. A l'inverse, la mémoire étendue ne gère pas de commutation de banque, ne demande pas de carte d'extension, mais utilise le mode V86. Par contre, il est possible d'émuler l'''expanded memory'' sans carte d'extension, avec la mémoire étendue. Quelques cartes mères intégraient des techniques pour, mais une émulation logicielle était aussi possible en réécrivant l'interruption 67h. ===Le mode long : l'adressage 64 bits=== Par la suite, les processeurs ont introduit le '''mode long''', dans lequel les adresses font 64 bits. Le CPU en mode long a aussi un jeu d'instruction totalement différent, des registres en plus et d'autres fonctionnalités actives uniquement dans ce mode. Les programmes compilés pour le 64 bits s'exécutent nativement dans ce mode, alors que les programmes 32 et 16 bits s'exécutent dans un mode de compatibilité dédié. Sur les systèmes 64 bits, le bus d'adresse fait seulement 48 bits afin d'économiser des interconnexions. Avec 48 bits, il manque 64 - 48 = 16 bits d'adresses, qui ne peuvent pas être utilisés pour adresser quoi que ce soit. La règle est que ces 16 bits doivent être tous égaux : soit ils valent tous 0, soient ils sont tous à 1. Les adresses qui respectent cette contrainte sont appelées des '''adresses canoniques'''. Le résultat est que l'espace d'adressage est coupé en trois sections, comme illustré ci-dessous. * Les '''adresses canoniques basses''' ont leurs 16 bits de poids fort à 0 et sont situées en bas de l'espace d'adressage, dans les premières adresses. * Les '''adresses canoniques hautes''' ont leurs 16 bits de poids fort à 1 et sont situées au sommet de l'espace d'adressage, dans les dernières adresses. * Entre les deux, se trouvent des '''adresses non-canoniques''', qui ne sont pas accessibles. Y accéder déclenche la levée d'une exception matérielle. {| |[[File:AMD64-canonical--48-bit.png|vignette|Espace d'adressage x86-64 bits, avec des adresses physiques de 48 bits.]] |[[File:AMD64-canonical--56-bit.png|vignette|Espace d'adressage x86-64 bits, avec des adresses physiques de 57 bits.]] |} : Les futurs systèmes x86 devraient passer à des adresses de 57 bits, les trois sections auront donc des frontières différentes. Les adresses non-canoniques sont censées être inutilisables. Mais les programmeurs aimeraient bien pouvoir les utiliser pour des ''pointeurs tagués'', à savoir des pointeurs/adresses associées à des informations. L'idée serait d'utiliser les 16 bits de poids fort pour stocker des informations liées au pointeur, seuls les 48 bits restant codant l'adresse. Les 16 bits peuvent être utilisés de manières très diverses. Un exemple est la technique du '''''memory tagging''''', qui crée une somme de contrôle au pointeur. La somme de contrôle est générée par un algorithme cryptographique, à partir de l'adresse du pointeur. Elle est vérifiée à chaque utilisation du pointeur. Si la somme de contrôle ne correspond pas au pointeur, une erreur est levée. Si jamais un virus ou un code malveillant modifie un pointeur, il ne saura pas comment calculer la somme de contrôle, la somme de contrôle a énormément de chances d'être incorrecte. Quelques fonctionnalités des processeurs visent à autoriser l'utilisation des adresses non-canoniques. L'idée est que les 16 bits de poids fort sont ignorées lors des accès mémoire, ce qui permet d'utiliser les 16 bits de poids fort à volonté. Il s'agit des techniques ''Top Byte Ignore'' d'ARM, ''Upper Address Ignore'' d'AMD, et ''Linear Address Masking'' d'Intel. ==La z/architecture d'IBM== IBM a sa propre gamme de ''mainframes'', qui utilisent des processeurs fait maison par IBM lui-même. Les CPU IBM ont leur propre jeu d'instruction CISC, appelé '''''z/architecture'''''. Et ce jeu d'instruction a des possibilités en termes d'adressage qui sont extrêmement intéressantes, en plus d'être assez inédites. Pour ce chapitre, nous allons nous concentrer sur une fonctionnalité : les programmes ont accès à plusieurs espaces d'adressage naturellement. L'avantage pour les programmes utilisateur n'est pas très évident, mais il l'est nettement plus pour les systèmes d'exploitation. Le système d'exploitation de ces ''mainframes'', le z/OS, profite de cette possibilité, idem pour l'hyperviseur z/VM. Il utilise cette possibilité pour donner un espace d'adressage séparé pour chaque programme en cours d'exécution. Les programmes sont alors isolés les uns des autres. Cette '''isolation des processus''' n'est pas spécifique à IBM : tous les systèmes d'exploitation modernes l'utilisent. Mais les processeurs x86 doivent se débrouiller seulement avec des techniques dites de mémoire virtuelle, alors que les ''mainframes'' IBM complètent la mémoire virtuelle avec des facilités en plus. Pour les programmes utilisateurs, un intérêt est que cela permet de partager des données entre plusieurs programmes. Ils ont un espace d'adressage rien qu'à eux, mais les données partagées sont placées dans un espace d'adressage commun, auquel les deux programmes ont accès. L'espace d'adressage partagé peu même être configuré de manière à ne contenir que des données, il est impossible d'exécuter du code depuis cet espace d'adressage. Ce qui empêche certains ''malwares'' de fonctionner. La ''z/architecture'' supporte 65536 espaces d'adressage simultanés. Les espaces d'adressage sont numérotés, ils ont chacun un '''''Address-Space Number''''' codé sur 16 bits. Nous utiliserons l’abréviation ASN dans ce qui suit. Par contre, un programme ne peut pas utiliser tout ces espaces d'adressages, il ne peut en utiliser que 1024 en même temps. De plus, ils ne sont accessibles que quand le processeur est dans un mode de fonctionnement bien précis. Un ^processeur ''z/architecture'' peut en effet fonctionner dans deux modes, qu'on va appeler "standard" et "étendu". ===Le mode standard de la ''z/architecture''=== En mode standard, un programme exécuté sur la ''z/architecture'' utilise deux espaces d'adressages : l''''espace d'adressage primaire''' où se situent le programme exécuté et les données importantes, un '''espace d'adressage secondaire''' utilisé pour des tâches/données annexes. : Un troisième espace d'adressage l''''espace d'adressage ''home''''', est réservé au système d'exploitation. Il a pour particularité de ne pas avoir d'ASN. Il n'est concrètement accesible qu'en mode superviseur, donc par le système d'exploitation. Nous n'en parlerons pas dans ce qui suit. L'ASN de ces deux espaces d'adressage est mémorisé dans les registres de contrôle du processeur. L'ASN de l'espace primaire est dans le registre de contrôle n°3, l'ASN de l'espace secondaire est dans le registre de contrôle n°4 (ils sont dans les 16 bits de poids faible, dans les deux cas). Les deux registres de contrôle sont initialisés au démarrage de l'application, par le système d'exploitation, ce qui fait que ce sont des espaces d'adressage par défaut. Le programme a donc accès à ces deux espaces d'adressage sans rien faire. Mais comment le processeur pour utiliser ces espaces d'adressages ? Par exemple, s'il veut faire une lecture, comment précise-t-il l'espace d'adressage courant ? La réponse est que le processeur peut être dans deux états : soit primaire, soit secondaire. Dans le premier état, toutes les lectures/écritures se font dans l'espace d'adressage primaire. Dans le second, elles se font toutes dans l'espace d'adressage secondaire. Il peut passer de l'un à l'autre en utilisant l'instruction ''Set Address Space Control''. Le processeur dispose aussi d'instructions ''MOVE TO PRIMARY'' et ''MOVE TO SECONDARY'' pour échanger des données dans les deux espaces d'adressage. ===Les registres d'accès de la ''z/architecture''=== Un programme peut donc passer de l'espace d'adressage primaire à secondaire (ou inversement) en utilisant l’instruction ''SET ADDRESS SPACE CONTROL''. Mais il peut en profiter pour passer dans un mode où il a accès à 16 espaces d'adressage différents, en même temps. Le mode en question est appelé le '''mode ''access register'''''. Le processeur a donc quatre modes de fonctionnement : primaire, secondaire, ''home'' et ''access register''. Qui dit 16 espaces d'adressage dit 16 ASNs à mémoriser quelque part. Pour cela, le processeur dispose de 16 '''registres d'accès''', chacun mémorisant un ASN. Un accès mémoire précise quel registre d'accès utiliser, ce qui lui permet de sélectionner l'espace d'adressage voulu. Pas besoin d'utiliser ''SET ADDRESS SPACE CONTROL'' pour passer de l'un à l'autre, le programme peut gérer 16 espaces d’adressage en même temps. Ce qui est une différence importante par rapport au mode de fonctionnement précédent. : Une précision importante est que le premier registre d'accès mémorise l'ASN de l'espace d'adressage primaire. De plus, les instructions sont chargées depuis l'espace d'adressage primaire, sauf quand le CPU est en mode ''home''. Les CPU ''z/architecture'' disposent de 16 registres généraux de 64 bits, de 16 registres flottants eux aussi de 64 bits, de 16 registres de contrôle eux aussi de 64 bits, et de '''registres d'accès''' qui gèrent les espaces d'adressage. Chaque registre entier est associé à un espace d'adressage particulier, qui est indiqué par son registre d'accès. Les registres d'accès sont utilisés lors des lectures/écritures indirectes. L'adresse est lue depuis un registre général, et son registre d'accès est consulté pour déterminer l'espace d'adressage. Une implémentation simple numéroterait les espaces d'adressage et mettrait les numéros dans les registres d'accès. Les registres d'accès seraient idéalement protégés en écriture et seulement accessibles par le système d'exploitation. Mais ce n'est pas ce qui est fait, pour des raisons de sécurité. Les registres d'accès mémorisent bien un identifiant, mais celui-ci ne sélectionne pas l'espace d'adressage directement. L'identifiant est envoyée à une table de correspondance matérielle, qui mémorise les correspondances entre un identifiant et un espace d'adressage. Il y a une table de correspondance par programme, ce qui fait qu'un identifiant ne correspondra pas au même espace d'adressage dans deux programmes différents. La table de correspondance mémorise aussi des droits d'accès, qui sont différents pour chaque programme. Par exemple, tel programme aura accès en lecture seule à tel espace d'adressage, alors qu'un autre programme y aura accès en lecture et écriture, etc. Un espace d'adressage peut ainsi être partagé entre deux programmes plus facilement, tout en ajoutant des garanties de sécurité. L'usage des registres d'accès est aussi plus flexible qu'on ne pourrait le croire. Le programme peut lire et écrire dans les registres d'accès, copier un registre d'accès dans un autre, etc. Par contre, la table de correspondance empêche à un programme d’accéder à un espace d'adressage arbitraire. Le système d'exploitation remplit la table de manière à n'autoriser l'accès qu'aux espaces d'adressage adéquats, il y enregistre seulement les correspondances utiles. Si un programme forge un identifiant arbitraire, et l'envoie à la table de correspondance, celle-ci n'aura pas la correspondance associée et l'accès mémoire sera invalidé. La table de correspondance est en réalité une table à deux niveaux. Une première table reçoit l'ASN et fournit l'adresse de la seconde table. La seconde table récupère alors l'adresse. Vu que le processeur utilise la mémoire virtuelle, c'est une adresse dite virtuelle, mais laissons cela pour le prochain chapitre. La première table est appelée l'''''access list''''', alors que la seconde est appelée l''''''ASN-Second-Table-Entry Sequence Number'''''. Les deux tables sont accessibles en écriture via une instruction ''LOAD ADDRESS SPACE PARAMETERS'', seulement accesible en mode noyau. : Il faut noter que si l'''access list'' a une taille limitée à 64 kibioctets, la seconde n'est pas limitée, ce qui permet de dépasser la limite des 655536 ASNs. La limite de 65535 ASNs est uen limite par programme, pas totale. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs 8 bits et moins | prevText=Les processeurs 8 bits et moins | next=L'abstraction mémoire et la mémoire virtuelle | nextText=L'abstraction mémoire et la mémoire virtuelle }} </noinclude> 471v4lphndvlzqfpyaxw81ja5z8fgpz 774377 774376 2026-10-07T17:15:04Z Mewtow 31375 /* Les registres d'accès de la z/architecture */ 774377 wikitext text/x-wiki L''''espace d'adressage''' du processeur est l'ensemble des adresses utilisables par le processeur. Par exemple, un processeur 16 bits peut adresser 2^16 = 65536 adresses, l'ensemble de ces adresses forme son espace d'adressage. L'espace d'adressage n'est pas toujours égal à la mémoire réellement installée. S'il n'y a pas assez de RAM installée, des adresses seront inoccupées. De plus, une partie de l'espace d'adressage peut être détourné pour communiquer avec les périphériques, comme nous le verrons plus bas. Nous verrons aussi qu'un processeur peut avoir plusieurs espaces d'adressages séparés. ==L'adressage de la RAM et de la ROM== Avoir plusieurs espaces d'adressage spécialisés est quelque chose que nous avons déjà rencontré dans les chapitres précédents, mais sans le dire ouvertement. Aussi, nous allons faire quelques rappels sur les cas déjà rencontrés. En premier lieu, nous allons rappeler la différence entre architectures Von Neumann et Hardvard. La différence entre les deux tient dans l'adressage des mémoires RAM et ROM : est-ce qu'elles sont dans un seul espace d'adressage, ou dans des espaces d'adressage séparés. L''''architecture Von Neumann''' a un seul espace d'adressage, découpé entre la mémoire RAM d'un côté et la mémoire ROM de l'autre. Une adresse correspond soit à la mémoire RAM, soit à la mémoire ROM, mais pas aux deux. Typiquement, la mémoire ROM est placée dans les adresses hautes, les plus élevées, alors que la RAM est placée dans les adresses basses en commençant par l'adresse 0. C'est une convention qui n'est pas toujours respectée, aussi mieux vaut éviter de la tenir pour acquise. [[File:Vision de la mémoire par un processeur sur une architecture Von Neumann.png|centre|vignette|upright=2|Vision de la mémoire par un processeur sur une architecture Von Neumann.]] L''''architecture Harvard''' utilise des espaces d'adressage séparés pour la RAM et la ROM. Une même adresse peut correspondre soit à la ROM, soit à la RAM. Le processeur voit bien deux mémoires séparées, chacune dans son propre espace d'adressage. Les deux espaces d'adressage n'ont pas forcément la même taille. Il est possible d'avoir un plus gros espace d'adressage pour la RAM que pour la ROM. Cela implique que les adresses des instructions et des données soient de taille différentes. C'est peu pratique et c'est rarement implémenté, ce qui fait que le cas le plus courant est celui où les deux espaces d'adressages ont la même taille. [[File:Vision de la mémoire par un processeur sur une architecture Harvard.png|centre|vignette|upright=2|Vision de la mémoire par un processeur sur une architecture Harvard.]] Sur une '''architecture Harvard modifiée''', le programme est placé dans un espace d'adressage à part du reste. Le problème, c'est que cet espace d'adressage ne contient pas que le code machine à exécuter. Il contient aussi des constantes, à savoir des données qui gardent la même valeur lors de l'exécution du programme. Elles peuvent être lues, mais pas modifiées durant l'exécution du programme. L'accès à ces constantes demande d'aller lire celles-ci dans l'autre espace d'adressage, pour les copier dans les registres généraux. Le processeur doit pour cela avoir des instructions de lecture dédiées. En clair, il y a une instruction LOAD pour lire dans la mémoire ROM, une autre pour lire dans la RAM. [[File:Espaces d'adressage sur une archi harvard modifiée.png|centre|vignette|upright=3|Espaces d'adressage sur une archi harvard modifiée]] Une autre solution utilise des instructions de copie, qui copient les constantes en mémoire RAM, donc d'un espace d'adressage à l'autre. [[File:Organisation des espaces d'adressage sur une archi harvard modifiée.png|centre|vignette|upright=3|Organisation des espaces d'adressage sur une archi harvard modifiée]] ==L'adressage des périphériques== Passons maintenant à l'adressage des périphériques. La communication avec les périphériques se fait par l'intermédiaire de registres d’interfaçage. Et ces registres peuvent soit avoir un espace d'adressage séparé, soit être inclus dans l'espace d'adressage des mémoires. Dans ce qui suit, nous allons supposer que l'architecture des de type Von Neumann pour simplifier les explications. ===Les entrées-sorties mappées en mémoire=== Une partie de l'espace d'adressage peut être détourné pour communiquer avec les périphériques. Le périphérique se retrouve inclus dans l'ensemble des adresses utilisées pour manipuler la mémoire : on dit qu'il est mappé en mémoire. Les adresses mémoires associées à un périphérique sont redirigées automatiquement vers les registres du périphérique en question, voire vers sa mémoire intégrée. On parle alors d''''entrées-sorties mappées en mémoire'''. [[File:IO mappées en mémoire.png|centre|vignette|upright=2.0|IO mappées en mémoire]] On remarque un défaut inhérent à cette technique : les adresses utilisées pour les périphériques ne sont plus disponibles pour la RAM. On ne peut plus adresser autant de RAM qu'avant. La perte dépend des périphériques installés et de leur gourmandise en adresses mémoires. C'est ce qui causait un problème assez connu sur les ordinateurs 32 bits, capables d'adresser 4 gibioctets de mémoire. Certaines personnes installaient 4 gigaoctets de mémoire sur leur ordinateur 32 bits et se retrouvaient avec « seulement » 3,5 à 3,8 gigaoctets de mémoire, les périphériques prenant le reste. Il est possible que la RAM d'un périphérique soit mappée en RAM. Un exemple classique est celui des cartes graphiques qui incorporent une mémoire RAM appelée la mémoire vidéo. La mémoire vidéo est mappée en mémoire, ce qui permet au processeur d'écrire directement dedans. Toute lecture ou écriture dans les adresses associées est redirigée vers le bus PCI/AGP/PCI-Express. Nous verrons plus bas des exemples d'ordinateurs où la mémoire vidéo est mappée en mémoire, que ce soit totalement ou partiellement. Si je dis totalement ou partiellement, c'est parce que les cartes graphiques modernes disposent de tellement de mémoire qu'on ne peut pas la mapper totalement dans l'espace d'adressage. Sur les systèmes d'avant 2008, seuls 256 mégaoctets de mémoire vidéo sont mappés en mémoire RAM. Le reste de la mémoire vidéo est invisible du point de vue du processeur, mais manipulable par le GPU à sa guise. Après 2008, la spécification du PCI-Express ajouta un support de la technologie ''Resizable Bar'', qui permet au processeur d’accéder directement à plus de 256 mégaoctets de mémoire vidéo, voire à la totalité de la mémoire vidéo. ===L'espace d'adressage séparé pour les entrées-sorties=== Les entrées-sorties et périphériques peuvent avoir leur propre espace d'adressage dédié, séparé de celui utilisé pour les mémoires RAM et ROM. [[File:Espaces_d'adressages_séparés_entre_mémoire_et_périphérique.png|centre|vignette|upright=3|Bit IO.]] Une même adresse peut donc adresser soit une entrée-sortie, soit une case mémoire. Et pour faire la différence, le processeur doit avoir des instructions séparées pour adresser les périphériques et la mémoire. Il a des instructions de lecture/écriture pour lire/écrire en mémoire, et d'autres pour lire/écrire les registres d’interfaçage. Sans cela, le processeur ne saurait pas si une adresse est destinée à un périphérique ou à la mémoire. Cela élimine aussi les problèmes avec les caches : les accès à l'espace d'adressage de la RAM passent par l'intermédiaire de la mémoire cache, alors les accès dans l'espace d'adressage des périphériques le contournent totalement. Là encore, les deux espaces d'adressage n'ont pas forcément la même taille. Il arrive que les deux espaces d'adressage aient la même taille, le plus souvent sur des ordinateurs complexes avec beaucoup de périphériques. Mais les systèmes embarqués ont souvent des espaces d'adressage plus petits pour les périphériques que pour la ou les mémoires. L'implémentation varie grandement suivant le cas, la première méthode imposant d'avoir deux bus séparés pour les mémoires et les périphériques, l'autre permettant un certain partage du bus d'adresse. Nous reviendrons dessus plus en détail dans le chapitre sur l'adressage des périphériques. ==La ''memory map'' d'un ordinateur== Les deux sections précédentes nous ont appris que l'on peut utiliser un espace d'adressage séparé pour la ROM et un autre pour les périphériques. En tout, cela donne quatre possibilités distinctes. {|class="wikitable" |- ! ! IO mappées en mémoire ! IO séparées |- ! ROM mappée en mémoire (architecture Von Neumann) | Un seul espace d'adressage | Deux espaces d'adressage : * un pour les mémoires RAM/ROM ; * un pour les IO. |- ! ROM séparée (architecture Harvard) | Deux espaces d'adressage : * un pour la RAM et les IO ; * un pour la ROM. | Trois espaces d'adressages : * un pour la RAM ; * un pour la ROM ; * un pour les IO. |} Les quatre solutions ont des avantages et inconvénients divers, mais il est intéressant de contraster un espace d'adressage unique avec plusieurs espaces d'adressages. ===L'espace d'adressage unique=== Avec un espace d'adressage unique, la ROM est au sommet de l'espace d'adressage, les périphériques sont juste en-dessous, la RAM commence à l'adresse 0 et prend les adresses basses. Faire simplifie grandement l'implémentation matérielle de l'adressage. Notons que d'autres composants que les périphériques ou les mémoires peuvent se trouver dans l'espace d'adressage. On peut y trouver les horloges temps réels, des timers, des senseurs de température, ou d'autres composants placés sur la carte mère. Un exemple un peu original est le suivant : la console de jeu Nintendo DS incorporait une unité de calcul spécialisée dans les divisions et racines carrées, séparée du processeur, qui était justement mappée en mémoire ! [[File:Espace d'adressage classique avec entrées-sorties mappées en mémoire.png|centre|vignette|upright=2|Espace d'adressage classique avec entrées-sorties mappées en mémoire]] L'espace d'adressage unique est plus simple pour les programmeurs, malgré un désavantage dont il faut parler. Le problème est que des adresses censées être disponibles pour la RAM sont détournées vers la ROM ou les périphériques, ce qui limite la quantité de RAM qui peut être réellement adressée en pratique. C'est un défaut qui se manifeste seulement pour les petits espaces d'adressages, de 8 à 16 bits, qui ne permettent pas d'adresser beaucoup de RAM. Avec des adresses codées sur 16 bits, un espace d'adressage de 64 kibioctets de RAM est grignoté par des entrées-sorties assez rapidement. Par contre, pour des adresses de 32 à 64 bits, une bonne partie de l'espace d'adressage est inutilisé, car il n'y a pas assez de RAM. L'espace d'adressage unique n'a donc pas de désavantages. Les vielles consoles de jeux et certains vieux ordinateurs (Commodores et Amiga), utilisaient un espace d'adressage unique. Elles n'avaient pas une variété de cartes graphiques ou de cartes sons différentes, comme sur les PCs modernes. Au contraire, elles avaient la même configuration matérielle, le matériel ne pouvait pas être changé ni upgradé. Le système d'exploitation était rudimentaire et ne contrôlait pas vraiment l'accès au matériel. Les programmeurs avaient donc totalement accès au matériel et mapper les entrées/sorties en mémoire rendait la programmation des périphériques très simple. <gallery widths=200px heights=500px> N5200mk2 memory map.svg|Adressage mémoire (carte mémoire) du N5200mk2. PC-9801VM memory map.svg|Adressage mémoire (carte mémoire) du PC-9801VM. </gallery> ===La commutation de banques (''bank switching'')=== Le '''''bank switching''''', aussi appelé '''commutation de banque''', permet d'utiliser plusieurs espaces d'adressage sur un même processeur, sans attribuer chaque espace d'adressage pour une raison précise. L'espace d'adressage est présent en plusieurs exemplaires appelés des '''banques'''. Les banques sont numérotées, chaque numéro de banque permettant de l'identifier et de le sélectionner. Le but de cette technique est d'augmenter la mémoire disponible pour l'ordinateur. Par exemple, supposons que j'ai besoin d'adresser une mémoire ROM de 4 kibioctets, une RAM de 8 kibioctets, et divers périphériques. Le processeur a un bus d'adresse de 12 bits, ce qui limite l'espace d'adressage à 4 kibioctets. Dans ce cas, je peux réserver 4 banques : une pour la ROM, une pour les périphériques, et deux banques qui contiennent chacune la moitié de la RAM. La simplicité et l'efficacité de cette technique font qu'elle est beaucoup utilisée dans l'informatique embarquée. [[File:PC-8801MemoryMap.gif|centre|vignette|upright=2|exemple de Bank switching.]] Cette technique demande d'utiliser un bus d'adresse plus grand que les adresses du processeur. L'adresse réelle se calcule en concaténant le numéro de banque avec l'adresse accédée. Le numéro de la banque actuellement en cours d'utilisation est mémorisé dans un registre appelé le '''registre de banque'''. On peut changer de banque en changeant le contenu de ce registre. Le processeur dispose souvent d'instructions spécialisées qui en sont capables. {| class="wikitable flexible" |[[File:Banque mémoire.png|Banque mémoire.]] |[[File:Registre de banque.png|Registre de banque.]] |} ==Le placement des programmes dans l'espace d'adressage== Les ordinateurs modernes sont des architectures Von Neumann, avec un seul espace d'adressage, qui est partagé suivant les besoins. Et un point important est que le système d'exploitation et les programmes sont copiés en mémoire RAM avant d'être exécutés. Ils ne sont pas exécutés depuis une mémoire ROM, cela est réservé aux systèmes embarqués simples. Et cela a une influence sur l'espace d'adressage. Les programmes et le système d'exploitation doivent se partager l'espace d'adressage. Voyons voir comment ce partage se fait. Dans ce chapitre, nous allons volontairement mettre de côté les ordinateurs qui supportent la mémoire virtuelle. Nous en parlerons dans le prochain chapitre. Le principe est de voir comment un processeur avec un seul espace d'adressage gèrent la présence d'un ou de plusieurs programmes. ===Un seul espace d'adressage, non-partagé=== Le cas le plus simple est celui où il n'y a pas de système d'exploitation et où un seul programme s'exécute sur l'ordinateur. Le programme a alors accès à tout l'espace d'adressage, sauf pour ce qui est réservé aux périphériques et à la mémoire ROM. Le programme organise la mémoire en plusieurs sections, dans lesquelles le programme range des données différentes. Typiquement, on trouve quatre blocs de mémoire, appelés des segments, chacun étant spécialisé pour les données, le code, la pile, etc. Voici ces trois sections : * Le segment '''''text''''' 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. * Le segment pour la '''pile''', de taille variable. * le reste est appelé le '''tas''', de taille variable. [[File:Organisation d'un espace d'adressage unique utilisé par un programme unique.png|centre|vignette|upright=2|Organisation d'un espace d'adressage unique utilisé par un programme unique]] [[File:Typical computer data memory arrangement.png|vignette|upright=0.5|Typical computer data memory arrangement]] La pile et le tas sont de taille variable, ce qui veut dire qu'ils peuvent grandir ou diminuer à volonté, contrairement au reste. Entre le tas et la pile, on trouve un espace de mémoire inutilisée, qui peut être réquisitionné selon les besoins. La pile commence généralement à l'adresse la plus haute et grandit en descendant, alors que le tas grandit en remontant vers les adresses hautes. Il s'agit là d'une convention, rien de plus. Il est possible d'inverser la pile et le tas sans problème, c'est juste que cette organisation est rentrée dans les usages. Avant de poursuivre, précisons qu'il est possible de regrouper plusieurs programmes distincts dans un seul, afin qu'ils partagent le même morceau de mémoire. Les programmes portent alors le nom de '''threads'''. Les ''threads'' d'un même processus partagent le même espace d'adressage. Ils partagent généralement certains segments : ils se partagent le code, le tas et les données statiques. Par contre, chaque thread dispose de sa propre pile d'appel. [[File:Single vs multithreaded processes.jpg|centre|vignette|upright=2.0|Distinction entre processus mono et multi-thread.]] Il va de soi que cette vision de l'espace d'adressage ne tient pas compte des périphériques. C'est-à-dire que les schémas précédents partent du principe qu'on a un espace d'adressage séparé pour les périphériques. Dans le cas où les entrées-sorties sont mappées en mémoire, l'organisation est plus compliquée. Généralement, les adresses associées aux périphériques sont placées juste au-dessus de la pile, dans les adresses hautes. ===Un seul espace d'adressage, partagé avec le système d'exploitation=== [[File:Gestion de la mémoire sur les OS monoprogrammés.png|vignette|upright=1.0|Gestion de la mémoire sur les OS monoprogrammés.]] Maintenant, étudions le cas où le programme partage la mémoire avec le système d'exploitation. Sur les systèmes d'exploitation les plus simples, on ne peut lancer qu'un seul programme à la fois. Le système d'exploitation réserve une portion de taille fixe réservée au système d'exploitation, alors que le reste de la mémoire est utilisé pour le programme à exécuter. Le programme est placé à un endroit en RAM qui est toujours le même. Le système d'exploitation peut être soit placé dans une ROM, soit copié en mémoire RAM depuis une mémoire de masse, comme le programme à lancer. Si le système d'exploitation est copié en mémoire RAM, il est généralement placé dans les premières adresses, les adresses basses. A l'inverse, un OS en mémoire ROM est généralement placé à la fin de la mémoire, dans les adresses hautes. Mais tout cela n'est qu'une convention, et les exceptions sont monnaie courante. [[File:Méthodes d'allocation de la mémoire avec un espace d'adressage unique.png|centre|vignette|upright=2.0|Méthodes d'allocation de la mémoire avec un espace d'adressage unique]] Sur de tels systèmes, il faut protéger l'OS contre une erreur ou malveillance d'un programme utilisateur. Notons qu'il s'agit d'une protection en écriture, pas en lecture. Le programme peut parfaitement lire les données de l'OS sans problèmes, au moins en partie. Dans ce qui suit, on part du principe que l'OS est dans les adresses basses. La solution la plus courante interdit au programme d'écrire au-delà d'une limite au-delà de laquelle se trouve le système d’exploitation. Pour cela, le processeur incorpore un '''registre limite''', qui contient l'adresse limite au-delà de laquelle un programme peut pas écrire. Pour toute écriture en espace utilisateur, l'adresse écrite est comparée au registre limite. Si l'adresse est inférieure au registre limite, le programme cherche écrire dans la mémoire réservée à l'OS. L’accès mémoire est interdit, une exception matérielle est levée et l'OS affiche un message d'erreur. [[File:Circuit total.png|centre|vignette|upright=2.0|Protection mémoire avec un registre limite.]] ===Un seul espace d'adressage, partagé entre plusieurs programmes=== Les systèmes d’exploitation modernes implémentent la '''multiprogrammation''', le fait de pouvoir lancer 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. Et il faut alors résoudre un problème : le partage de la RAM entre les différents programmes. Les différents programmes et leurs données sont placés en mémoire RAM, et il faut trouver un moyen pour répartir les différents programmes en RAM. [[File:Partitions mémoire.png|vignette|upright=1|Partitions mémoire]] Il y a plusieurs solutions pour cela, mais l'une d'entre elle est l'usage de segments mémoire. Avec elle, chaque programme se voit attribuer un ou plusieurs blocs de mémoire. Ils sont appelés des '''partitions mémoire''', ou encore des '''segments'''. Dans ce qui va suivre, nous partons du principe qu'un programme est égal à un segment, pour simplifier les explications. Mais sachez qu'un programme peut être éclaté en plusieurs segments dispersés dans la mémoire, et même être conçu pour ! Nous en reparlerons dans le chapitre sur le mémoire virtuelle. Le système d'exploitation mémorise une liste des segments importants en mémoire RAM, appelée la ''table des segments''. Il s'agit d'un tableau dont chaque entrée mémorise les informations pour un segment. Une entrée de ce tableau est appelée un '''descripteur de segment''' et contient son adresse de base, sa taille, et des autorisations liées à la protection mémoire. Précisément, l'entrée numéro N mémorise les informations du segment numéro N. A partir de l'adresse de base de la table des segments et du numéro de segment, on peut lire ou écrire un descripteur de segment. [[File:Global Descriptor table.png|centre|vignette|upright=2|Global Descriptor table]] Toutefois, l'usage de segments amène un paquet de problèmes qu'il faut résoudre au mieux. Le premier problème est tout simplement de placer les segments dans l'espace d'adressage, mais c'est quelque chose qui est du ressort du système d'exploitation. Par contre, cela implique qu'un segment peut être placé n'importe où en RAM et sa position en RAM change à chaque exécution. En conséquence, les adresses des branchements et des données ne sont jamais les mêmes d'une exécution à l'autre. Pour résoudre ce problème, le compilateur considère que le segment commence à l'adresse zéro, puis les adresses sont corrigées avant ou pendant l'exécution du programme. Cette correction est en général réalisée par l'OS lors de la copie du programme en mémoire, mais il existe aussi des techniques matérielles, que nous verrons dans le chapitre suivant. Il faut aussi prendre en compte le phénomène de l''''allocation mémoire'''. Derrière ce nom barbare, se cache quelque chose de simple : les programmes peuvent réclamer de la mémoire au système d'exploitation, pour y placer des données. Les langages de programmation bas niveau supportent des fonctions comme malloc(), qui permettent de demander un bloc de mémoire de N octets à l'OS, qui doit alors accommoder la demande. S'il n'y a pas assez de mémoire, l'appel échoue et le programme doit gérer la situation. De même, un programme peut libérer de la mémoire qu'il n'utilise plus avec des fonctions comme free(). Avec des segments, cela revient à changer la taille d'un segment, plus précisément à la fin du segment. Et tout cela est géré par le système d'exploitation, aussi on laisse cela pour le prochain chapitre. Enfin, sans protection particulière, les programmes peuvent techniquement lire ou écrire les données des autres. Pour éviter cela, il suffit de vérifier qu'un programme accède bien à son segment et pas ailleurs. Il suffit de vérifier si l'adresse lue/écrite est dans le bon intervalle. Il suffit alors d'utiliser deux registres limites, rien de plus. Mais au-delà de cette simple vérification, si un programme pouvait modifier les données d'un autre programme, on se retrouverait rapidement avec une situation non prévue par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses. Il faut donc introduire des mécanismes de protection mémoire, pour isoler les programmes les uns des autres, et éviter toute modification problématique. La protection mémoire regroupe plusieurs techniques assez variées, qui ont des objectifs différents. Elles ont pour point commun de faire intervenir à des niveaux divers le système d'exploitation et le processeur. ==La ''memory map'' des PC IBM x86== Dans ce qui suit, nous allons étudier l'espace d'adressage des processeurs x86. L'organisation de leur espace d'adressage a évolué dans le temps et l'espace d'adressage s'est adapté. Mais il s'est adapté sans modifier l'existant, pour des raisons de compatibilité. Aussi, les processeurs x86 modernes peuvent fonctionner dans plusieurs modes, appelés mode réel, protégé, virtuel 8086, long, ''system management mode'', et compatibilité 32 bits. [[File:AMD64StateDiagram.svg|centre|vignette|upright=2|AMD64StateDiagram]] Le ''system management mode'' a déjà été abordé dans le chapitre sur les interruptions. Pour simplifier, il ne s'agit pas d'un véritable mode au sens "adressage". Dans ce mode, le système d'exploitation est mis en pause et le BIOS ou un autre firmware s'exécute sans restriction. On y rentre grâce à une interruption dédiée, lancée par le noyau de l'OS. Le système d'exploitation exécute cette interruption afin de laisser la main au BIOS, pour lui déléguer certaines tâches. Par contre, les autres modes sont bien plus intéressant, car ils ont chacun un espace d'adressage différent. Voyons-les dans le détail. ===Le mode réel : l'adressage sur 20 bits=== Le tout premier mode était le '''mode réel''', qui utilisait des adresses de 20 bits, ce qui fait 1 mébioctet de mémoire adressable. Il était utilisé sur des processeurs 16 bits, qui géraient donc des adresses de 16 bits capables d'adresser 64 kibioctets de mémoire. Pour contourner cette limitation, le processeur utilisait la segmentation, que nous verrons dans le prochain chapitre. Pour le moment, nous allons dire que la mémoire en mode réel était composée de 16 blocs de 64 kibioctets. C'est une simplification qui ne fera pas de mal. Le processeur démarre systématiquement en mode réel, y compris sur les ordinateurs modernes. Le BIOS s'exécute dans ce mode, avant de passer en mode protégé et de laisser la main à l'UEFI et au système d'exploitation. Autrefois, le système d'exploitation s'exécutait aussi en mode réel, c'est le cas du DOS. En mode réel, l'espace d'adressage est peuplé par de la RAM, la ROM du BIOS et des périphériques. La RAM prenait les adresses basses, la ROM était au sommet de l'espace d'adressage, et les périphériques entre les deux. Les premiers 640 kibioctets sont réservés à la RAM et sont appelés la '''mémoire conventionnelle'''. Les octets restants forment la '''mémoire haute''', réservée aux ROM et aux périphériques. Les deux premiers kibioctets de la mémoire conventionnelle sont initialisés au démarrage de l'ordinateur. Ils sont utilisés pour stocker le vecteur d'interruption (on expliquera cela dans quelques chapitres) et servent aussi au BIOS. La portion réservée au BIOS, la ''BIOS Data Area'', mémorise des informations en RAM. Elle commence à l'adresse 0040:0000h, a une taille de 255 octets, et est initialisée lors du démarrage de l'ordinateur. Le reste de la mémoire conventionnelle est réservée au le système d'exploitation (MS-DOS, avant sa version 5.0) et au programme en cours d’exécution. La mémoire haute est sectionnée en deux portions. Le sommet de la mémoire haute est réservé au BIOS, alors que le bas de la mémoire haute est réservé aux périphériques. Dans cette dernière, on trouve les BIOS des périphériques (dont celui de la carte vidéo, s'il existe), qui sont nécessaires pour les initialiser et pour communiquer avec eux. De plus, on y trouve la mémoire de la carte vidéo et éventuellement la mémoire d'autres périphériques comme la carte son. {| class="wikitable" style="text-align:center;" |+ Espace d'adressage en mode réel |- ! Numéro du bloc !! Contenu du bloc |- | 1024 - 960 Kio || class="f_jaune" | BIOS principal (ROM) |- | 960 Kio - 640 Kio | class="f_vert" | ROM d'extension des périphériques (XT, EGA, 3270 PC) </br> Mémoire vidéo des cartes EGA, MDA ou CGA |- | 640 Kio - 0 || class="f_rouge" | Mémoire RAM, mémoire conventionnelle |} Pour détailler un peu plus, il faut tenir compte du découpage de l'espace d'adressage en blocs de 64 kibioctets. Le découpage en blocs de 64 kibioctets est particulièrement important pour la mémoire haute. Des blocs sont attribués à un périphérique spécifique. Par exemple, le 16ème bloc est réservé au BIOS, le 12ème aux ROM des périphériques, le 10ème et le 11ème à la mémoire vidéo, etc. {| class="wikitable" style="text-align:center;" |+ Espace d'adressage en mode réel |- ! Numéro du bloc !! Contenu du bloc |- | 0 || rowspan="10" class="f_rouge" | Mémoire RAM, mémoire conventionnelle |- | 1 |- | 2 |- | 3 |- | 4 |- | 5 |- | 6 |- | 7 |- | 8 |- | 9 |- | 10 || class="f_vert" | Mémoire vidéo des cartes EGA |- | 11 || class="f_vert" | Mémoire vidéo des cartes MDA ou CGA |- | 12 || class="f_vert" | ROM d'extension des périphériques (XT, EGA, 3270 PC) |- | 13 || class="f_gris" | Autre, non-réservé |- | 14 || class="f_gris" | Autre, non-réservé |- | 15 || class="f_jaune" | ROM du BIOS |} Vous remarquerez que les blocs 13 et 14 sont non-réservés. Ils peuvent être utilisés pour mapper des périphériques en mémoire. Mais si aucun périphérique n'est mappé dedans, les blocs sont laissés libres. Les OS n'hésitaient pas à les utiliser pour adresser de la RAM. Par exemple, si l'ordinateur n'a pas de carte vidéo EGA, le bloc qui leur était réservé était réutilisé comme mémoire RAM. La mémoire conventionnelle passait alors de 640 à 704 kibioctets de RAM disponibles. L'espace d'adressage correspond à la ROM du BIOS. Mais avec le temps, la ROM est devenue plus lente au point qu'exécuter du code directement depuis la ROM du BIOS était un problème niveau performance. Je rappelle que le BIOS contient de nombreuses routines d'interruptions qui étaient appelées par les systèmes d'exploitation de l'époque, notamment le DOS. Heureusement, diverses optimisations permettaient de copier le BIOS dans la mémoire RAM, plus rapide que la ROM du BIOS. Il s'agit des techniques de '''''BIOS Shadowing'''''. Elles s'appliquaient au BIOS proprement dit, mais le BIOS vidéo pouvait aussi être optimisé ainsi, mais les ROM d'extension ne le sont généralement pas. Le ''BIOS Shadowing'' peut s'implémenter de plusieurs manières, mais la plus courante mappait temporairement de la RAM dans le même espace d'adressage que la ROM du BIOS. Le code du BIOS copiait le BIOS avec une boucle, dont chaque itération lisait une adresse du BIOS et écrivait dans cette même adresse. Juste avant la boucle, le ''chipset'' était configuré par le BIOS pour que les lectures atterrissent dans la ROM du BIOS, mais que les écritures tombaient dans une mémoire RAM mappée. Une fois la copie/boucle terminée, l'adressage tombait dans la RAM aussi bien pour les lectures et écritures. Elle demandait un support du ''chipset'' et du logiciel, pour détourner les adresses temporairement. ===Interlude : les technologies d'expanded memory=== Déjà à l'époque, 1 mébioctet était une limite assez faible, même si cela n'a pas empêché à un grand dirigeant de l'industrie informatique de dire que "640K ought to be enough for anybody". Aussi, diverses solutions matérielles ont vu le jour. Elles étaient peu utilisées et ont concrètement servi à des applications spécifiques. La principale est l''''''expanded memory''''' (EMS), une solution utilisant une carte d'extension avec plusieurs modules de RAM installés dessus. Les modules étaient accédés via la technique de la commutation de banque. [[File:Expanded memory.svg|centre|vignette|upright=2|L'espace d'adressage avec ''Expanded memory''. La carte d'extension et le matériel requis ne sont pas représentés.]] Le terme ''expanded memory'' regroupe plusieurs technologies propriétaires semblables sur le principe mais différentes dans l'exécution. Les premières utilisaient des banques de 64 kibioctets, qui étaient mappées dans un bloc de 64 kibioctets libre en mémoire haute. L'usage de banques de 64 kibioctets collait bien avec l'adressage particulier du mode réel, où la mémoire était gérée par blocs de 64 kibioctets. Les technologies ultérieures utilisaient 2 à 4 pages de 16 kibioctets, qui pouvaient être placée n'importe où en mémoire conventionnelle. [[File:EmulexPersyst 4M ISA.jpeg|centre|vignette|upright=2|Carte d'extension ''Expanded memory'' de marque Emulex Persyst, de 4 mébioctets.]] L'''expanded memory'' demandait que les programmes soient codés pour utiliser cette fonctionnalité. Pour cela, ils devaient utiliser une technique de programmation nommée ''Overlay programming''. L'idée est de découper le programme en plusieurs blocs séparés, appelés des '''''overlays'''''. Certains blocs sont en permanence en RAM, mais d'autres sont soit chargés en RAM, soit stockés ailleurs. Le ailleurs est souvent le disque dur, ou dans la RAM ''expanded memory'' si elle est disponible. Le va-et-vient des ''overlays'' entre RAM et disque dur était réalisé en logiciel, par le programme lui-même. [[File:Overlay Programming.svg|centre|vignette|upright=1.5|''Overlay Programming''.]] L'utilisation de l'''expanded memory'' utilisait l'interruption logicielle 67h, une interruption peu utilisée à l'époque, qui était libre. La carte d'extension était utilisée à travers un pilote de périphérique dédié, qui était appelé via l'interruption 67h. ===Le mode protégé : l'adressage sur 24 et 32 bits=== Par la suite, le mode réel a été remplacé par le '''mode protégé'''. Il utilise la mémoire virtuelle, ce qui fait qu'on devrait le détailler dans le chapitre suivant. Il permettait aussi de dépasser la limite du mébioctet de mémoire. L'espace d'adressage en mode protégé est passé de 24 bits sur le CPU 286, à 32 bits sur le 386 et les CPU suivants. Les programmes conçus pour le mode réel ne pouvaient pas s'exécuter en mode protégé. Pour corriger cela, le 286 a introduit le '''mode virtuel 8086''', aussi appelé mode V86. Il s'agit d'une technique de virtualisation, ce qui fait qu'on en parlera en détail dans le chapitre sur la virtualisation assistée en matériel. Tout ce que l'on peut dire ici est que le mode V86 a été détourné pour que les applications DOS utilisent la mémoire au-delà du mébioctet, appelée la '''mémoire étendue'''. Les logiciels DOS accédaient à la mémoire étendue à travers un driver DOS dédié (HIMEM.SYS). Les logiciels DOS déplaçaient des données dans la mémoire étendue, puis les rapatriaient en mémoire conventionnelle quand ils en avaient besoin. Il ne faut pas confondre mémoire étendue et ''expanded memory''. La technique de ''expanded memory'' repose sur de la commutation de banque, demande une carte d'extension dédiée, fonctionne sans mode V86. A l'inverse, la mémoire étendue ne gère pas de commutation de banque, ne demande pas de carte d'extension, mais utilise le mode V86. Par contre, il est possible d'émuler l'''expanded memory'' sans carte d'extension, avec la mémoire étendue. Quelques cartes mères intégraient des techniques pour, mais une émulation logicielle était aussi possible en réécrivant l'interruption 67h. ===Le mode long : l'adressage 64 bits=== Par la suite, les processeurs ont introduit le '''mode long''', dans lequel les adresses font 64 bits. Le CPU en mode long a aussi un jeu d'instruction totalement différent, des registres en plus et d'autres fonctionnalités actives uniquement dans ce mode. Les programmes compilés pour le 64 bits s'exécutent nativement dans ce mode, alors que les programmes 32 et 16 bits s'exécutent dans un mode de compatibilité dédié. Sur les systèmes 64 bits, le bus d'adresse fait seulement 48 bits afin d'économiser des interconnexions. Avec 48 bits, il manque 64 - 48 = 16 bits d'adresses, qui ne peuvent pas être utilisés pour adresser quoi que ce soit. La règle est que ces 16 bits doivent être tous égaux : soit ils valent tous 0, soient ils sont tous à 1. Les adresses qui respectent cette contrainte sont appelées des '''adresses canoniques'''. Le résultat est que l'espace d'adressage est coupé en trois sections, comme illustré ci-dessous. * Les '''adresses canoniques basses''' ont leurs 16 bits de poids fort à 0 et sont situées en bas de l'espace d'adressage, dans les premières adresses. * Les '''adresses canoniques hautes''' ont leurs 16 bits de poids fort à 1 et sont situées au sommet de l'espace d'adressage, dans les dernières adresses. * Entre les deux, se trouvent des '''adresses non-canoniques''', qui ne sont pas accessibles. Y accéder déclenche la levée d'une exception matérielle. {| |[[File:AMD64-canonical--48-bit.png|vignette|Espace d'adressage x86-64 bits, avec des adresses physiques de 48 bits.]] |[[File:AMD64-canonical--56-bit.png|vignette|Espace d'adressage x86-64 bits, avec des adresses physiques de 57 bits.]] |} : Les futurs systèmes x86 devraient passer à des adresses de 57 bits, les trois sections auront donc des frontières différentes. Les adresses non-canoniques sont censées être inutilisables. Mais les programmeurs aimeraient bien pouvoir les utiliser pour des ''pointeurs tagués'', à savoir des pointeurs/adresses associées à des informations. L'idée serait d'utiliser les 16 bits de poids fort pour stocker des informations liées au pointeur, seuls les 48 bits restant codant l'adresse. Les 16 bits peuvent être utilisés de manières très diverses. Un exemple est la technique du '''''memory tagging''''', qui crée une somme de contrôle au pointeur. La somme de contrôle est générée par un algorithme cryptographique, à partir de l'adresse du pointeur. Elle est vérifiée à chaque utilisation du pointeur. Si la somme de contrôle ne correspond pas au pointeur, une erreur est levée. Si jamais un virus ou un code malveillant modifie un pointeur, il ne saura pas comment calculer la somme de contrôle, la somme de contrôle a énormément de chances d'être incorrecte. Quelques fonctionnalités des processeurs visent à autoriser l'utilisation des adresses non-canoniques. L'idée est que les 16 bits de poids fort sont ignorées lors des accès mémoire, ce qui permet d'utiliser les 16 bits de poids fort à volonté. Il s'agit des techniques ''Top Byte Ignore'' d'ARM, ''Upper Address Ignore'' d'AMD, et ''Linear Address Masking'' d'Intel. ==La z/architecture d'IBM== IBM a sa propre gamme de ''mainframes'', qui utilisent des processeurs fait maison par IBM lui-même. Les CPU IBM ont leur propre jeu d'instruction CISC, appelé '''''z/architecture'''''. Et ce jeu d'instruction a des possibilités en termes d'adressage qui sont extrêmement intéressantes, en plus d'être assez inédites. Pour ce chapitre, nous allons nous concentrer sur une fonctionnalité : les programmes ont accès à plusieurs espaces d'adressage naturellement. L'avantage pour les programmes utilisateur n'est pas très évident, mais il l'est nettement plus pour les systèmes d'exploitation. Le système d'exploitation de ces ''mainframes'', le z/OS, profite de cette possibilité, idem pour l'hyperviseur z/VM. Il utilise cette possibilité pour donner un espace d'adressage séparé pour chaque programme en cours d'exécution. Les programmes sont alors isolés les uns des autres. Cette '''isolation des processus''' n'est pas spécifique à IBM : tous les systèmes d'exploitation modernes l'utilisent. Mais les processeurs x86 doivent se débrouiller seulement avec des techniques dites de mémoire virtuelle, alors que les ''mainframes'' IBM complètent la mémoire virtuelle avec des facilités en plus. Pour les programmes utilisateurs, un intérêt est que cela permet de partager des données entre plusieurs programmes. Ils ont un espace d'adressage rien qu'à eux, mais les données partagées sont placées dans un espace d'adressage commun, auquel les deux programmes ont accès. L'espace d'adressage partagé peu même être configuré de manière à ne contenir que des données, il est impossible d'exécuter du code depuis cet espace d'adressage. Ce qui empêche certains ''malwares'' de fonctionner. La ''z/architecture'' supporte 65536 espaces d'adressage simultanés. Les espaces d'adressage sont numérotés, ils ont chacun un '''''Address-Space Number''''' codé sur 16 bits. Nous utiliserons l’abréviation ASN dans ce qui suit. Par contre, un programme ne peut pas utiliser tout ces espaces d'adressages, il ne peut en utiliser que 1024 en même temps. De plus, ils ne sont accessibles que quand le processeur est dans un mode de fonctionnement bien précis. Un ^processeur ''z/architecture'' peut en effet fonctionner dans deux modes, qu'on va appeler "standard" et "étendu". ===Le mode standard de la ''z/architecture''=== En mode standard, un programme exécuté sur la ''z/architecture'' utilise deux espaces d'adressages : l''''espace d'adressage primaire''' où se situent le programme exécuté et les données importantes, un '''espace d'adressage secondaire''' utilisé pour des tâches/données annexes. : Un troisième espace d'adressage l''''espace d'adressage ''home''''', est réservé au système d'exploitation. Il a pour particularité de ne pas avoir d'ASN. Il n'est concrètement accesible qu'en mode superviseur, donc par le système d'exploitation. Nous n'en parlerons pas dans ce qui suit. L'ASN de ces deux espaces d'adressage est mémorisé dans les registres de contrôle du processeur. L'ASN de l'espace primaire est dans le registre de contrôle n°3, l'ASN de l'espace secondaire est dans le registre de contrôle n°4 (ils sont dans les 16 bits de poids faible, dans les deux cas). Les deux registres de contrôle sont initialisés au démarrage de l'application, par le système d'exploitation, ce qui fait que ce sont des espaces d'adressage par défaut. Le programme a donc accès à ces deux espaces d'adressage sans rien faire. Mais comment le processeur pour utiliser ces espaces d'adressages ? Par exemple, s'il veut faire une lecture, comment précise-t-il l'espace d'adressage courant ? La réponse est que le processeur peut être dans deux états : soit primaire, soit secondaire. Dans le premier état, toutes les lectures/écritures se font dans l'espace d'adressage primaire. Dans le second, elles se font toutes dans l'espace d'adressage secondaire. Il peut passer de l'un à l'autre en utilisant l'instruction ''Set Address Space Control''. Le processeur dispose aussi d'instructions ''MOVE TO PRIMARY'' et ''MOVE TO SECONDARY'' pour échanger des données dans les deux espaces d'adressage. ===Les registres d'accès de la ''z/architecture''=== Un programme peut donc passer de l'espace d'adressage primaire à secondaire (ou inversement) en utilisant l’instruction ''SET ADDRESS SPACE CONTROL''. Mais il peut en profiter pour passer dans un mode où il a accès à 1024 espaces d'adressage différents, en même temps. Le mode en question est appelé le '''mode ''access register'''''. Le mode ''access register'' donne accès aux '''registres d'accès''', qui lui donnent son nom. Les registres d'accès permettent de sélectionner un espace d'adressage, afin qu'une instruction puisse lire/écrire dedans. Il y a en tout 16 registres d'accès, chacun étant appairé avec un registre général. Par exemple, le registre général numéro 5 est appairé au registre d'accès de même numéro. En mode ''access register'', toute lecture/écriture lit à la fois un registre général et le registre d'accès associé. Le registre général contient un pointeur, l'adresse à lire/écrire, et le registre d'accès indique dans quel espace d'adressage se trouve cette adresse. Une implémentation simple numéroterait les espaces d'adressage et mettrait les numéros dans les registres d'accès. Les registres d'accès seraient idéalement protégés en écriture et seulement accessibles par le système d'exploitation. Mais ce n'est pas ce qui est fait. Les registres d'accès font 32 bits chacun, ce qui ne colle pas avec le fait que les ASNs sont codés sur 16 bits. La raison est que les registres d'accès ne mémorisent pas des ASNs, mais quelque chose qui permet de les récupérer indirectement. Un programme peut accéder à certains espaces d'adressage bien précis, que lui a attribué le système d'exploitation. Le système d'exploitation mémorise, pour chaque programme, les espaces d'adressages auxquels il a accès. Les ASN associés sont mémorisés dans une '''liste d'accès privée'''. La liste d'accès privée est un tableau qui regroupe les ASNs des espaces d'adressage accessibles. Les ASNs ont tous un indice qui permet de les sélectionner dans ce tableau. L'indice en question est appelé un '''''Access List Entry Token''''' ou ALET. Et les registres d'accès mémorisent des ALETs. Il y a donc un niveau d'indirection. Les programmes ne manipulent pas des ASNs, mais des ALETs, qui sont traduits à la volée en ASN, avant d'être traduits en adresses mémoires. Lors d'une lecture/écriture, l'ALETs sont est envoyé à une table de correspondance matérielle, qui mémorise les correspondances entre un identifiant et un espace d'adressage. Il y a une table de correspondance par programme, ce qui fait qu'un identifiant ne correspondra pas au même espace d'adressage dans deux programmes différents. La table de correspondance mémorise aussi des droits d'accès, qui sont différents pour chaque programme. Par exemple, tel programme aura accès en lecture seule à tel espace d'adressage, alors qu'un autre programme y aura accès en lecture et écriture, etc. Un espace d'adressage peut ainsi être partagé entre deux programmes plus facilement, tout en ajoutant des garanties de sécurité. L'usage des registres d'accès est aussi plus flexible qu'on ne pourrait le croire. Le programme peut lire et écrire dans les registres d'accès, copier un registre d'accès dans un autre, etc. Par contre, la table de correspondance empêche à un programme d’accéder à un espace d'adressage arbitraire. Le système d'exploitation remplit la table de manière à n'autoriser l'accès qu'aux espaces d'adressage adéquats, il y enregistre seulement les correspondances utiles. Si un programme forge un identifiant arbitraire, et l'envoie à la table de correspondance, celle-ci n'aura pas la correspondance associée et l'accès mémoire sera invalidé. La table de correspondance est en réalité une table à deux niveaux. Une première table reçoit l'ASN et fournit l'adresse de la seconde table. La seconde table récupère alors l'adresse. Vu que le processeur utilise la mémoire virtuelle, c'est une adresse dite virtuelle, mais laissons cela pour le prochain chapitre. La première table est appelée l'''''access list''''', alors que la seconde est appelée l''''''ASN-Second-Table-Entry Sequence Number'''''. Les deux tables sont accessibles en écriture via une instruction ''LOAD ADDRESS SPACE PARAMETERS'', seulement accesible en mode noyau. : Il faut noter que si l'''access list'' a une taille limitée à 64 kibioctets, la seconde n'est pas limitée, ce qui permet de dépasser la limite des 655536 ASNs. La limite de 65535 ASNs est uen limite par programme, pas totale. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs 8 bits et moins | prevText=Les processeurs 8 bits et moins | next=L'abstraction mémoire et la mémoire virtuelle | nextText=L'abstraction mémoire et la mémoire virtuelle }} </noinclude> 5l5mn5t80rpjq298k80n8j8h5b7tiik 774378 774377 2026-10-07T17:30:04Z Mewtow 31375 /* Les registres d'accès de la z/architecture */ 774378 wikitext text/x-wiki L''''espace d'adressage''' du processeur est l'ensemble des adresses utilisables par le processeur. Par exemple, un processeur 16 bits peut adresser 2^16 = 65536 adresses, l'ensemble de ces adresses forme son espace d'adressage. L'espace d'adressage n'est pas toujours égal à la mémoire réellement installée. S'il n'y a pas assez de RAM installée, des adresses seront inoccupées. De plus, une partie de l'espace d'adressage peut être détourné pour communiquer avec les périphériques, comme nous le verrons plus bas. Nous verrons aussi qu'un processeur peut avoir plusieurs espaces d'adressages séparés. ==L'adressage de la RAM et de la ROM== Avoir plusieurs espaces d'adressage spécialisés est quelque chose que nous avons déjà rencontré dans les chapitres précédents, mais sans le dire ouvertement. Aussi, nous allons faire quelques rappels sur les cas déjà rencontrés. En premier lieu, nous allons rappeler la différence entre architectures Von Neumann et Hardvard. La différence entre les deux tient dans l'adressage des mémoires RAM et ROM : est-ce qu'elles sont dans un seul espace d'adressage, ou dans des espaces d'adressage séparés. L''''architecture Von Neumann''' a un seul espace d'adressage, découpé entre la mémoire RAM d'un côté et la mémoire ROM de l'autre. Une adresse correspond soit à la mémoire RAM, soit à la mémoire ROM, mais pas aux deux. Typiquement, la mémoire ROM est placée dans les adresses hautes, les plus élevées, alors que la RAM est placée dans les adresses basses en commençant par l'adresse 0. C'est une convention qui n'est pas toujours respectée, aussi mieux vaut éviter de la tenir pour acquise. [[File:Vision de la mémoire par un processeur sur une architecture Von Neumann.png|centre|vignette|upright=2|Vision de la mémoire par un processeur sur une architecture Von Neumann.]] L''''architecture Harvard''' utilise des espaces d'adressage séparés pour la RAM et la ROM. Une même adresse peut correspondre soit à la ROM, soit à la RAM. Le processeur voit bien deux mémoires séparées, chacune dans son propre espace d'adressage. Les deux espaces d'adressage n'ont pas forcément la même taille. Il est possible d'avoir un plus gros espace d'adressage pour la RAM que pour la ROM. Cela implique que les adresses des instructions et des données soient de taille différentes. C'est peu pratique et c'est rarement implémenté, ce qui fait que le cas le plus courant est celui où les deux espaces d'adressages ont la même taille. [[File:Vision de la mémoire par un processeur sur une architecture Harvard.png|centre|vignette|upright=2|Vision de la mémoire par un processeur sur une architecture Harvard.]] Sur une '''architecture Harvard modifiée''', le programme est placé dans un espace d'adressage à part du reste. Le problème, c'est que cet espace d'adressage ne contient pas que le code machine à exécuter. Il contient aussi des constantes, à savoir des données qui gardent la même valeur lors de l'exécution du programme. Elles peuvent être lues, mais pas modifiées durant l'exécution du programme. L'accès à ces constantes demande d'aller lire celles-ci dans l'autre espace d'adressage, pour les copier dans les registres généraux. Le processeur doit pour cela avoir des instructions de lecture dédiées. En clair, il y a une instruction LOAD pour lire dans la mémoire ROM, une autre pour lire dans la RAM. [[File:Espaces d'adressage sur une archi harvard modifiée.png|centre|vignette|upright=3|Espaces d'adressage sur une archi harvard modifiée]] Une autre solution utilise des instructions de copie, qui copient les constantes en mémoire RAM, donc d'un espace d'adressage à l'autre. [[File:Organisation des espaces d'adressage sur une archi harvard modifiée.png|centre|vignette|upright=3|Organisation des espaces d'adressage sur une archi harvard modifiée]] ==L'adressage des périphériques== Passons maintenant à l'adressage des périphériques. La communication avec les périphériques se fait par l'intermédiaire de registres d’interfaçage. Et ces registres peuvent soit avoir un espace d'adressage séparé, soit être inclus dans l'espace d'adressage des mémoires. Dans ce qui suit, nous allons supposer que l'architecture des de type Von Neumann pour simplifier les explications. ===Les entrées-sorties mappées en mémoire=== Une partie de l'espace d'adressage peut être détourné pour communiquer avec les périphériques. Le périphérique se retrouve inclus dans l'ensemble des adresses utilisées pour manipuler la mémoire : on dit qu'il est mappé en mémoire. Les adresses mémoires associées à un périphérique sont redirigées automatiquement vers les registres du périphérique en question, voire vers sa mémoire intégrée. On parle alors d''''entrées-sorties mappées en mémoire'''. [[File:IO mappées en mémoire.png|centre|vignette|upright=2.0|IO mappées en mémoire]] On remarque un défaut inhérent à cette technique : les adresses utilisées pour les périphériques ne sont plus disponibles pour la RAM. On ne peut plus adresser autant de RAM qu'avant. La perte dépend des périphériques installés et de leur gourmandise en adresses mémoires. C'est ce qui causait un problème assez connu sur les ordinateurs 32 bits, capables d'adresser 4 gibioctets de mémoire. Certaines personnes installaient 4 gigaoctets de mémoire sur leur ordinateur 32 bits et se retrouvaient avec « seulement » 3,5 à 3,8 gigaoctets de mémoire, les périphériques prenant le reste. Il est possible que la RAM d'un périphérique soit mappée en RAM. Un exemple classique est celui des cartes graphiques qui incorporent une mémoire RAM appelée la mémoire vidéo. La mémoire vidéo est mappée en mémoire, ce qui permet au processeur d'écrire directement dedans. Toute lecture ou écriture dans les adresses associées est redirigée vers le bus PCI/AGP/PCI-Express. Nous verrons plus bas des exemples d'ordinateurs où la mémoire vidéo est mappée en mémoire, que ce soit totalement ou partiellement. Si je dis totalement ou partiellement, c'est parce que les cartes graphiques modernes disposent de tellement de mémoire qu'on ne peut pas la mapper totalement dans l'espace d'adressage. Sur les systèmes d'avant 2008, seuls 256 mégaoctets de mémoire vidéo sont mappés en mémoire RAM. Le reste de la mémoire vidéo est invisible du point de vue du processeur, mais manipulable par le GPU à sa guise. Après 2008, la spécification du PCI-Express ajouta un support de la technologie ''Resizable Bar'', qui permet au processeur d’accéder directement à plus de 256 mégaoctets de mémoire vidéo, voire à la totalité de la mémoire vidéo. ===L'espace d'adressage séparé pour les entrées-sorties=== Les entrées-sorties et périphériques peuvent avoir leur propre espace d'adressage dédié, séparé de celui utilisé pour les mémoires RAM et ROM. [[File:Espaces_d'adressages_séparés_entre_mémoire_et_périphérique.png|centre|vignette|upright=3|Bit IO.]] Une même adresse peut donc adresser soit une entrée-sortie, soit une case mémoire. Et pour faire la différence, le processeur doit avoir des instructions séparées pour adresser les périphériques et la mémoire. Il a des instructions de lecture/écriture pour lire/écrire en mémoire, et d'autres pour lire/écrire les registres d’interfaçage. Sans cela, le processeur ne saurait pas si une adresse est destinée à un périphérique ou à la mémoire. Cela élimine aussi les problèmes avec les caches : les accès à l'espace d'adressage de la RAM passent par l'intermédiaire de la mémoire cache, alors les accès dans l'espace d'adressage des périphériques le contournent totalement. Là encore, les deux espaces d'adressage n'ont pas forcément la même taille. Il arrive que les deux espaces d'adressage aient la même taille, le plus souvent sur des ordinateurs complexes avec beaucoup de périphériques. Mais les systèmes embarqués ont souvent des espaces d'adressage plus petits pour les périphériques que pour la ou les mémoires. L'implémentation varie grandement suivant le cas, la première méthode imposant d'avoir deux bus séparés pour les mémoires et les périphériques, l'autre permettant un certain partage du bus d'adresse. Nous reviendrons dessus plus en détail dans le chapitre sur l'adressage des périphériques. ==La ''memory map'' d'un ordinateur== Les deux sections précédentes nous ont appris que l'on peut utiliser un espace d'adressage séparé pour la ROM et un autre pour les périphériques. En tout, cela donne quatre possibilités distinctes. {|class="wikitable" |- ! ! IO mappées en mémoire ! IO séparées |- ! ROM mappée en mémoire (architecture Von Neumann) | Un seul espace d'adressage | Deux espaces d'adressage : * un pour les mémoires RAM/ROM ; * un pour les IO. |- ! ROM séparée (architecture Harvard) | Deux espaces d'adressage : * un pour la RAM et les IO ; * un pour la ROM. | Trois espaces d'adressages : * un pour la RAM ; * un pour la ROM ; * un pour les IO. |} Les quatre solutions ont des avantages et inconvénients divers, mais il est intéressant de contraster un espace d'adressage unique avec plusieurs espaces d'adressages. ===L'espace d'adressage unique=== Avec un espace d'adressage unique, la ROM est au sommet de l'espace d'adressage, les périphériques sont juste en-dessous, la RAM commence à l'adresse 0 et prend les adresses basses. Faire simplifie grandement l'implémentation matérielle de l'adressage. Notons que d'autres composants que les périphériques ou les mémoires peuvent se trouver dans l'espace d'adressage. On peut y trouver les horloges temps réels, des timers, des senseurs de température, ou d'autres composants placés sur la carte mère. Un exemple un peu original est le suivant : la console de jeu Nintendo DS incorporait une unité de calcul spécialisée dans les divisions et racines carrées, séparée du processeur, qui était justement mappée en mémoire ! [[File:Espace d'adressage classique avec entrées-sorties mappées en mémoire.png|centre|vignette|upright=2|Espace d'adressage classique avec entrées-sorties mappées en mémoire]] L'espace d'adressage unique est plus simple pour les programmeurs, malgré un désavantage dont il faut parler. Le problème est que des adresses censées être disponibles pour la RAM sont détournées vers la ROM ou les périphériques, ce qui limite la quantité de RAM qui peut être réellement adressée en pratique. C'est un défaut qui se manifeste seulement pour les petits espaces d'adressages, de 8 à 16 bits, qui ne permettent pas d'adresser beaucoup de RAM. Avec des adresses codées sur 16 bits, un espace d'adressage de 64 kibioctets de RAM est grignoté par des entrées-sorties assez rapidement. Par contre, pour des adresses de 32 à 64 bits, une bonne partie de l'espace d'adressage est inutilisé, car il n'y a pas assez de RAM. L'espace d'adressage unique n'a donc pas de désavantages. Les vielles consoles de jeux et certains vieux ordinateurs (Commodores et Amiga), utilisaient un espace d'adressage unique. Elles n'avaient pas une variété de cartes graphiques ou de cartes sons différentes, comme sur les PCs modernes. Au contraire, elles avaient la même configuration matérielle, le matériel ne pouvait pas être changé ni upgradé. Le système d'exploitation était rudimentaire et ne contrôlait pas vraiment l'accès au matériel. Les programmeurs avaient donc totalement accès au matériel et mapper les entrées/sorties en mémoire rendait la programmation des périphériques très simple. <gallery widths=200px heights=500px> N5200mk2 memory map.svg|Adressage mémoire (carte mémoire) du N5200mk2. PC-9801VM memory map.svg|Adressage mémoire (carte mémoire) du PC-9801VM. </gallery> ===La commutation de banques (''bank switching'')=== Le '''''bank switching''''', aussi appelé '''commutation de banque''', permet d'utiliser plusieurs espaces d'adressage sur un même processeur, sans attribuer chaque espace d'adressage pour une raison précise. L'espace d'adressage est présent en plusieurs exemplaires appelés des '''banques'''. Les banques sont numérotées, chaque numéro de banque permettant de l'identifier et de le sélectionner. Le but de cette technique est d'augmenter la mémoire disponible pour l'ordinateur. Par exemple, supposons que j'ai besoin d'adresser une mémoire ROM de 4 kibioctets, une RAM de 8 kibioctets, et divers périphériques. Le processeur a un bus d'adresse de 12 bits, ce qui limite l'espace d'adressage à 4 kibioctets. Dans ce cas, je peux réserver 4 banques : une pour la ROM, une pour les périphériques, et deux banques qui contiennent chacune la moitié de la RAM. La simplicité et l'efficacité de cette technique font qu'elle est beaucoup utilisée dans l'informatique embarquée. [[File:PC-8801MemoryMap.gif|centre|vignette|upright=2|exemple de Bank switching.]] Cette technique demande d'utiliser un bus d'adresse plus grand que les adresses du processeur. L'adresse réelle se calcule en concaténant le numéro de banque avec l'adresse accédée. Le numéro de la banque actuellement en cours d'utilisation est mémorisé dans un registre appelé le '''registre de banque'''. On peut changer de banque en changeant le contenu de ce registre. Le processeur dispose souvent d'instructions spécialisées qui en sont capables. {| class="wikitable flexible" |[[File:Banque mémoire.png|Banque mémoire.]] |[[File:Registre de banque.png|Registre de banque.]] |} ==Le placement des programmes dans l'espace d'adressage== Les ordinateurs modernes sont des architectures Von Neumann, avec un seul espace d'adressage, qui est partagé suivant les besoins. Et un point important est que le système d'exploitation et les programmes sont copiés en mémoire RAM avant d'être exécutés. Ils ne sont pas exécutés depuis une mémoire ROM, cela est réservé aux systèmes embarqués simples. Et cela a une influence sur l'espace d'adressage. Les programmes et le système d'exploitation doivent se partager l'espace d'adressage. Voyons voir comment ce partage se fait. Dans ce chapitre, nous allons volontairement mettre de côté les ordinateurs qui supportent la mémoire virtuelle. Nous en parlerons dans le prochain chapitre. Le principe est de voir comment un processeur avec un seul espace d'adressage gèrent la présence d'un ou de plusieurs programmes. ===Un seul espace d'adressage, non-partagé=== Le cas le plus simple est celui où il n'y a pas de système d'exploitation et où un seul programme s'exécute sur l'ordinateur. Le programme a alors accès à tout l'espace d'adressage, sauf pour ce qui est réservé aux périphériques et à la mémoire ROM. Le programme organise la mémoire en plusieurs sections, dans lesquelles le programme range des données différentes. Typiquement, on trouve quatre blocs de mémoire, appelés des segments, chacun étant spécialisé pour les données, le code, la pile, etc. Voici ces trois sections : * Le segment '''''text''''' 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. * Le segment pour la '''pile''', de taille variable. * le reste est appelé le '''tas''', de taille variable. [[File:Organisation d'un espace d'adressage unique utilisé par un programme unique.png|centre|vignette|upright=2|Organisation d'un espace d'adressage unique utilisé par un programme unique]] [[File:Typical computer data memory arrangement.png|vignette|upright=0.5|Typical computer data memory arrangement]] La pile et le tas sont de taille variable, ce qui veut dire qu'ils peuvent grandir ou diminuer à volonté, contrairement au reste. Entre le tas et la pile, on trouve un espace de mémoire inutilisée, qui peut être réquisitionné selon les besoins. La pile commence généralement à l'adresse la plus haute et grandit en descendant, alors que le tas grandit en remontant vers les adresses hautes. Il s'agit là d'une convention, rien de plus. Il est possible d'inverser la pile et le tas sans problème, c'est juste que cette organisation est rentrée dans les usages. Avant de poursuivre, précisons qu'il est possible de regrouper plusieurs programmes distincts dans un seul, afin qu'ils partagent le même morceau de mémoire. Les programmes portent alors le nom de '''threads'''. Les ''threads'' d'un même processus partagent le même espace d'adressage. Ils partagent généralement certains segments : ils se partagent le code, le tas et les données statiques. Par contre, chaque thread dispose de sa propre pile d'appel. [[File:Single vs multithreaded processes.jpg|centre|vignette|upright=2.0|Distinction entre processus mono et multi-thread.]] Il va de soi que cette vision de l'espace d'adressage ne tient pas compte des périphériques. C'est-à-dire que les schémas précédents partent du principe qu'on a un espace d'adressage séparé pour les périphériques. Dans le cas où les entrées-sorties sont mappées en mémoire, l'organisation est plus compliquée. Généralement, les adresses associées aux périphériques sont placées juste au-dessus de la pile, dans les adresses hautes. ===Un seul espace d'adressage, partagé avec le système d'exploitation=== [[File:Gestion de la mémoire sur les OS monoprogrammés.png|vignette|upright=1.0|Gestion de la mémoire sur les OS monoprogrammés.]] Maintenant, étudions le cas où le programme partage la mémoire avec le système d'exploitation. Sur les systèmes d'exploitation les plus simples, on ne peut lancer qu'un seul programme à la fois. Le système d'exploitation réserve une portion de taille fixe réservée au système d'exploitation, alors que le reste de la mémoire est utilisé pour le programme à exécuter. Le programme est placé à un endroit en RAM qui est toujours le même. Le système d'exploitation peut être soit placé dans une ROM, soit copié en mémoire RAM depuis une mémoire de masse, comme le programme à lancer. Si le système d'exploitation est copié en mémoire RAM, il est généralement placé dans les premières adresses, les adresses basses. A l'inverse, un OS en mémoire ROM est généralement placé à la fin de la mémoire, dans les adresses hautes. Mais tout cela n'est qu'une convention, et les exceptions sont monnaie courante. [[File:Méthodes d'allocation de la mémoire avec un espace d'adressage unique.png|centre|vignette|upright=2.0|Méthodes d'allocation de la mémoire avec un espace d'adressage unique]] Sur de tels systèmes, il faut protéger l'OS contre une erreur ou malveillance d'un programme utilisateur. Notons qu'il s'agit d'une protection en écriture, pas en lecture. Le programme peut parfaitement lire les données de l'OS sans problèmes, au moins en partie. Dans ce qui suit, on part du principe que l'OS est dans les adresses basses. La solution la plus courante interdit au programme d'écrire au-delà d'une limite au-delà de laquelle se trouve le système d’exploitation. Pour cela, le processeur incorpore un '''registre limite''', qui contient l'adresse limite au-delà de laquelle un programme peut pas écrire. Pour toute écriture en espace utilisateur, l'adresse écrite est comparée au registre limite. Si l'adresse est inférieure au registre limite, le programme cherche écrire dans la mémoire réservée à l'OS. L’accès mémoire est interdit, une exception matérielle est levée et l'OS affiche un message d'erreur. [[File:Circuit total.png|centre|vignette|upright=2.0|Protection mémoire avec un registre limite.]] ===Un seul espace d'adressage, partagé entre plusieurs programmes=== Les systèmes d’exploitation modernes implémentent la '''multiprogrammation''', le fait de pouvoir lancer 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. Et il faut alors résoudre un problème : le partage de la RAM entre les différents programmes. Les différents programmes et leurs données sont placés en mémoire RAM, et il faut trouver un moyen pour répartir les différents programmes en RAM. [[File:Partitions mémoire.png|vignette|upright=1|Partitions mémoire]] Il y a plusieurs solutions pour cela, mais l'une d'entre elle est l'usage de segments mémoire. Avec elle, chaque programme se voit attribuer un ou plusieurs blocs de mémoire. Ils sont appelés des '''partitions mémoire''', ou encore des '''segments'''. Dans ce qui va suivre, nous partons du principe qu'un programme est égal à un segment, pour simplifier les explications. Mais sachez qu'un programme peut être éclaté en plusieurs segments dispersés dans la mémoire, et même être conçu pour ! Nous en reparlerons dans le chapitre sur le mémoire virtuelle. Le système d'exploitation mémorise une liste des segments importants en mémoire RAM, appelée la ''table des segments''. Il s'agit d'un tableau dont chaque entrée mémorise les informations pour un segment. Une entrée de ce tableau est appelée un '''descripteur de segment''' et contient son adresse de base, sa taille, et des autorisations liées à la protection mémoire. Précisément, l'entrée numéro N mémorise les informations du segment numéro N. A partir de l'adresse de base de la table des segments et du numéro de segment, on peut lire ou écrire un descripteur de segment. [[File:Global Descriptor table.png|centre|vignette|upright=2|Global Descriptor table]] Toutefois, l'usage de segments amène un paquet de problèmes qu'il faut résoudre au mieux. Le premier problème est tout simplement de placer les segments dans l'espace d'adressage, mais c'est quelque chose qui est du ressort du système d'exploitation. Par contre, cela implique qu'un segment peut être placé n'importe où en RAM et sa position en RAM change à chaque exécution. En conséquence, les adresses des branchements et des données ne sont jamais les mêmes d'une exécution à l'autre. Pour résoudre ce problème, le compilateur considère que le segment commence à l'adresse zéro, puis les adresses sont corrigées avant ou pendant l'exécution du programme. Cette correction est en général réalisée par l'OS lors de la copie du programme en mémoire, mais il existe aussi des techniques matérielles, que nous verrons dans le chapitre suivant. Il faut aussi prendre en compte le phénomène de l''''allocation mémoire'''. Derrière ce nom barbare, se cache quelque chose de simple : les programmes peuvent réclamer de la mémoire au système d'exploitation, pour y placer des données. Les langages de programmation bas niveau supportent des fonctions comme malloc(), qui permettent de demander un bloc de mémoire de N octets à l'OS, qui doit alors accommoder la demande. S'il n'y a pas assez de mémoire, l'appel échoue et le programme doit gérer la situation. De même, un programme peut libérer de la mémoire qu'il n'utilise plus avec des fonctions comme free(). Avec des segments, cela revient à changer la taille d'un segment, plus précisément à la fin du segment. Et tout cela est géré par le système d'exploitation, aussi on laisse cela pour le prochain chapitre. Enfin, sans protection particulière, les programmes peuvent techniquement lire ou écrire les données des autres. Pour éviter cela, il suffit de vérifier qu'un programme accède bien à son segment et pas ailleurs. Il suffit de vérifier si l'adresse lue/écrite est dans le bon intervalle. Il suffit alors d'utiliser deux registres limites, rien de plus. Mais au-delà de cette simple vérification, si un programme pouvait modifier les données d'un autre programme, on se retrouverait rapidement avec une situation non prévue par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses. Il faut donc introduire des mécanismes de protection mémoire, pour isoler les programmes les uns des autres, et éviter toute modification problématique. La protection mémoire regroupe plusieurs techniques assez variées, qui ont des objectifs différents. Elles ont pour point commun de faire intervenir à des niveaux divers le système d'exploitation et le processeur. ==La ''memory map'' des PC IBM x86== Dans ce qui suit, nous allons étudier l'espace d'adressage des processeurs x86. L'organisation de leur espace d'adressage a évolué dans le temps et l'espace d'adressage s'est adapté. Mais il s'est adapté sans modifier l'existant, pour des raisons de compatibilité. Aussi, les processeurs x86 modernes peuvent fonctionner dans plusieurs modes, appelés mode réel, protégé, virtuel 8086, long, ''system management mode'', et compatibilité 32 bits. [[File:AMD64StateDiagram.svg|centre|vignette|upright=2|AMD64StateDiagram]] Le ''system management mode'' a déjà été abordé dans le chapitre sur les interruptions. Pour simplifier, il ne s'agit pas d'un véritable mode au sens "adressage". Dans ce mode, le système d'exploitation est mis en pause et le BIOS ou un autre firmware s'exécute sans restriction. On y rentre grâce à une interruption dédiée, lancée par le noyau de l'OS. Le système d'exploitation exécute cette interruption afin de laisser la main au BIOS, pour lui déléguer certaines tâches. Par contre, les autres modes sont bien plus intéressant, car ils ont chacun un espace d'adressage différent. Voyons-les dans le détail. ===Le mode réel : l'adressage sur 20 bits=== Le tout premier mode était le '''mode réel''', qui utilisait des adresses de 20 bits, ce qui fait 1 mébioctet de mémoire adressable. Il était utilisé sur des processeurs 16 bits, qui géraient donc des adresses de 16 bits capables d'adresser 64 kibioctets de mémoire. Pour contourner cette limitation, le processeur utilisait la segmentation, que nous verrons dans le prochain chapitre. Pour le moment, nous allons dire que la mémoire en mode réel était composée de 16 blocs de 64 kibioctets. C'est une simplification qui ne fera pas de mal. Le processeur démarre systématiquement en mode réel, y compris sur les ordinateurs modernes. Le BIOS s'exécute dans ce mode, avant de passer en mode protégé et de laisser la main à l'UEFI et au système d'exploitation. Autrefois, le système d'exploitation s'exécutait aussi en mode réel, c'est le cas du DOS. En mode réel, l'espace d'adressage est peuplé par de la RAM, la ROM du BIOS et des périphériques. La RAM prenait les adresses basses, la ROM était au sommet de l'espace d'adressage, et les périphériques entre les deux. Les premiers 640 kibioctets sont réservés à la RAM et sont appelés la '''mémoire conventionnelle'''. Les octets restants forment la '''mémoire haute''', réservée aux ROM et aux périphériques. Les deux premiers kibioctets de la mémoire conventionnelle sont initialisés au démarrage de l'ordinateur. Ils sont utilisés pour stocker le vecteur d'interruption (on expliquera cela dans quelques chapitres) et servent aussi au BIOS. La portion réservée au BIOS, la ''BIOS Data Area'', mémorise des informations en RAM. Elle commence à l'adresse 0040:0000h, a une taille de 255 octets, et est initialisée lors du démarrage de l'ordinateur. Le reste de la mémoire conventionnelle est réservée au le système d'exploitation (MS-DOS, avant sa version 5.0) et au programme en cours d’exécution. La mémoire haute est sectionnée en deux portions. Le sommet de la mémoire haute est réservé au BIOS, alors que le bas de la mémoire haute est réservé aux périphériques. Dans cette dernière, on trouve les BIOS des périphériques (dont celui de la carte vidéo, s'il existe), qui sont nécessaires pour les initialiser et pour communiquer avec eux. De plus, on y trouve la mémoire de la carte vidéo et éventuellement la mémoire d'autres périphériques comme la carte son. {| class="wikitable" style="text-align:center;" |+ Espace d'adressage en mode réel |- ! Numéro du bloc !! Contenu du bloc |- | 1024 - 960 Kio || class="f_jaune" | BIOS principal (ROM) |- | 960 Kio - 640 Kio | class="f_vert" | ROM d'extension des périphériques (XT, EGA, 3270 PC) </br> Mémoire vidéo des cartes EGA, MDA ou CGA |- | 640 Kio - 0 || class="f_rouge" | Mémoire RAM, mémoire conventionnelle |} Pour détailler un peu plus, il faut tenir compte du découpage de l'espace d'adressage en blocs de 64 kibioctets. Le découpage en blocs de 64 kibioctets est particulièrement important pour la mémoire haute. Des blocs sont attribués à un périphérique spécifique. Par exemple, le 16ème bloc est réservé au BIOS, le 12ème aux ROM des périphériques, le 10ème et le 11ème à la mémoire vidéo, etc. {| class="wikitable" style="text-align:center;" |+ Espace d'adressage en mode réel |- ! Numéro du bloc !! Contenu du bloc |- | 0 || rowspan="10" class="f_rouge" | Mémoire RAM, mémoire conventionnelle |- | 1 |- | 2 |- | 3 |- | 4 |- | 5 |- | 6 |- | 7 |- | 8 |- | 9 |- | 10 || class="f_vert" | Mémoire vidéo des cartes EGA |- | 11 || class="f_vert" | Mémoire vidéo des cartes MDA ou CGA |- | 12 || class="f_vert" | ROM d'extension des périphériques (XT, EGA, 3270 PC) |- | 13 || class="f_gris" | Autre, non-réservé |- | 14 || class="f_gris" | Autre, non-réservé |- | 15 || class="f_jaune" | ROM du BIOS |} Vous remarquerez que les blocs 13 et 14 sont non-réservés. Ils peuvent être utilisés pour mapper des périphériques en mémoire. Mais si aucun périphérique n'est mappé dedans, les blocs sont laissés libres. Les OS n'hésitaient pas à les utiliser pour adresser de la RAM. Par exemple, si l'ordinateur n'a pas de carte vidéo EGA, le bloc qui leur était réservé était réutilisé comme mémoire RAM. La mémoire conventionnelle passait alors de 640 à 704 kibioctets de RAM disponibles. L'espace d'adressage correspond à la ROM du BIOS. Mais avec le temps, la ROM est devenue plus lente au point qu'exécuter du code directement depuis la ROM du BIOS était un problème niveau performance. Je rappelle que le BIOS contient de nombreuses routines d'interruptions qui étaient appelées par les systèmes d'exploitation de l'époque, notamment le DOS. Heureusement, diverses optimisations permettaient de copier le BIOS dans la mémoire RAM, plus rapide que la ROM du BIOS. Il s'agit des techniques de '''''BIOS Shadowing'''''. Elles s'appliquaient au BIOS proprement dit, mais le BIOS vidéo pouvait aussi être optimisé ainsi, mais les ROM d'extension ne le sont généralement pas. Le ''BIOS Shadowing'' peut s'implémenter de plusieurs manières, mais la plus courante mappait temporairement de la RAM dans le même espace d'adressage que la ROM du BIOS. Le code du BIOS copiait le BIOS avec une boucle, dont chaque itération lisait une adresse du BIOS et écrivait dans cette même adresse. Juste avant la boucle, le ''chipset'' était configuré par le BIOS pour que les lectures atterrissent dans la ROM du BIOS, mais que les écritures tombaient dans une mémoire RAM mappée. Une fois la copie/boucle terminée, l'adressage tombait dans la RAM aussi bien pour les lectures et écritures. Elle demandait un support du ''chipset'' et du logiciel, pour détourner les adresses temporairement. ===Interlude : les technologies d'expanded memory=== Déjà à l'époque, 1 mébioctet était une limite assez faible, même si cela n'a pas empêché à un grand dirigeant de l'industrie informatique de dire que "640K ought to be enough for anybody". Aussi, diverses solutions matérielles ont vu le jour. Elles étaient peu utilisées et ont concrètement servi à des applications spécifiques. La principale est l''''''expanded memory''''' (EMS), une solution utilisant une carte d'extension avec plusieurs modules de RAM installés dessus. Les modules étaient accédés via la technique de la commutation de banque. [[File:Expanded memory.svg|centre|vignette|upright=2|L'espace d'adressage avec ''Expanded memory''. La carte d'extension et le matériel requis ne sont pas représentés.]] Le terme ''expanded memory'' regroupe plusieurs technologies propriétaires semblables sur le principe mais différentes dans l'exécution. Les premières utilisaient des banques de 64 kibioctets, qui étaient mappées dans un bloc de 64 kibioctets libre en mémoire haute. L'usage de banques de 64 kibioctets collait bien avec l'adressage particulier du mode réel, où la mémoire était gérée par blocs de 64 kibioctets. Les technologies ultérieures utilisaient 2 à 4 pages de 16 kibioctets, qui pouvaient être placée n'importe où en mémoire conventionnelle. [[File:EmulexPersyst 4M ISA.jpeg|centre|vignette|upright=2|Carte d'extension ''Expanded memory'' de marque Emulex Persyst, de 4 mébioctets.]] L'''expanded memory'' demandait que les programmes soient codés pour utiliser cette fonctionnalité. Pour cela, ils devaient utiliser une technique de programmation nommée ''Overlay programming''. L'idée est de découper le programme en plusieurs blocs séparés, appelés des '''''overlays'''''. Certains blocs sont en permanence en RAM, mais d'autres sont soit chargés en RAM, soit stockés ailleurs. Le ailleurs est souvent le disque dur, ou dans la RAM ''expanded memory'' si elle est disponible. Le va-et-vient des ''overlays'' entre RAM et disque dur était réalisé en logiciel, par le programme lui-même. [[File:Overlay Programming.svg|centre|vignette|upright=1.5|''Overlay Programming''.]] L'utilisation de l'''expanded memory'' utilisait l'interruption logicielle 67h, une interruption peu utilisée à l'époque, qui était libre. La carte d'extension était utilisée à travers un pilote de périphérique dédié, qui était appelé via l'interruption 67h. ===Le mode protégé : l'adressage sur 24 et 32 bits=== Par la suite, le mode réel a été remplacé par le '''mode protégé'''. Il utilise la mémoire virtuelle, ce qui fait qu'on devrait le détailler dans le chapitre suivant. Il permettait aussi de dépasser la limite du mébioctet de mémoire. L'espace d'adressage en mode protégé est passé de 24 bits sur le CPU 286, à 32 bits sur le 386 et les CPU suivants. Les programmes conçus pour le mode réel ne pouvaient pas s'exécuter en mode protégé. Pour corriger cela, le 286 a introduit le '''mode virtuel 8086''', aussi appelé mode V86. Il s'agit d'une technique de virtualisation, ce qui fait qu'on en parlera en détail dans le chapitre sur la virtualisation assistée en matériel. Tout ce que l'on peut dire ici est que le mode V86 a été détourné pour que les applications DOS utilisent la mémoire au-delà du mébioctet, appelée la '''mémoire étendue'''. Les logiciels DOS accédaient à la mémoire étendue à travers un driver DOS dédié (HIMEM.SYS). Les logiciels DOS déplaçaient des données dans la mémoire étendue, puis les rapatriaient en mémoire conventionnelle quand ils en avaient besoin. Il ne faut pas confondre mémoire étendue et ''expanded memory''. La technique de ''expanded memory'' repose sur de la commutation de banque, demande une carte d'extension dédiée, fonctionne sans mode V86. A l'inverse, la mémoire étendue ne gère pas de commutation de banque, ne demande pas de carte d'extension, mais utilise le mode V86. Par contre, il est possible d'émuler l'''expanded memory'' sans carte d'extension, avec la mémoire étendue. Quelques cartes mères intégraient des techniques pour, mais une émulation logicielle était aussi possible en réécrivant l'interruption 67h. ===Le mode long : l'adressage 64 bits=== Par la suite, les processeurs ont introduit le '''mode long''', dans lequel les adresses font 64 bits. Le CPU en mode long a aussi un jeu d'instruction totalement différent, des registres en plus et d'autres fonctionnalités actives uniquement dans ce mode. Les programmes compilés pour le 64 bits s'exécutent nativement dans ce mode, alors que les programmes 32 et 16 bits s'exécutent dans un mode de compatibilité dédié. Sur les systèmes 64 bits, le bus d'adresse fait seulement 48 bits afin d'économiser des interconnexions. Avec 48 bits, il manque 64 - 48 = 16 bits d'adresses, qui ne peuvent pas être utilisés pour adresser quoi que ce soit. La règle est que ces 16 bits doivent être tous égaux : soit ils valent tous 0, soient ils sont tous à 1. Les adresses qui respectent cette contrainte sont appelées des '''adresses canoniques'''. Le résultat est que l'espace d'adressage est coupé en trois sections, comme illustré ci-dessous. * Les '''adresses canoniques basses''' ont leurs 16 bits de poids fort à 0 et sont situées en bas de l'espace d'adressage, dans les premières adresses. * Les '''adresses canoniques hautes''' ont leurs 16 bits de poids fort à 1 et sont situées au sommet de l'espace d'adressage, dans les dernières adresses. * Entre les deux, se trouvent des '''adresses non-canoniques''', qui ne sont pas accessibles. Y accéder déclenche la levée d'une exception matérielle. {| |[[File:AMD64-canonical--48-bit.png|vignette|Espace d'adressage x86-64 bits, avec des adresses physiques de 48 bits.]] |[[File:AMD64-canonical--56-bit.png|vignette|Espace d'adressage x86-64 bits, avec des adresses physiques de 57 bits.]] |} : Les futurs systèmes x86 devraient passer à des adresses de 57 bits, les trois sections auront donc des frontières différentes. Les adresses non-canoniques sont censées être inutilisables. Mais les programmeurs aimeraient bien pouvoir les utiliser pour des ''pointeurs tagués'', à savoir des pointeurs/adresses associées à des informations. L'idée serait d'utiliser les 16 bits de poids fort pour stocker des informations liées au pointeur, seuls les 48 bits restant codant l'adresse. Les 16 bits peuvent être utilisés de manières très diverses. Un exemple est la technique du '''''memory tagging''''', qui crée une somme de contrôle au pointeur. La somme de contrôle est générée par un algorithme cryptographique, à partir de l'adresse du pointeur. Elle est vérifiée à chaque utilisation du pointeur. Si la somme de contrôle ne correspond pas au pointeur, une erreur est levée. Si jamais un virus ou un code malveillant modifie un pointeur, il ne saura pas comment calculer la somme de contrôle, la somme de contrôle a énormément de chances d'être incorrecte. Quelques fonctionnalités des processeurs visent à autoriser l'utilisation des adresses non-canoniques. L'idée est que les 16 bits de poids fort sont ignorées lors des accès mémoire, ce qui permet d'utiliser les 16 bits de poids fort à volonté. Il s'agit des techniques ''Top Byte Ignore'' d'ARM, ''Upper Address Ignore'' d'AMD, et ''Linear Address Masking'' d'Intel. ==La z/architecture d'IBM== IBM a sa propre gamme de ''mainframes'', qui utilisent des processeurs fait maison par IBM lui-même. Les CPU IBM ont leur propre jeu d'instruction CISC, appelé '''''z/architecture'''''. Et ce jeu d'instruction a des possibilités en termes d'adressage qui sont extrêmement intéressantes, en plus d'être assez inédites. Pour ce chapitre, nous allons nous concentrer sur une fonctionnalité : les programmes ont accès à plusieurs espaces d'adressage naturellement. L'avantage pour les programmes utilisateur n'est pas très évident, mais il l'est nettement plus pour les systèmes d'exploitation. Le système d'exploitation de ces ''mainframes'', le z/OS, profite de cette possibilité, idem pour l'hyperviseur z/VM. Il utilise cette possibilité pour donner un espace d'adressage séparé pour chaque programme en cours d'exécution. Les programmes sont alors isolés les uns des autres. Cette '''isolation des processus''' n'est pas spécifique à IBM : tous les systèmes d'exploitation modernes l'utilisent. Mais les processeurs x86 doivent se débrouiller seulement avec des techniques dites de mémoire virtuelle, alors que les ''mainframes'' IBM complètent la mémoire virtuelle avec des facilités en plus. Pour les programmes utilisateurs, un intérêt est que cela permet de partager des données entre plusieurs programmes. Ils ont un espace d'adressage rien qu'à eux, mais les données partagées sont placées dans un espace d'adressage commun, auquel les deux programmes ont accès. L'espace d'adressage partagé peu même être configuré de manière à ne contenir que des données, il est impossible d'exécuter du code depuis cet espace d'adressage. Ce qui empêche certains ''malwares'' de fonctionner. La ''z/architecture'' supporte 65536 espaces d'adressage simultanés. Les espaces d'adressage sont numérotés, ils ont chacun un '''''Address-Space Number''''' codé sur 16 bits. Nous utiliserons l’abréviation ASN dans ce qui suit. Par contre, un programme ne peut pas utiliser tout ces espaces d'adressages, il ne peut en utiliser que 1024 en même temps. De plus, ils ne sont accessibles que quand le processeur est dans un mode de fonctionnement bien précis. Un ^processeur ''z/architecture'' peut en effet fonctionner dans deux modes, qu'on va appeler "standard" et "étendu". ===Le mode standard de la ''z/architecture''=== En mode standard, un programme exécuté sur la ''z/architecture'' utilise deux espaces d'adressages : l''''espace d'adressage primaire''' où se situent le programme exécuté et les données importantes, un '''espace d'adressage secondaire''' utilisé pour des tâches/données annexes. : Un troisième espace d'adressage l''''espace d'adressage ''home''''', est réservé au système d'exploitation. Il a pour particularité de ne pas avoir d'ASN. Il n'est concrètement accesible qu'en mode superviseur, donc par le système d'exploitation. Nous n'en parlerons pas dans ce qui suit. L'ASN de ces deux espaces d'adressage est mémorisé dans les registres de contrôle du processeur. L'ASN de l'espace primaire est dans le registre de contrôle n°3, l'ASN de l'espace secondaire est dans le registre de contrôle n°4 (ils sont dans les 16 bits de poids faible, dans les deux cas). Les deux registres de contrôle sont initialisés au démarrage de l'application, par le système d'exploitation, ce qui fait que ce sont des espaces d'adressage par défaut. Le programme a donc accès à ces deux espaces d'adressage sans rien faire. Mais comment le processeur pour utiliser ces espaces d'adressages ? Par exemple, s'il veut faire une lecture, comment précise-t-il l'espace d'adressage courant ? La réponse est que le processeur peut être dans deux états : soit primaire, soit secondaire. Dans le premier état, toutes les lectures/écritures se font dans l'espace d'adressage primaire. Dans le second, elles se font toutes dans l'espace d'adressage secondaire. Il peut passer de l'un à l'autre en utilisant l'instruction ''Set Address Space Control''. Le processeur dispose aussi d'instructions ''MOVE TO PRIMARY'' et ''MOVE TO SECONDARY'' pour échanger des données dans les deux espaces d'adressage. ===Les registres d'accès de la ''z/architecture''=== Un programme peut donc passer de l'espace d'adressage primaire à secondaire (ou inversement) en utilisant l’instruction ''SET ADDRESS SPACE CONTROL''. Mais il peut en profiter pour passer dans un mode où il a accès à 1024 espaces d'adressage différents, en même temps. Le processeur n'a pas besoin d'utiliser d'instructions particulières pour copier des données d'un espace d'adressage à un autre, un simple MOV suffit. Le mode en question est appelé le '''mode ''access register'''''. Le mode ''access register'' donne accès aux '''registres d'accès''', qui lui donnent son nom. Les registres d'accès permettent de sélectionner un espace d'adressage, afin qu'une instruction puisse lire/écrire dedans. Il y a en tout 16 registres d'accès, chacun étant appairé avec un registre général. Par exemple, le registre général numéro 5 est appairé au registre d'accès de même numéro. En mode ''access register'', toute lecture/écriture lit à la fois un registre général et le registre d'accès associé. Le registre général contient un pointeur, l'adresse à lire/écrire, et le registre d'accès indique dans quel espace d'adressage se trouve cette adresse. Une implémentation simple numéroterait les espaces d'adressage et mettrait les numéros dans les registres d'accès. Les registres d'accès seraient idéalement protégés en écriture et seulement accessibles par le système d'exploitation. Mais ce n'est pas ce qui est fait. Les registres d'accès font 32 bits chacun, ce qui ne colle pas avec le fait que les ASNs sont codés sur 16 bits. La raison est que les registres d'accès ne mémorisent pas des ASNs, et on peut même aller plus loin : les ASNs ne sont pas utilisés pour adresser les espaces d'adressage. Un programme peut accéder à certains espaces d'adressage bien précis, que lui a attribué le système d'exploitation. Les espaces d'adressages d'un programme sont mémorisés dans une '''liste d'accès privée'''. La liste d'accès est en réalité non pas une liste chainée, mais un tableau. Il contient jusqu’à 1024 entrées, chacune contenant toutes les informations nécessaires pour utiliser un espace d'adressage. Une entrée contient notamment l'adresse mémoire à laquelle commence l'espace d'adressage, qui est utilisée dans divers calculs d'adresse. La liste d'accès privée est gérée par le système d'exploitation, lui seul peut la modifier. Les entrées de la liste privée ont toutes un indice qui permet de les sélectionner dans ce tableau. L'indice en question est appelé un '''''Access List Entry Token''''' ou ALET. Et les registres d'accès mémorisent des ALETs. Un registre d'accès sélectionne une entrée, qui permet de récupérer l'adresse mémoire d'un espace d'adressage. L'ALET peut avoir plusieurs valeurs, mais deux d'entre elles sont pré-déterminées : 0 pointe vers l'espace d'adressage principal, 1 vers l'espace d'adressage secondaire. Les autres valeurs sont configurables. : Précisons que le registre d'accès numéro 0 contient obligatoirement la valeur 0, ce qui fait qu'il pointe vers l'espace d'adressage primaire. Lors d'une lecture/écriture, l'ALETs est utilisé pour sélectionner l'espace d'adressage. Les valeurs 0 et 1 sont traitées telles quelles, l'espace d'adressage est déterminé automatiquement par le processeur. Pour les autres valeurs, récupérer l'adresse mémoire demande de consulter la liste d'accès privée. L'ALET est envoyé à la liste d'accès privée, l'adresse est récupérée. L'usage des registres d'accès est aussi plus flexible qu'on ne pourrait le croire. Le programme peut lire et écrire dans les registres d'accès, copier un registre d'accès dans un autre, etc. Par contre, la table de correspondance empêche à un programme d’accéder à un espace d'adressage arbitraire. Le système d'exploitation remplit la table de manière à n'autoriser l'accès qu'aux espaces d'adressage adéquats, il y enregistre seulement les correspondances utiles. Si un programme forge un identifiant arbitraire, et l'envoie à la table de correspondance, celle-ci n'aura pas la correspondance associée et l'accès mémoire sera invalidé. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs 8 bits et moins | prevText=Les processeurs 8 bits et moins | next=L'abstraction mémoire et la mémoire virtuelle | nextText=L'abstraction mémoire et la mémoire virtuelle }} </noinclude> lxjfgpj8668qklcr276z16rrgifyxjm 774379 774378 2026-10-07T17:42:01Z Mewtow 31375 /* Les registres d'accès de la z/architecture */ 774379 wikitext text/x-wiki L''''espace d'adressage''' du processeur est l'ensemble des adresses utilisables par le processeur. Par exemple, un processeur 16 bits peut adresser 2^16 = 65536 adresses, l'ensemble de ces adresses forme son espace d'adressage. L'espace d'adressage n'est pas toujours égal à la mémoire réellement installée. S'il n'y a pas assez de RAM installée, des adresses seront inoccupées. De plus, une partie de l'espace d'adressage peut être détourné pour communiquer avec les périphériques, comme nous le verrons plus bas. Nous verrons aussi qu'un processeur peut avoir plusieurs espaces d'adressages séparés. ==L'adressage de la RAM et de la ROM== Avoir plusieurs espaces d'adressage spécialisés est quelque chose que nous avons déjà rencontré dans les chapitres précédents, mais sans le dire ouvertement. Aussi, nous allons faire quelques rappels sur les cas déjà rencontrés. En premier lieu, nous allons rappeler la différence entre architectures Von Neumann et Hardvard. La différence entre les deux tient dans l'adressage des mémoires RAM et ROM : est-ce qu'elles sont dans un seul espace d'adressage, ou dans des espaces d'adressage séparés. L''''architecture Von Neumann''' a un seul espace d'adressage, découpé entre la mémoire RAM d'un côté et la mémoire ROM de l'autre. Une adresse correspond soit à la mémoire RAM, soit à la mémoire ROM, mais pas aux deux. Typiquement, la mémoire ROM est placée dans les adresses hautes, les plus élevées, alors que la RAM est placée dans les adresses basses en commençant par l'adresse 0. C'est une convention qui n'est pas toujours respectée, aussi mieux vaut éviter de la tenir pour acquise. [[File:Vision de la mémoire par un processeur sur une architecture Von Neumann.png|centre|vignette|upright=2|Vision de la mémoire par un processeur sur une architecture Von Neumann.]] L''''architecture Harvard''' utilise des espaces d'adressage séparés pour la RAM et la ROM. Une même adresse peut correspondre soit à la ROM, soit à la RAM. Le processeur voit bien deux mémoires séparées, chacune dans son propre espace d'adressage. Les deux espaces d'adressage n'ont pas forcément la même taille. Il est possible d'avoir un plus gros espace d'adressage pour la RAM que pour la ROM. Cela implique que les adresses des instructions et des données soient de taille différentes. C'est peu pratique et c'est rarement implémenté, ce qui fait que le cas le plus courant est celui où les deux espaces d'adressages ont la même taille. [[File:Vision de la mémoire par un processeur sur une architecture Harvard.png|centre|vignette|upright=2|Vision de la mémoire par un processeur sur une architecture Harvard.]] Sur une '''architecture Harvard modifiée''', le programme est placé dans un espace d'adressage à part du reste. Le problème, c'est que cet espace d'adressage ne contient pas que le code machine à exécuter. Il contient aussi des constantes, à savoir des données qui gardent la même valeur lors de l'exécution du programme. Elles peuvent être lues, mais pas modifiées durant l'exécution du programme. L'accès à ces constantes demande d'aller lire celles-ci dans l'autre espace d'adressage, pour les copier dans les registres généraux. Le processeur doit pour cela avoir des instructions de lecture dédiées. En clair, il y a une instruction LOAD pour lire dans la mémoire ROM, une autre pour lire dans la RAM. [[File:Espaces d'adressage sur une archi harvard modifiée.png|centre|vignette|upright=3|Espaces d'adressage sur une archi harvard modifiée]] Une autre solution utilise des instructions de copie, qui copient les constantes en mémoire RAM, donc d'un espace d'adressage à l'autre. [[File:Organisation des espaces d'adressage sur une archi harvard modifiée.png|centre|vignette|upright=3|Organisation des espaces d'adressage sur une archi harvard modifiée]] ==L'adressage des périphériques== Passons maintenant à l'adressage des périphériques. La communication avec les périphériques se fait par l'intermédiaire de registres d’interfaçage. Et ces registres peuvent soit avoir un espace d'adressage séparé, soit être inclus dans l'espace d'adressage des mémoires. Dans ce qui suit, nous allons supposer que l'architecture des de type Von Neumann pour simplifier les explications. ===Les entrées-sorties mappées en mémoire=== Une partie de l'espace d'adressage peut être détourné pour communiquer avec les périphériques. Le périphérique se retrouve inclus dans l'ensemble des adresses utilisées pour manipuler la mémoire : on dit qu'il est mappé en mémoire. Les adresses mémoires associées à un périphérique sont redirigées automatiquement vers les registres du périphérique en question, voire vers sa mémoire intégrée. On parle alors d''''entrées-sorties mappées en mémoire'''. [[File:IO mappées en mémoire.png|centre|vignette|upright=2.0|IO mappées en mémoire]] On remarque un défaut inhérent à cette technique : les adresses utilisées pour les périphériques ne sont plus disponibles pour la RAM. On ne peut plus adresser autant de RAM qu'avant. La perte dépend des périphériques installés et de leur gourmandise en adresses mémoires. C'est ce qui causait un problème assez connu sur les ordinateurs 32 bits, capables d'adresser 4 gibioctets de mémoire. Certaines personnes installaient 4 gigaoctets de mémoire sur leur ordinateur 32 bits et se retrouvaient avec « seulement » 3,5 à 3,8 gigaoctets de mémoire, les périphériques prenant le reste. Il est possible que la RAM d'un périphérique soit mappée en RAM. Un exemple classique est celui des cartes graphiques qui incorporent une mémoire RAM appelée la mémoire vidéo. La mémoire vidéo est mappée en mémoire, ce qui permet au processeur d'écrire directement dedans. Toute lecture ou écriture dans les adresses associées est redirigée vers le bus PCI/AGP/PCI-Express. Nous verrons plus bas des exemples d'ordinateurs où la mémoire vidéo est mappée en mémoire, que ce soit totalement ou partiellement. Si je dis totalement ou partiellement, c'est parce que les cartes graphiques modernes disposent de tellement de mémoire qu'on ne peut pas la mapper totalement dans l'espace d'adressage. Sur les systèmes d'avant 2008, seuls 256 mégaoctets de mémoire vidéo sont mappés en mémoire RAM. Le reste de la mémoire vidéo est invisible du point de vue du processeur, mais manipulable par le GPU à sa guise. Après 2008, la spécification du PCI-Express ajouta un support de la technologie ''Resizable Bar'', qui permet au processeur d’accéder directement à plus de 256 mégaoctets de mémoire vidéo, voire à la totalité de la mémoire vidéo. ===L'espace d'adressage séparé pour les entrées-sorties=== Les entrées-sorties et périphériques peuvent avoir leur propre espace d'adressage dédié, séparé de celui utilisé pour les mémoires RAM et ROM. [[File:Espaces_d'adressages_séparés_entre_mémoire_et_périphérique.png|centre|vignette|upright=3|Bit IO.]] Une même adresse peut donc adresser soit une entrée-sortie, soit une case mémoire. Et pour faire la différence, le processeur doit avoir des instructions séparées pour adresser les périphériques et la mémoire. Il a des instructions de lecture/écriture pour lire/écrire en mémoire, et d'autres pour lire/écrire les registres d’interfaçage. Sans cela, le processeur ne saurait pas si une adresse est destinée à un périphérique ou à la mémoire. Cela élimine aussi les problèmes avec les caches : les accès à l'espace d'adressage de la RAM passent par l'intermédiaire de la mémoire cache, alors les accès dans l'espace d'adressage des périphériques le contournent totalement. Là encore, les deux espaces d'adressage n'ont pas forcément la même taille. Il arrive que les deux espaces d'adressage aient la même taille, le plus souvent sur des ordinateurs complexes avec beaucoup de périphériques. Mais les systèmes embarqués ont souvent des espaces d'adressage plus petits pour les périphériques que pour la ou les mémoires. L'implémentation varie grandement suivant le cas, la première méthode imposant d'avoir deux bus séparés pour les mémoires et les périphériques, l'autre permettant un certain partage du bus d'adresse. Nous reviendrons dessus plus en détail dans le chapitre sur l'adressage des périphériques. ==La ''memory map'' d'un ordinateur== Les deux sections précédentes nous ont appris que l'on peut utiliser un espace d'adressage séparé pour la ROM et un autre pour les périphériques. En tout, cela donne quatre possibilités distinctes. {|class="wikitable" |- ! ! IO mappées en mémoire ! IO séparées |- ! ROM mappée en mémoire (architecture Von Neumann) | Un seul espace d'adressage | Deux espaces d'adressage : * un pour les mémoires RAM/ROM ; * un pour les IO. |- ! ROM séparée (architecture Harvard) | Deux espaces d'adressage : * un pour la RAM et les IO ; * un pour la ROM. | Trois espaces d'adressages : * un pour la RAM ; * un pour la ROM ; * un pour les IO. |} Les quatre solutions ont des avantages et inconvénients divers, mais il est intéressant de contraster un espace d'adressage unique avec plusieurs espaces d'adressages. ===L'espace d'adressage unique=== Avec un espace d'adressage unique, la ROM est au sommet de l'espace d'adressage, les périphériques sont juste en-dessous, la RAM commence à l'adresse 0 et prend les adresses basses. Faire simplifie grandement l'implémentation matérielle de l'adressage. Notons que d'autres composants que les périphériques ou les mémoires peuvent se trouver dans l'espace d'adressage. On peut y trouver les horloges temps réels, des timers, des senseurs de température, ou d'autres composants placés sur la carte mère. Un exemple un peu original est le suivant : la console de jeu Nintendo DS incorporait une unité de calcul spécialisée dans les divisions et racines carrées, séparée du processeur, qui était justement mappée en mémoire ! [[File:Espace d'adressage classique avec entrées-sorties mappées en mémoire.png|centre|vignette|upright=2|Espace d'adressage classique avec entrées-sorties mappées en mémoire]] L'espace d'adressage unique est plus simple pour les programmeurs, malgré un désavantage dont il faut parler. Le problème est que des adresses censées être disponibles pour la RAM sont détournées vers la ROM ou les périphériques, ce qui limite la quantité de RAM qui peut être réellement adressée en pratique. C'est un défaut qui se manifeste seulement pour les petits espaces d'adressages, de 8 à 16 bits, qui ne permettent pas d'adresser beaucoup de RAM. Avec des adresses codées sur 16 bits, un espace d'adressage de 64 kibioctets de RAM est grignoté par des entrées-sorties assez rapidement. Par contre, pour des adresses de 32 à 64 bits, une bonne partie de l'espace d'adressage est inutilisé, car il n'y a pas assez de RAM. L'espace d'adressage unique n'a donc pas de désavantages. Les vielles consoles de jeux et certains vieux ordinateurs (Commodores et Amiga), utilisaient un espace d'adressage unique. Elles n'avaient pas une variété de cartes graphiques ou de cartes sons différentes, comme sur les PCs modernes. Au contraire, elles avaient la même configuration matérielle, le matériel ne pouvait pas être changé ni upgradé. Le système d'exploitation était rudimentaire et ne contrôlait pas vraiment l'accès au matériel. Les programmeurs avaient donc totalement accès au matériel et mapper les entrées/sorties en mémoire rendait la programmation des périphériques très simple. <gallery widths=200px heights=500px> N5200mk2 memory map.svg|Adressage mémoire (carte mémoire) du N5200mk2. PC-9801VM memory map.svg|Adressage mémoire (carte mémoire) du PC-9801VM. </gallery> ===La commutation de banques (''bank switching'')=== Le '''''bank switching''''', aussi appelé '''commutation de banque''', permet d'utiliser plusieurs espaces d'adressage sur un même processeur, sans attribuer chaque espace d'adressage pour une raison précise. L'espace d'adressage est présent en plusieurs exemplaires appelés des '''banques'''. Les banques sont numérotées, chaque numéro de banque permettant de l'identifier et de le sélectionner. Le but de cette technique est d'augmenter la mémoire disponible pour l'ordinateur. Par exemple, supposons que j'ai besoin d'adresser une mémoire ROM de 4 kibioctets, une RAM de 8 kibioctets, et divers périphériques. Le processeur a un bus d'adresse de 12 bits, ce qui limite l'espace d'adressage à 4 kibioctets. Dans ce cas, je peux réserver 4 banques : une pour la ROM, une pour les périphériques, et deux banques qui contiennent chacune la moitié de la RAM. La simplicité et l'efficacité de cette technique font qu'elle est beaucoup utilisée dans l'informatique embarquée. [[File:PC-8801MemoryMap.gif|centre|vignette|upright=2|exemple de Bank switching.]] Cette technique demande d'utiliser un bus d'adresse plus grand que les adresses du processeur. L'adresse réelle se calcule en concaténant le numéro de banque avec l'adresse accédée. Le numéro de la banque actuellement en cours d'utilisation est mémorisé dans un registre appelé le '''registre de banque'''. On peut changer de banque en changeant le contenu de ce registre. Le processeur dispose souvent d'instructions spécialisées qui en sont capables. {| class="wikitable flexible" |[[File:Banque mémoire.png|Banque mémoire.]] |[[File:Registre de banque.png|Registre de banque.]] |} ==Le placement des programmes dans l'espace d'adressage== Les ordinateurs modernes sont des architectures Von Neumann, avec un seul espace d'adressage, qui est partagé suivant les besoins. Et un point important est que le système d'exploitation et les programmes sont copiés en mémoire RAM avant d'être exécutés. Ils ne sont pas exécutés depuis une mémoire ROM, cela est réservé aux systèmes embarqués simples. Et cela a une influence sur l'espace d'adressage. Les programmes et le système d'exploitation doivent se partager l'espace d'adressage. Voyons voir comment ce partage se fait. Dans ce chapitre, nous allons volontairement mettre de côté les ordinateurs qui supportent la mémoire virtuelle. Nous en parlerons dans le prochain chapitre. Le principe est de voir comment un processeur avec un seul espace d'adressage gèrent la présence d'un ou de plusieurs programmes. ===Un seul espace d'adressage, non-partagé=== Le cas le plus simple est celui où il n'y a pas de système d'exploitation et où un seul programme s'exécute sur l'ordinateur. Le programme a alors accès à tout l'espace d'adressage, sauf pour ce qui est réservé aux périphériques et à la mémoire ROM. Le programme organise la mémoire en plusieurs sections, dans lesquelles le programme range des données différentes. Typiquement, on trouve quatre blocs de mémoire, appelés des segments, chacun étant spécialisé pour les données, le code, la pile, etc. Voici ces trois sections : * Le segment '''''text''''' 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. * Le segment pour la '''pile''', de taille variable. * le reste est appelé le '''tas''', de taille variable. [[File:Organisation d'un espace d'adressage unique utilisé par un programme unique.png|centre|vignette|upright=2|Organisation d'un espace d'adressage unique utilisé par un programme unique]] [[File:Typical computer data memory arrangement.png|vignette|upright=0.5|Typical computer data memory arrangement]] La pile et le tas sont de taille variable, ce qui veut dire qu'ils peuvent grandir ou diminuer à volonté, contrairement au reste. Entre le tas et la pile, on trouve un espace de mémoire inutilisée, qui peut être réquisitionné selon les besoins. La pile commence généralement à l'adresse la plus haute et grandit en descendant, alors que le tas grandit en remontant vers les adresses hautes. Il s'agit là d'une convention, rien de plus. Il est possible d'inverser la pile et le tas sans problème, c'est juste que cette organisation est rentrée dans les usages. Avant de poursuivre, précisons qu'il est possible de regrouper plusieurs programmes distincts dans un seul, afin qu'ils partagent le même morceau de mémoire. Les programmes portent alors le nom de '''threads'''. Les ''threads'' d'un même processus partagent le même espace d'adressage. Ils partagent généralement certains segments : ils se partagent le code, le tas et les données statiques. Par contre, chaque thread dispose de sa propre pile d'appel. [[File:Single vs multithreaded processes.jpg|centre|vignette|upright=2.0|Distinction entre processus mono et multi-thread.]] Il va de soi que cette vision de l'espace d'adressage ne tient pas compte des périphériques. C'est-à-dire que les schémas précédents partent du principe qu'on a un espace d'adressage séparé pour les périphériques. Dans le cas où les entrées-sorties sont mappées en mémoire, l'organisation est plus compliquée. Généralement, les adresses associées aux périphériques sont placées juste au-dessus de la pile, dans les adresses hautes. ===Un seul espace d'adressage, partagé avec le système d'exploitation=== [[File:Gestion de la mémoire sur les OS monoprogrammés.png|vignette|upright=1.0|Gestion de la mémoire sur les OS monoprogrammés.]] Maintenant, étudions le cas où le programme partage la mémoire avec le système d'exploitation. Sur les systèmes d'exploitation les plus simples, on ne peut lancer qu'un seul programme à la fois. Le système d'exploitation réserve une portion de taille fixe réservée au système d'exploitation, alors que le reste de la mémoire est utilisé pour le programme à exécuter. Le programme est placé à un endroit en RAM qui est toujours le même. Le système d'exploitation peut être soit placé dans une ROM, soit copié en mémoire RAM depuis une mémoire de masse, comme le programme à lancer. Si le système d'exploitation est copié en mémoire RAM, il est généralement placé dans les premières adresses, les adresses basses. A l'inverse, un OS en mémoire ROM est généralement placé à la fin de la mémoire, dans les adresses hautes. Mais tout cela n'est qu'une convention, et les exceptions sont monnaie courante. [[File:Méthodes d'allocation de la mémoire avec un espace d'adressage unique.png|centre|vignette|upright=2.0|Méthodes d'allocation de la mémoire avec un espace d'adressage unique]] Sur de tels systèmes, il faut protéger l'OS contre une erreur ou malveillance d'un programme utilisateur. Notons qu'il s'agit d'une protection en écriture, pas en lecture. Le programme peut parfaitement lire les données de l'OS sans problèmes, au moins en partie. Dans ce qui suit, on part du principe que l'OS est dans les adresses basses. La solution la plus courante interdit au programme d'écrire au-delà d'une limite au-delà de laquelle se trouve le système d’exploitation. Pour cela, le processeur incorpore un '''registre limite''', qui contient l'adresse limite au-delà de laquelle un programme peut pas écrire. Pour toute écriture en espace utilisateur, l'adresse écrite est comparée au registre limite. Si l'adresse est inférieure au registre limite, le programme cherche écrire dans la mémoire réservée à l'OS. L’accès mémoire est interdit, une exception matérielle est levée et l'OS affiche un message d'erreur. [[File:Circuit total.png|centre|vignette|upright=2.0|Protection mémoire avec un registre limite.]] ===Un seul espace d'adressage, partagé entre plusieurs programmes=== Les systèmes d’exploitation modernes implémentent la '''multiprogrammation''', le fait de pouvoir lancer 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. Et il faut alors résoudre un problème : le partage de la RAM entre les différents programmes. Les différents programmes et leurs données sont placés en mémoire RAM, et il faut trouver un moyen pour répartir les différents programmes en RAM. [[File:Partitions mémoire.png|vignette|upright=1|Partitions mémoire]] Il y a plusieurs solutions pour cela, mais l'une d'entre elle est l'usage de segments mémoire. Avec elle, chaque programme se voit attribuer un ou plusieurs blocs de mémoire. Ils sont appelés des '''partitions mémoire''', ou encore des '''segments'''. Dans ce qui va suivre, nous partons du principe qu'un programme est égal à un segment, pour simplifier les explications. Mais sachez qu'un programme peut être éclaté en plusieurs segments dispersés dans la mémoire, et même être conçu pour ! Nous en reparlerons dans le chapitre sur le mémoire virtuelle. Le système d'exploitation mémorise une liste des segments importants en mémoire RAM, appelée la ''table des segments''. Il s'agit d'un tableau dont chaque entrée mémorise les informations pour un segment. Une entrée de ce tableau est appelée un '''descripteur de segment''' et contient son adresse de base, sa taille, et des autorisations liées à la protection mémoire. Précisément, l'entrée numéro N mémorise les informations du segment numéro N. A partir de l'adresse de base de la table des segments et du numéro de segment, on peut lire ou écrire un descripteur de segment. [[File:Global Descriptor table.png|centre|vignette|upright=2|Global Descriptor table]] Toutefois, l'usage de segments amène un paquet de problèmes qu'il faut résoudre au mieux. Le premier problème est tout simplement de placer les segments dans l'espace d'adressage, mais c'est quelque chose qui est du ressort du système d'exploitation. Par contre, cela implique qu'un segment peut être placé n'importe où en RAM et sa position en RAM change à chaque exécution. En conséquence, les adresses des branchements et des données ne sont jamais les mêmes d'une exécution à l'autre. Pour résoudre ce problème, le compilateur considère que le segment commence à l'adresse zéro, puis les adresses sont corrigées avant ou pendant l'exécution du programme. Cette correction est en général réalisée par l'OS lors de la copie du programme en mémoire, mais il existe aussi des techniques matérielles, que nous verrons dans le chapitre suivant. Il faut aussi prendre en compte le phénomène de l''''allocation mémoire'''. Derrière ce nom barbare, se cache quelque chose de simple : les programmes peuvent réclamer de la mémoire au système d'exploitation, pour y placer des données. Les langages de programmation bas niveau supportent des fonctions comme malloc(), qui permettent de demander un bloc de mémoire de N octets à l'OS, qui doit alors accommoder la demande. S'il n'y a pas assez de mémoire, l'appel échoue et le programme doit gérer la situation. De même, un programme peut libérer de la mémoire qu'il n'utilise plus avec des fonctions comme free(). Avec des segments, cela revient à changer la taille d'un segment, plus précisément à la fin du segment. Et tout cela est géré par le système d'exploitation, aussi on laisse cela pour le prochain chapitre. Enfin, sans protection particulière, les programmes peuvent techniquement lire ou écrire les données des autres. Pour éviter cela, il suffit de vérifier qu'un programme accède bien à son segment et pas ailleurs. Il suffit de vérifier si l'adresse lue/écrite est dans le bon intervalle. Il suffit alors d'utiliser deux registres limites, rien de plus. Mais au-delà de cette simple vérification, si un programme pouvait modifier les données d'un autre programme, on se retrouverait rapidement avec une situation non prévue par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses. Il faut donc introduire des mécanismes de protection mémoire, pour isoler les programmes les uns des autres, et éviter toute modification problématique. La protection mémoire regroupe plusieurs techniques assez variées, qui ont des objectifs différents. Elles ont pour point commun de faire intervenir à des niveaux divers le système d'exploitation et le processeur. ==La ''memory map'' des PC IBM x86== Dans ce qui suit, nous allons étudier l'espace d'adressage des processeurs x86. L'organisation de leur espace d'adressage a évolué dans le temps et l'espace d'adressage s'est adapté. Mais il s'est adapté sans modifier l'existant, pour des raisons de compatibilité. Aussi, les processeurs x86 modernes peuvent fonctionner dans plusieurs modes, appelés mode réel, protégé, virtuel 8086, long, ''system management mode'', et compatibilité 32 bits. [[File:AMD64StateDiagram.svg|centre|vignette|upright=2|AMD64StateDiagram]] Le ''system management mode'' a déjà été abordé dans le chapitre sur les interruptions. Pour simplifier, il ne s'agit pas d'un véritable mode au sens "adressage". Dans ce mode, le système d'exploitation est mis en pause et le BIOS ou un autre firmware s'exécute sans restriction. On y rentre grâce à une interruption dédiée, lancée par le noyau de l'OS. Le système d'exploitation exécute cette interruption afin de laisser la main au BIOS, pour lui déléguer certaines tâches. Par contre, les autres modes sont bien plus intéressant, car ils ont chacun un espace d'adressage différent. Voyons-les dans le détail. ===Le mode réel : l'adressage sur 20 bits=== Le tout premier mode était le '''mode réel''', qui utilisait des adresses de 20 bits, ce qui fait 1 mébioctet de mémoire adressable. Il était utilisé sur des processeurs 16 bits, qui géraient donc des adresses de 16 bits capables d'adresser 64 kibioctets de mémoire. Pour contourner cette limitation, le processeur utilisait la segmentation, que nous verrons dans le prochain chapitre. Pour le moment, nous allons dire que la mémoire en mode réel était composée de 16 blocs de 64 kibioctets. C'est une simplification qui ne fera pas de mal. Le processeur démarre systématiquement en mode réel, y compris sur les ordinateurs modernes. Le BIOS s'exécute dans ce mode, avant de passer en mode protégé et de laisser la main à l'UEFI et au système d'exploitation. Autrefois, le système d'exploitation s'exécutait aussi en mode réel, c'est le cas du DOS. En mode réel, l'espace d'adressage est peuplé par de la RAM, la ROM du BIOS et des périphériques. La RAM prenait les adresses basses, la ROM était au sommet de l'espace d'adressage, et les périphériques entre les deux. Les premiers 640 kibioctets sont réservés à la RAM et sont appelés la '''mémoire conventionnelle'''. Les octets restants forment la '''mémoire haute''', réservée aux ROM et aux périphériques. Les deux premiers kibioctets de la mémoire conventionnelle sont initialisés au démarrage de l'ordinateur. Ils sont utilisés pour stocker le vecteur d'interruption (on expliquera cela dans quelques chapitres) et servent aussi au BIOS. La portion réservée au BIOS, la ''BIOS Data Area'', mémorise des informations en RAM. Elle commence à l'adresse 0040:0000h, a une taille de 255 octets, et est initialisée lors du démarrage de l'ordinateur. Le reste de la mémoire conventionnelle est réservée au le système d'exploitation (MS-DOS, avant sa version 5.0) et au programme en cours d’exécution. La mémoire haute est sectionnée en deux portions. Le sommet de la mémoire haute est réservé au BIOS, alors que le bas de la mémoire haute est réservé aux périphériques. Dans cette dernière, on trouve les BIOS des périphériques (dont celui de la carte vidéo, s'il existe), qui sont nécessaires pour les initialiser et pour communiquer avec eux. De plus, on y trouve la mémoire de la carte vidéo et éventuellement la mémoire d'autres périphériques comme la carte son. {| class="wikitable" style="text-align:center;" |+ Espace d'adressage en mode réel |- ! Numéro du bloc !! Contenu du bloc |- | 1024 - 960 Kio || class="f_jaune" | BIOS principal (ROM) |- | 960 Kio - 640 Kio | class="f_vert" | ROM d'extension des périphériques (XT, EGA, 3270 PC) </br> Mémoire vidéo des cartes EGA, MDA ou CGA |- | 640 Kio - 0 || class="f_rouge" | Mémoire RAM, mémoire conventionnelle |} Pour détailler un peu plus, il faut tenir compte du découpage de l'espace d'adressage en blocs de 64 kibioctets. Le découpage en blocs de 64 kibioctets est particulièrement important pour la mémoire haute. Des blocs sont attribués à un périphérique spécifique. Par exemple, le 16ème bloc est réservé au BIOS, le 12ème aux ROM des périphériques, le 10ème et le 11ème à la mémoire vidéo, etc. {| class="wikitable" style="text-align:center;" |+ Espace d'adressage en mode réel |- ! Numéro du bloc !! Contenu du bloc |- | 0 || rowspan="10" class="f_rouge" | Mémoire RAM, mémoire conventionnelle |- | 1 |- | 2 |- | 3 |- | 4 |- | 5 |- | 6 |- | 7 |- | 8 |- | 9 |- | 10 || class="f_vert" | Mémoire vidéo des cartes EGA |- | 11 || class="f_vert" | Mémoire vidéo des cartes MDA ou CGA |- | 12 || class="f_vert" | ROM d'extension des périphériques (XT, EGA, 3270 PC) |- | 13 || class="f_gris" | Autre, non-réservé |- | 14 || class="f_gris" | Autre, non-réservé |- | 15 || class="f_jaune" | ROM du BIOS |} Vous remarquerez que les blocs 13 et 14 sont non-réservés. Ils peuvent être utilisés pour mapper des périphériques en mémoire. Mais si aucun périphérique n'est mappé dedans, les blocs sont laissés libres. Les OS n'hésitaient pas à les utiliser pour adresser de la RAM. Par exemple, si l'ordinateur n'a pas de carte vidéo EGA, le bloc qui leur était réservé était réutilisé comme mémoire RAM. La mémoire conventionnelle passait alors de 640 à 704 kibioctets de RAM disponibles. L'espace d'adressage correspond à la ROM du BIOS. Mais avec le temps, la ROM est devenue plus lente au point qu'exécuter du code directement depuis la ROM du BIOS était un problème niveau performance. Je rappelle que le BIOS contient de nombreuses routines d'interruptions qui étaient appelées par les systèmes d'exploitation de l'époque, notamment le DOS. Heureusement, diverses optimisations permettaient de copier le BIOS dans la mémoire RAM, plus rapide que la ROM du BIOS. Il s'agit des techniques de '''''BIOS Shadowing'''''. Elles s'appliquaient au BIOS proprement dit, mais le BIOS vidéo pouvait aussi être optimisé ainsi, mais les ROM d'extension ne le sont généralement pas. Le ''BIOS Shadowing'' peut s'implémenter de plusieurs manières, mais la plus courante mappait temporairement de la RAM dans le même espace d'adressage que la ROM du BIOS. Le code du BIOS copiait le BIOS avec une boucle, dont chaque itération lisait une adresse du BIOS et écrivait dans cette même adresse. Juste avant la boucle, le ''chipset'' était configuré par le BIOS pour que les lectures atterrissent dans la ROM du BIOS, mais que les écritures tombaient dans une mémoire RAM mappée. Une fois la copie/boucle terminée, l'adressage tombait dans la RAM aussi bien pour les lectures et écritures. Elle demandait un support du ''chipset'' et du logiciel, pour détourner les adresses temporairement. ===Interlude : les technologies d'expanded memory=== Déjà à l'époque, 1 mébioctet était une limite assez faible, même si cela n'a pas empêché à un grand dirigeant de l'industrie informatique de dire que "640K ought to be enough for anybody". Aussi, diverses solutions matérielles ont vu le jour. Elles étaient peu utilisées et ont concrètement servi à des applications spécifiques. La principale est l''''''expanded memory''''' (EMS), une solution utilisant une carte d'extension avec plusieurs modules de RAM installés dessus. Les modules étaient accédés via la technique de la commutation de banque. [[File:Expanded memory.svg|centre|vignette|upright=2|L'espace d'adressage avec ''Expanded memory''. La carte d'extension et le matériel requis ne sont pas représentés.]] Le terme ''expanded memory'' regroupe plusieurs technologies propriétaires semblables sur le principe mais différentes dans l'exécution. Les premières utilisaient des banques de 64 kibioctets, qui étaient mappées dans un bloc de 64 kibioctets libre en mémoire haute. L'usage de banques de 64 kibioctets collait bien avec l'adressage particulier du mode réel, où la mémoire était gérée par blocs de 64 kibioctets. Les technologies ultérieures utilisaient 2 à 4 pages de 16 kibioctets, qui pouvaient être placée n'importe où en mémoire conventionnelle. [[File:EmulexPersyst 4M ISA.jpeg|centre|vignette|upright=2|Carte d'extension ''Expanded memory'' de marque Emulex Persyst, de 4 mébioctets.]] L'''expanded memory'' demandait que les programmes soient codés pour utiliser cette fonctionnalité. Pour cela, ils devaient utiliser une technique de programmation nommée ''Overlay programming''. L'idée est de découper le programme en plusieurs blocs séparés, appelés des '''''overlays'''''. Certains blocs sont en permanence en RAM, mais d'autres sont soit chargés en RAM, soit stockés ailleurs. Le ailleurs est souvent le disque dur, ou dans la RAM ''expanded memory'' si elle est disponible. Le va-et-vient des ''overlays'' entre RAM et disque dur était réalisé en logiciel, par le programme lui-même. [[File:Overlay Programming.svg|centre|vignette|upright=1.5|''Overlay Programming''.]] L'utilisation de l'''expanded memory'' utilisait l'interruption logicielle 67h, une interruption peu utilisée à l'époque, qui était libre. La carte d'extension était utilisée à travers un pilote de périphérique dédié, qui était appelé via l'interruption 67h. ===Le mode protégé : l'adressage sur 24 et 32 bits=== Par la suite, le mode réel a été remplacé par le '''mode protégé'''. Il utilise la mémoire virtuelle, ce qui fait qu'on devrait le détailler dans le chapitre suivant. Il permettait aussi de dépasser la limite du mébioctet de mémoire. L'espace d'adressage en mode protégé est passé de 24 bits sur le CPU 286, à 32 bits sur le 386 et les CPU suivants. Les programmes conçus pour le mode réel ne pouvaient pas s'exécuter en mode protégé. Pour corriger cela, le 286 a introduit le '''mode virtuel 8086''', aussi appelé mode V86. Il s'agit d'une technique de virtualisation, ce qui fait qu'on en parlera en détail dans le chapitre sur la virtualisation assistée en matériel. Tout ce que l'on peut dire ici est que le mode V86 a été détourné pour que les applications DOS utilisent la mémoire au-delà du mébioctet, appelée la '''mémoire étendue'''. Les logiciels DOS accédaient à la mémoire étendue à travers un driver DOS dédié (HIMEM.SYS). Les logiciels DOS déplaçaient des données dans la mémoire étendue, puis les rapatriaient en mémoire conventionnelle quand ils en avaient besoin. Il ne faut pas confondre mémoire étendue et ''expanded memory''. La technique de ''expanded memory'' repose sur de la commutation de banque, demande une carte d'extension dédiée, fonctionne sans mode V86. A l'inverse, la mémoire étendue ne gère pas de commutation de banque, ne demande pas de carte d'extension, mais utilise le mode V86. Par contre, il est possible d'émuler l'''expanded memory'' sans carte d'extension, avec la mémoire étendue. Quelques cartes mères intégraient des techniques pour, mais une émulation logicielle était aussi possible en réécrivant l'interruption 67h. ===Le mode long : l'adressage 64 bits=== Par la suite, les processeurs ont introduit le '''mode long''', dans lequel les adresses font 64 bits. Le CPU en mode long a aussi un jeu d'instruction totalement différent, des registres en plus et d'autres fonctionnalités actives uniquement dans ce mode. Les programmes compilés pour le 64 bits s'exécutent nativement dans ce mode, alors que les programmes 32 et 16 bits s'exécutent dans un mode de compatibilité dédié. Sur les systèmes 64 bits, le bus d'adresse fait seulement 48 bits afin d'économiser des interconnexions. Avec 48 bits, il manque 64 - 48 = 16 bits d'adresses, qui ne peuvent pas être utilisés pour adresser quoi que ce soit. La règle est que ces 16 bits doivent être tous égaux : soit ils valent tous 0, soient ils sont tous à 1. Les adresses qui respectent cette contrainte sont appelées des '''adresses canoniques'''. Le résultat est que l'espace d'adressage est coupé en trois sections, comme illustré ci-dessous. * Les '''adresses canoniques basses''' ont leurs 16 bits de poids fort à 0 et sont situées en bas de l'espace d'adressage, dans les premières adresses. * Les '''adresses canoniques hautes''' ont leurs 16 bits de poids fort à 1 et sont situées au sommet de l'espace d'adressage, dans les dernières adresses. * Entre les deux, se trouvent des '''adresses non-canoniques''', qui ne sont pas accessibles. Y accéder déclenche la levée d'une exception matérielle. {| |[[File:AMD64-canonical--48-bit.png|vignette|Espace d'adressage x86-64 bits, avec des adresses physiques de 48 bits.]] |[[File:AMD64-canonical--56-bit.png|vignette|Espace d'adressage x86-64 bits, avec des adresses physiques de 57 bits.]] |} : Les futurs systèmes x86 devraient passer à des adresses de 57 bits, les trois sections auront donc des frontières différentes. Les adresses non-canoniques sont censées être inutilisables. Mais les programmeurs aimeraient bien pouvoir les utiliser pour des ''pointeurs tagués'', à savoir des pointeurs/adresses associées à des informations. L'idée serait d'utiliser les 16 bits de poids fort pour stocker des informations liées au pointeur, seuls les 48 bits restant codant l'adresse. Les 16 bits peuvent être utilisés de manières très diverses. Un exemple est la technique du '''''memory tagging''''', qui crée une somme de contrôle au pointeur. La somme de contrôle est générée par un algorithme cryptographique, à partir de l'adresse du pointeur. Elle est vérifiée à chaque utilisation du pointeur. Si la somme de contrôle ne correspond pas au pointeur, une erreur est levée. Si jamais un virus ou un code malveillant modifie un pointeur, il ne saura pas comment calculer la somme de contrôle, la somme de contrôle a énormément de chances d'être incorrecte. Quelques fonctionnalités des processeurs visent à autoriser l'utilisation des adresses non-canoniques. L'idée est que les 16 bits de poids fort sont ignorées lors des accès mémoire, ce qui permet d'utiliser les 16 bits de poids fort à volonté. Il s'agit des techniques ''Top Byte Ignore'' d'ARM, ''Upper Address Ignore'' d'AMD, et ''Linear Address Masking'' d'Intel. ==La z/architecture d'IBM== IBM a sa propre gamme de ''mainframes'', qui utilisent des processeurs fait maison par IBM lui-même. Les CPU IBM ont leur propre jeu d'instruction CISC, appelé '''''z/architecture'''''. Et ce jeu d'instruction a des possibilités en termes d'adressage qui sont extrêmement intéressantes, en plus d'être assez inédites. Pour ce chapitre, nous allons nous concentrer sur une fonctionnalité : les programmes ont accès à plusieurs espaces d'adressage naturellement. L'avantage pour les programmes utilisateur n'est pas très évident, mais il l'est nettement plus pour les systèmes d'exploitation. Le système d'exploitation de ces ''mainframes'', le z/OS, profite de cette possibilité, idem pour l'hyperviseur z/VM. Il utilise cette possibilité pour donner un espace d'adressage séparé pour chaque programme en cours d'exécution. Les programmes sont alors isolés les uns des autres. Cette '''isolation des processus''' n'est pas spécifique à IBM : tous les systèmes d'exploitation modernes l'utilisent. Mais les processeurs x86 doivent se débrouiller seulement avec des techniques dites de mémoire virtuelle, alors que les ''mainframes'' IBM complètent la mémoire virtuelle avec des facilités en plus. Pour les programmes utilisateurs, un intérêt est que cela permet de partager des données entre plusieurs programmes. Ils ont un espace d'adressage rien qu'à eux, mais les données partagées sont placées dans un espace d'adressage commun, auquel les deux programmes ont accès. L'espace d'adressage partagé peu même être configuré de manière à ne contenir que des données, il est impossible d'exécuter du code depuis cet espace d'adressage. Ce qui empêche certains ''malwares'' de fonctionner. La ''z/architecture'' supporte 65536 espaces d'adressage simultanés. Les espaces d'adressage sont numérotés, ils ont chacun un '''''Address-Space Number''''' codé sur 16 bits. Nous utiliserons l’abréviation ASN dans ce qui suit. Par contre, un programme ne peut pas utiliser tout ces espaces d'adressages, il ne peut en utiliser que 1024 en même temps. De plus, ils ne sont accessibles que quand le processeur est dans un mode de fonctionnement bien précis. Un ^processeur ''z/architecture'' peut en effet fonctionner dans deux modes, qu'on va appeler "standard" et "étendu". ===Le mode standard de la ''z/architecture''=== En mode standard, un programme exécuté sur la ''z/architecture'' utilise deux espaces d'adressages : l''''espace d'adressage primaire''' où se situent le programme exécuté et les données importantes, un '''espace d'adressage secondaire''' utilisé pour des tâches/données annexes. : Un troisième espace d'adressage l''''espace d'adressage ''home''''', est réservé au système d'exploitation. Il a pour particularité de ne pas avoir d'ASN. Il n'est concrètement accesible qu'en mode superviseur, donc par le système d'exploitation. Nous n'en parlerons pas dans ce qui suit. L'ASN de ces deux espaces d'adressage est mémorisé dans les registres de contrôle du processeur. L'ASN de l'espace primaire est dans le registre de contrôle n°3, l'ASN de l'espace secondaire est dans le registre de contrôle n°4 (ils sont dans les 16 bits de poids faible, dans les deux cas). Les deux registres de contrôle sont initialisés au démarrage de l'application, par le système d'exploitation, ce qui fait que ce sont des espaces d'adressage par défaut. Le programme a donc accès à ces deux espaces d'adressage sans rien faire. Mais comment le processeur pour utiliser ces espaces d'adressages ? Par exemple, s'il veut faire une lecture, comment précise-t-il l'espace d'adressage courant ? La réponse est que le processeur peut être dans deux états : soit primaire, soit secondaire. Dans le premier état, toutes les lectures/écritures se font dans l'espace d'adressage primaire. Dans le second, elles se font toutes dans l'espace d'adressage secondaire. Il peut passer de l'un à l'autre en utilisant l'instruction ''Set Address Space Control''. Le processeur dispose aussi d'instructions ''MOVE TO PRIMARY'' et ''MOVE TO SECONDARY'' pour échanger des données dans les deux espaces d'adressage. ===Les registres d'accès de la ''z/architecture''=== Un programme peut donc passer de l'espace d'adressage primaire à secondaire (ou inversement) en utilisant l’instruction ''SET ADDRESS SPACE CONTROL''. Mais il peut en profiter pour passer dans un mode où il a accès à 1024 espaces d'adressage différents, en même temps. Le processeur n'a pas besoin d'utiliser d'instructions particulières pour copier des données d'un espace d'adressage à un autre, un simple MOV suffit. Le mode en question est appelé le '''mode ''access register'''''. Le mode ''access register'' donne accès aux '''registres d'accès''', qui lui donnent son nom. Les registres d'accès permettent de sélectionner un espace d'adressage, afin qu'une instruction puisse lire/écrire dedans. Il y a en tout 16 registres d'accès, chacun étant appairé avec un registre général. Par exemple, le registre général numéro 5 est appairé au registre d'accès de même numéro. En mode ''access register'', toute lecture/écriture lit à la fois un registre général et le registre d'accès associé. Le registre général contient un pointeur, l'adresse à lire/écrire, et le registre d'accès indique dans quel espace d'adressage se trouve cette adresse. Une implémentation simple numéroterait les espaces d'adressage et mettrait les numéros dans les registres d'accès. Les registres d'accès seraient idéalement protégés en écriture et seulement accessibles par le système d'exploitation. Mais ce n'est pas ce qui est fait. Les registres d'accès font 32 bits chacun, ce qui ne colle pas avec le fait que les ASNs sont codés sur 16 bits. La raison est que les registres d'accès ne mémorisent pas des ASNs, et on peut même aller plus loin : les ASNs ne sont pas utilisés pour adresser les espaces d'adressage. Un programme peut accéder à certains espaces d'adressage bien précis, que lui a attribué le système d'exploitation. Les espaces d'adressages d'un programme sont mémorisés dans une '''liste d'accès privée'''. La liste d'accès est en réalité non pas une liste chainée, mais un tableau. Il contient jusqu’à 1024 entrées, chacune contenant toutes les informations nécessaires pour utiliser un espace d'adressage. Une entrée contient notamment l'adresse mémoire à laquelle commence l'espace d'adressage, qui est utilisée dans divers calculs d'adresse. La liste d'accès privée est gérée par le système d'exploitation, lui seul peut la modifier. Je dis une liste d'accès privée, mais il y en a deux en réalité. Mais la distinction exacte demanderait de parler de ''threads'' et de processus. Pour ceux qui sont familiers avec ces concepts, chaque ''thread'' a sa propre liste privée, avec en plus une liste par processus partagée entre tous les ''threads''. Deux listes de 1024 entrées maximum, cela fait 2048 entrées au total. Les deux listes portent les noms de ''Dispatchable Unit Access List'' et de ''PASN-AL (Primary Address Space Access List''. Inutile de retenir ces deux termes, cependant. Un registre d'accès mémorise un '''''Access List Entry Token''''' ou ALET. Un ALET regroupe plusieurs informations. Déjà, il contient un bit qui indique quelle liste privée utiliser, laquelle des deux. Ensuite, il contient un indice qui sélectionne une entrée dans la liste privée choisie. L'ALET peut avoir plusieurs valeurs, mais deux d'entre elles sont pré-déterminées : 0 pointe vers l'espace d'adressage principal, 1 vers l'espace d'adressage secondaire. Les autres valeurs sont configurables. : Précisons que le registre d'accès numéro 0 contient obligatoirement la valeur 0, ce qui fait qu'il pointe vers l'espace d'adressage primaire. Lors d'une lecture/écriture, l'ALETs est utilisé pour sélectionner l'espace d'adressage. Les valeurs 0 et 1 sont traitées telles quelles, l'espace d'adressage est déterminé automatiquement par le processeur. Pour les autres valeurs, récupérer l'adresse mémoire demande de consulter la liste d'accès privée. L'ALET est envoyé à la liste d'accès privée, l'adresse est récupérée. Le processeur connait l'adresse de la liste privée, car son adresse mémoire est mémorisée dans les registres de contrôle numéro 2 et 5. Une subtilité est que le système d'exploitation peut à tout moment modifier le contenu de la liste privée. Il se peut qu'un ALET qui correspondait à un espace d'adressage se mette à pointer vers un autre. Pour détecter cette situation, une entrée de la liste privée contient un identifiant, qui est modifié à chaque fois que le système d'exploitation modifie l'entrée. L'ALET mémorise aussi cet identifiant, qui est obtenu lors de la création de l'ALET. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs 8 bits et moins | prevText=Les processeurs 8 bits et moins | next=L'abstraction mémoire et la mémoire virtuelle | nextText=L'abstraction mémoire et la mémoire virtuelle }} </noinclude> cug7mggo16clwfinr46vafbgklnsfh2 774381 774379 2026-10-07T18:02:12Z Mewtow 31375 /* La z/architecture d'IBM */ Déplacement chapitre plus adapté. 774381 wikitext text/x-wiki L''''espace d'adressage''' du processeur est l'ensemble des adresses utilisables par le processeur. Par exemple, un processeur 16 bits peut adresser 2^16 = 65536 adresses, l'ensemble de ces adresses forme son espace d'adressage. L'espace d'adressage n'est pas toujours égal à la mémoire réellement installée. S'il n'y a pas assez de RAM installée, des adresses seront inoccupées. De plus, une partie de l'espace d'adressage peut être détourné pour communiquer avec les périphériques, comme nous le verrons plus bas. Nous verrons aussi qu'un processeur peut avoir plusieurs espaces d'adressages séparés. ==L'adressage de la RAM et de la ROM== Avoir plusieurs espaces d'adressage spécialisés est quelque chose que nous avons déjà rencontré dans les chapitres précédents, mais sans le dire ouvertement. Aussi, nous allons faire quelques rappels sur les cas déjà rencontrés. En premier lieu, nous allons rappeler la différence entre architectures Von Neumann et Hardvard. La différence entre les deux tient dans l'adressage des mémoires RAM et ROM : est-ce qu'elles sont dans un seul espace d'adressage, ou dans des espaces d'adressage séparés. L''''architecture Von Neumann''' a un seul espace d'adressage, découpé entre la mémoire RAM d'un côté et la mémoire ROM de l'autre. Une adresse correspond soit à la mémoire RAM, soit à la mémoire ROM, mais pas aux deux. Typiquement, la mémoire ROM est placée dans les adresses hautes, les plus élevées, alors que la RAM est placée dans les adresses basses en commençant par l'adresse 0. C'est une convention qui n'est pas toujours respectée, aussi mieux vaut éviter de la tenir pour acquise. [[File:Vision de la mémoire par un processeur sur une architecture Von Neumann.png|centre|vignette|upright=2|Vision de la mémoire par un processeur sur une architecture Von Neumann.]] L''''architecture Harvard''' utilise des espaces d'adressage séparés pour la RAM et la ROM. Une même adresse peut correspondre soit à la ROM, soit à la RAM. Le processeur voit bien deux mémoires séparées, chacune dans son propre espace d'adressage. Les deux espaces d'adressage n'ont pas forcément la même taille. Il est possible d'avoir un plus gros espace d'adressage pour la RAM que pour la ROM. Cela implique que les adresses des instructions et des données soient de taille différentes. C'est peu pratique et c'est rarement implémenté, ce qui fait que le cas le plus courant est celui où les deux espaces d'adressages ont la même taille. [[File:Vision de la mémoire par un processeur sur une architecture Harvard.png|centre|vignette|upright=2|Vision de la mémoire par un processeur sur une architecture Harvard.]] Sur une '''architecture Harvard modifiée''', le programme est placé dans un espace d'adressage à part du reste. Le problème, c'est que cet espace d'adressage ne contient pas que le code machine à exécuter. Il contient aussi des constantes, à savoir des données qui gardent la même valeur lors de l'exécution du programme. Elles peuvent être lues, mais pas modifiées durant l'exécution du programme. L'accès à ces constantes demande d'aller lire celles-ci dans l'autre espace d'adressage, pour les copier dans les registres généraux. Le processeur doit pour cela avoir des instructions de lecture dédiées. En clair, il y a une instruction LOAD pour lire dans la mémoire ROM, une autre pour lire dans la RAM. [[File:Espaces d'adressage sur une archi harvard modifiée.png|centre|vignette|upright=3|Espaces d'adressage sur une archi harvard modifiée]] Une autre solution utilise des instructions de copie, qui copient les constantes en mémoire RAM, donc d'un espace d'adressage à l'autre. [[File:Organisation des espaces d'adressage sur une archi harvard modifiée.png|centre|vignette|upright=3|Organisation des espaces d'adressage sur une archi harvard modifiée]] ==L'adressage des périphériques== Passons maintenant à l'adressage des périphériques. La communication avec les périphériques se fait par l'intermédiaire de registres d’interfaçage. Et ces registres peuvent soit avoir un espace d'adressage séparé, soit être inclus dans l'espace d'adressage des mémoires. Dans ce qui suit, nous allons supposer que l'architecture des de type Von Neumann pour simplifier les explications. ===Les entrées-sorties mappées en mémoire=== Une partie de l'espace d'adressage peut être détourné pour communiquer avec les périphériques. Le périphérique se retrouve inclus dans l'ensemble des adresses utilisées pour manipuler la mémoire : on dit qu'il est mappé en mémoire. Les adresses mémoires associées à un périphérique sont redirigées automatiquement vers les registres du périphérique en question, voire vers sa mémoire intégrée. On parle alors d''''entrées-sorties mappées en mémoire'''. [[File:IO mappées en mémoire.png|centre|vignette|upright=2.0|IO mappées en mémoire]] On remarque un défaut inhérent à cette technique : les adresses utilisées pour les périphériques ne sont plus disponibles pour la RAM. On ne peut plus adresser autant de RAM qu'avant. La perte dépend des périphériques installés et de leur gourmandise en adresses mémoires. C'est ce qui causait un problème assez connu sur les ordinateurs 32 bits, capables d'adresser 4 gibioctets de mémoire. Certaines personnes installaient 4 gigaoctets de mémoire sur leur ordinateur 32 bits et se retrouvaient avec « seulement » 3,5 à 3,8 gigaoctets de mémoire, les périphériques prenant le reste. Il est possible que la RAM d'un périphérique soit mappée en RAM. Un exemple classique est celui des cartes graphiques qui incorporent une mémoire RAM appelée la mémoire vidéo. La mémoire vidéo est mappée en mémoire, ce qui permet au processeur d'écrire directement dedans. Toute lecture ou écriture dans les adresses associées est redirigée vers le bus PCI/AGP/PCI-Express. Nous verrons plus bas des exemples d'ordinateurs où la mémoire vidéo est mappée en mémoire, que ce soit totalement ou partiellement. Si je dis totalement ou partiellement, c'est parce que les cartes graphiques modernes disposent de tellement de mémoire qu'on ne peut pas la mapper totalement dans l'espace d'adressage. Sur les systèmes d'avant 2008, seuls 256 mégaoctets de mémoire vidéo sont mappés en mémoire RAM. Le reste de la mémoire vidéo est invisible du point de vue du processeur, mais manipulable par le GPU à sa guise. Après 2008, la spécification du PCI-Express ajouta un support de la technologie ''Resizable Bar'', qui permet au processeur d’accéder directement à plus de 256 mégaoctets de mémoire vidéo, voire à la totalité de la mémoire vidéo. ===L'espace d'adressage séparé pour les entrées-sorties=== Les entrées-sorties et périphériques peuvent avoir leur propre espace d'adressage dédié, séparé de celui utilisé pour les mémoires RAM et ROM. [[File:Espaces_d'adressages_séparés_entre_mémoire_et_périphérique.png|centre|vignette|upright=3|Bit IO.]] Une même adresse peut donc adresser soit une entrée-sortie, soit une case mémoire. Et pour faire la différence, le processeur doit avoir des instructions séparées pour adresser les périphériques et la mémoire. Il a des instructions de lecture/écriture pour lire/écrire en mémoire, et d'autres pour lire/écrire les registres d’interfaçage. Sans cela, le processeur ne saurait pas si une adresse est destinée à un périphérique ou à la mémoire. Cela élimine aussi les problèmes avec les caches : les accès à l'espace d'adressage de la RAM passent par l'intermédiaire de la mémoire cache, alors les accès dans l'espace d'adressage des périphériques le contournent totalement. Là encore, les deux espaces d'adressage n'ont pas forcément la même taille. Il arrive que les deux espaces d'adressage aient la même taille, le plus souvent sur des ordinateurs complexes avec beaucoup de périphériques. Mais les systèmes embarqués ont souvent des espaces d'adressage plus petits pour les périphériques que pour la ou les mémoires. L'implémentation varie grandement suivant le cas, la première méthode imposant d'avoir deux bus séparés pour les mémoires et les périphériques, l'autre permettant un certain partage du bus d'adresse. Nous reviendrons dessus plus en détail dans le chapitre sur l'adressage des périphériques. ==La ''memory map'' d'un ordinateur== Les deux sections précédentes nous ont appris que l'on peut utiliser un espace d'adressage séparé pour la ROM et un autre pour les périphériques. En tout, cela donne quatre possibilités distinctes. {|class="wikitable" |- ! ! IO mappées en mémoire ! IO séparées |- ! ROM mappée en mémoire (architecture Von Neumann) | Un seul espace d'adressage | Deux espaces d'adressage : * un pour les mémoires RAM/ROM ; * un pour les IO. |- ! ROM séparée (architecture Harvard) | Deux espaces d'adressage : * un pour la RAM et les IO ; * un pour la ROM. | Trois espaces d'adressages : * un pour la RAM ; * un pour la ROM ; * un pour les IO. |} Les quatre solutions ont des avantages et inconvénients divers, mais il est intéressant de contraster un espace d'adressage unique avec plusieurs espaces d'adressages. ===L'espace d'adressage unique=== Avec un espace d'adressage unique, la ROM est au sommet de l'espace d'adressage, les périphériques sont juste en-dessous, la RAM commence à l'adresse 0 et prend les adresses basses. Faire simplifie grandement l'implémentation matérielle de l'adressage. Notons que d'autres composants que les périphériques ou les mémoires peuvent se trouver dans l'espace d'adressage. On peut y trouver les horloges temps réels, des timers, des senseurs de température, ou d'autres composants placés sur la carte mère. Un exemple un peu original est le suivant : la console de jeu Nintendo DS incorporait une unité de calcul spécialisée dans les divisions et racines carrées, séparée du processeur, qui était justement mappée en mémoire ! [[File:Espace d'adressage classique avec entrées-sorties mappées en mémoire.png|centre|vignette|upright=2|Espace d'adressage classique avec entrées-sorties mappées en mémoire]] L'espace d'adressage unique est plus simple pour les programmeurs, malgré un désavantage dont il faut parler. Le problème est que des adresses censées être disponibles pour la RAM sont détournées vers la ROM ou les périphériques, ce qui limite la quantité de RAM qui peut être réellement adressée en pratique. C'est un défaut qui se manifeste seulement pour les petits espaces d'adressages, de 8 à 16 bits, qui ne permettent pas d'adresser beaucoup de RAM. Avec des adresses codées sur 16 bits, un espace d'adressage de 64 kibioctets de RAM est grignoté par des entrées-sorties assez rapidement. Par contre, pour des adresses de 32 à 64 bits, une bonne partie de l'espace d'adressage est inutilisé, car il n'y a pas assez de RAM. L'espace d'adressage unique n'a donc pas de désavantages. Les vielles consoles de jeux et certains vieux ordinateurs (Commodores et Amiga), utilisaient un espace d'adressage unique. Elles n'avaient pas une variété de cartes graphiques ou de cartes sons différentes, comme sur les PCs modernes. Au contraire, elles avaient la même configuration matérielle, le matériel ne pouvait pas être changé ni upgradé. Le système d'exploitation était rudimentaire et ne contrôlait pas vraiment l'accès au matériel. Les programmeurs avaient donc totalement accès au matériel et mapper les entrées/sorties en mémoire rendait la programmation des périphériques très simple. <gallery widths=200px heights=500px> N5200mk2 memory map.svg|Adressage mémoire (carte mémoire) du N5200mk2. PC-9801VM memory map.svg|Adressage mémoire (carte mémoire) du PC-9801VM. </gallery> ===La commutation de banques (''bank switching'')=== Le '''''bank switching''''', aussi appelé '''commutation de banque''', permet d'utiliser plusieurs espaces d'adressage sur un même processeur, sans attribuer chaque espace d'adressage pour une raison précise. L'espace d'adressage est présent en plusieurs exemplaires appelés des '''banques'''. Les banques sont numérotées, chaque numéro de banque permettant de l'identifier et de le sélectionner. Le but de cette technique est d'augmenter la mémoire disponible pour l'ordinateur. Par exemple, supposons que j'ai besoin d'adresser une mémoire ROM de 4 kibioctets, une RAM de 8 kibioctets, et divers périphériques. Le processeur a un bus d'adresse de 12 bits, ce qui limite l'espace d'adressage à 4 kibioctets. Dans ce cas, je peux réserver 4 banques : une pour la ROM, une pour les périphériques, et deux banques qui contiennent chacune la moitié de la RAM. La simplicité et l'efficacité de cette technique font qu'elle est beaucoup utilisée dans l'informatique embarquée. [[File:PC-8801MemoryMap.gif|centre|vignette|upright=2|exemple de Bank switching.]] Cette technique demande d'utiliser un bus d'adresse plus grand que les adresses du processeur. L'adresse réelle se calcule en concaténant le numéro de banque avec l'adresse accédée. Le numéro de la banque actuellement en cours d'utilisation est mémorisé dans un registre appelé le '''registre de banque'''. On peut changer de banque en changeant le contenu de ce registre. Le processeur dispose souvent d'instructions spécialisées qui en sont capables. {| class="wikitable flexible" |[[File:Banque mémoire.png|Banque mémoire.]] |[[File:Registre de banque.png|Registre de banque.]] |} ==Le placement des programmes dans l'espace d'adressage== Les ordinateurs modernes sont des architectures Von Neumann, avec un seul espace d'adressage, qui est partagé suivant les besoins. Et un point important est que le système d'exploitation et les programmes sont copiés en mémoire RAM avant d'être exécutés. Ils ne sont pas exécutés depuis une mémoire ROM, cela est réservé aux systèmes embarqués simples. Et cela a une influence sur l'espace d'adressage. Les programmes et le système d'exploitation doivent se partager l'espace d'adressage. Voyons voir comment ce partage se fait. Dans ce chapitre, nous allons volontairement mettre de côté les ordinateurs qui supportent la mémoire virtuelle. Nous en parlerons dans le prochain chapitre. Le principe est de voir comment un processeur avec un seul espace d'adressage gèrent la présence d'un ou de plusieurs programmes. ===Un seul espace d'adressage, non-partagé=== Le cas le plus simple est celui où il n'y a pas de système d'exploitation et où un seul programme s'exécute sur l'ordinateur. Le programme a alors accès à tout l'espace d'adressage, sauf pour ce qui est réservé aux périphériques et à la mémoire ROM. Le programme organise la mémoire en plusieurs sections, dans lesquelles le programme range des données différentes. Typiquement, on trouve quatre blocs de mémoire, appelés des segments, chacun étant spécialisé pour les données, le code, la pile, etc. Voici ces trois sections : * Le segment '''''text''''' 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. * Le segment pour la '''pile''', de taille variable. * le reste est appelé le '''tas''', de taille variable. [[File:Organisation d'un espace d'adressage unique utilisé par un programme unique.png|centre|vignette|upright=2|Organisation d'un espace d'adressage unique utilisé par un programme unique]] [[File:Typical computer data memory arrangement.png|vignette|upright=0.5|Typical computer data memory arrangement]] La pile et le tas sont de taille variable, ce qui veut dire qu'ils peuvent grandir ou diminuer à volonté, contrairement au reste. Entre le tas et la pile, on trouve un espace de mémoire inutilisée, qui peut être réquisitionné selon les besoins. La pile commence généralement à l'adresse la plus haute et grandit en descendant, alors que le tas grandit en remontant vers les adresses hautes. Il s'agit là d'une convention, rien de plus. Il est possible d'inverser la pile et le tas sans problème, c'est juste que cette organisation est rentrée dans les usages. Avant de poursuivre, précisons qu'il est possible de regrouper plusieurs programmes distincts dans un seul, afin qu'ils partagent le même morceau de mémoire. Les programmes portent alors le nom de '''threads'''. Les ''threads'' d'un même processus partagent le même espace d'adressage. Ils partagent généralement certains segments : ils se partagent le code, le tas et les données statiques. Par contre, chaque thread dispose de sa propre pile d'appel. [[File:Single vs multithreaded processes.jpg|centre|vignette|upright=2.0|Distinction entre processus mono et multi-thread.]] Il va de soi que cette vision de l'espace d'adressage ne tient pas compte des périphériques. C'est-à-dire que les schémas précédents partent du principe qu'on a un espace d'adressage séparé pour les périphériques. Dans le cas où les entrées-sorties sont mappées en mémoire, l'organisation est plus compliquée. Généralement, les adresses associées aux périphériques sont placées juste au-dessus de la pile, dans les adresses hautes. ===Un seul espace d'adressage, partagé avec le système d'exploitation=== [[File:Gestion de la mémoire sur les OS monoprogrammés.png|vignette|upright=1.0|Gestion de la mémoire sur les OS monoprogrammés.]] Maintenant, étudions le cas où le programme partage la mémoire avec le système d'exploitation. Sur les systèmes d'exploitation les plus simples, on ne peut lancer qu'un seul programme à la fois. Le système d'exploitation réserve une portion de taille fixe réservée au système d'exploitation, alors que le reste de la mémoire est utilisé pour le programme à exécuter. Le programme est placé à un endroit en RAM qui est toujours le même. Le système d'exploitation peut être soit placé dans une ROM, soit copié en mémoire RAM depuis une mémoire de masse, comme le programme à lancer. Si le système d'exploitation est copié en mémoire RAM, il est généralement placé dans les premières adresses, les adresses basses. A l'inverse, un OS en mémoire ROM est généralement placé à la fin de la mémoire, dans les adresses hautes. Mais tout cela n'est qu'une convention, et les exceptions sont monnaie courante. [[File:Méthodes d'allocation de la mémoire avec un espace d'adressage unique.png|centre|vignette|upright=2.0|Méthodes d'allocation de la mémoire avec un espace d'adressage unique]] Sur de tels systèmes, il faut protéger l'OS contre une erreur ou malveillance d'un programme utilisateur. Notons qu'il s'agit d'une protection en écriture, pas en lecture. Le programme peut parfaitement lire les données de l'OS sans problèmes, au moins en partie. Dans ce qui suit, on part du principe que l'OS est dans les adresses basses. La solution la plus courante interdit au programme d'écrire au-delà d'une limite au-delà de laquelle se trouve le système d’exploitation. Pour cela, le processeur incorpore un '''registre limite''', qui contient l'adresse limite au-delà de laquelle un programme peut pas écrire. Pour toute écriture en espace utilisateur, l'adresse écrite est comparée au registre limite. Si l'adresse est inférieure au registre limite, le programme cherche écrire dans la mémoire réservée à l'OS. L’accès mémoire est interdit, une exception matérielle est levée et l'OS affiche un message d'erreur. [[File:Circuit total.png|centre|vignette|upright=2.0|Protection mémoire avec un registre limite.]] ===Un seul espace d'adressage, partagé entre plusieurs programmes=== Les systèmes d’exploitation modernes implémentent la '''multiprogrammation''', le fait de pouvoir lancer 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. Et il faut alors résoudre un problème : le partage de la RAM entre les différents programmes. Les différents programmes et leurs données sont placés en mémoire RAM, et il faut trouver un moyen pour répartir les différents programmes en RAM. [[File:Partitions mémoire.png|vignette|upright=1|Partitions mémoire]] Il y a plusieurs solutions pour cela, mais l'une d'entre elle est l'usage de segments mémoire. Avec elle, chaque programme se voit attribuer un ou plusieurs blocs de mémoire. Ils sont appelés des '''partitions mémoire''', ou encore des '''segments'''. Dans ce qui va suivre, nous partons du principe qu'un programme est égal à un segment, pour simplifier les explications. Mais sachez qu'un programme peut être éclaté en plusieurs segments dispersés dans la mémoire, et même être conçu pour ! Nous en reparlerons dans le chapitre sur le mémoire virtuelle. Le système d'exploitation mémorise une liste des segments importants en mémoire RAM, appelée la ''table des segments''. Il s'agit d'un tableau dont chaque entrée mémorise les informations pour un segment. Une entrée de ce tableau est appelée un '''descripteur de segment''' et contient son adresse de base, sa taille, et des autorisations liées à la protection mémoire. Précisément, l'entrée numéro N mémorise les informations du segment numéro N. A partir de l'adresse de base de la table des segments et du numéro de segment, on peut lire ou écrire un descripteur de segment. [[File:Global Descriptor table.png|centre|vignette|upright=2|Global Descriptor table]] Toutefois, l'usage de segments amène un paquet de problèmes qu'il faut résoudre au mieux. Le premier problème est tout simplement de placer les segments dans l'espace d'adressage, mais c'est quelque chose qui est du ressort du système d'exploitation. Par contre, cela implique qu'un segment peut être placé n'importe où en RAM et sa position en RAM change à chaque exécution. En conséquence, les adresses des branchements et des données ne sont jamais les mêmes d'une exécution à l'autre. Pour résoudre ce problème, le compilateur considère que le segment commence à l'adresse zéro, puis les adresses sont corrigées avant ou pendant l'exécution du programme. Cette correction est en général réalisée par l'OS lors de la copie du programme en mémoire, mais il existe aussi des techniques matérielles, que nous verrons dans le chapitre suivant. Il faut aussi prendre en compte le phénomène de l''''allocation mémoire'''. Derrière ce nom barbare, se cache quelque chose de simple : les programmes peuvent réclamer de la mémoire au système d'exploitation, pour y placer des données. Les langages de programmation bas niveau supportent des fonctions comme malloc(), qui permettent de demander un bloc de mémoire de N octets à l'OS, qui doit alors accommoder la demande. S'il n'y a pas assez de mémoire, l'appel échoue et le programme doit gérer la situation. De même, un programme peut libérer de la mémoire qu'il n'utilise plus avec des fonctions comme free(). Avec des segments, cela revient à changer la taille d'un segment, plus précisément à la fin du segment. Et tout cela est géré par le système d'exploitation, aussi on laisse cela pour le prochain chapitre. Enfin, sans protection particulière, les programmes peuvent techniquement lire ou écrire les données des autres. Pour éviter cela, il suffit de vérifier qu'un programme accède bien à son segment et pas ailleurs. Il suffit de vérifier si l'adresse lue/écrite est dans le bon intervalle. Il suffit alors d'utiliser deux registres limites, rien de plus. Mais au-delà de cette simple vérification, si un programme pouvait modifier les données d'un autre programme, on se retrouverait rapidement avec une situation non prévue par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses. Il faut donc introduire des mécanismes de protection mémoire, pour isoler les programmes les uns des autres, et éviter toute modification problématique. La protection mémoire regroupe plusieurs techniques assez variées, qui ont des objectifs différents. Elles ont pour point commun de faire intervenir à des niveaux divers le système d'exploitation et le processeur. ==La ''memory map'' des PC IBM x86== Dans ce qui suit, nous allons étudier l'espace d'adressage des processeurs x86. L'organisation de leur espace d'adressage a évolué dans le temps et l'espace d'adressage s'est adapté. Mais il s'est adapté sans modifier l'existant, pour des raisons de compatibilité. Aussi, les processeurs x86 modernes peuvent fonctionner dans plusieurs modes, appelés mode réel, protégé, virtuel 8086, long, ''system management mode'', et compatibilité 32 bits. [[File:AMD64StateDiagram.svg|centre|vignette|upright=2|AMD64StateDiagram]] Le ''system management mode'' a déjà été abordé dans le chapitre sur les interruptions. Pour simplifier, il ne s'agit pas d'un véritable mode au sens "adressage". Dans ce mode, le système d'exploitation est mis en pause et le BIOS ou un autre firmware s'exécute sans restriction. On y rentre grâce à une interruption dédiée, lancée par le noyau de l'OS. Le système d'exploitation exécute cette interruption afin de laisser la main au BIOS, pour lui déléguer certaines tâches. Par contre, les autres modes sont bien plus intéressant, car ils ont chacun un espace d'adressage différent. Voyons-les dans le détail. ===Le mode réel : l'adressage sur 20 bits=== Le tout premier mode était le '''mode réel''', qui utilisait des adresses de 20 bits, ce qui fait 1 mébioctet de mémoire adressable. Il était utilisé sur des processeurs 16 bits, qui géraient donc des adresses de 16 bits capables d'adresser 64 kibioctets de mémoire. Pour contourner cette limitation, le processeur utilisait la segmentation, que nous verrons dans le prochain chapitre. Pour le moment, nous allons dire que la mémoire en mode réel était composée de 16 blocs de 64 kibioctets. C'est une simplification qui ne fera pas de mal. Le processeur démarre systématiquement en mode réel, y compris sur les ordinateurs modernes. Le BIOS s'exécute dans ce mode, avant de passer en mode protégé et de laisser la main à l'UEFI et au système d'exploitation. Autrefois, le système d'exploitation s'exécutait aussi en mode réel, c'est le cas du DOS. En mode réel, l'espace d'adressage est peuplé par de la RAM, la ROM du BIOS et des périphériques. La RAM prenait les adresses basses, la ROM était au sommet de l'espace d'adressage, et les périphériques entre les deux. Les premiers 640 kibioctets sont réservés à la RAM et sont appelés la '''mémoire conventionnelle'''. Les octets restants forment la '''mémoire haute''', réservée aux ROM et aux périphériques. Les deux premiers kibioctets de la mémoire conventionnelle sont initialisés au démarrage de l'ordinateur. Ils sont utilisés pour stocker le vecteur d'interruption (on expliquera cela dans quelques chapitres) et servent aussi au BIOS. La portion réservée au BIOS, la ''BIOS Data Area'', mémorise des informations en RAM. Elle commence à l'adresse 0040:0000h, a une taille de 255 octets, et est initialisée lors du démarrage de l'ordinateur. Le reste de la mémoire conventionnelle est réservée au le système d'exploitation (MS-DOS, avant sa version 5.0) et au programme en cours d’exécution. La mémoire haute est sectionnée en deux portions. Le sommet de la mémoire haute est réservé au BIOS, alors que le bas de la mémoire haute est réservé aux périphériques. Dans cette dernière, on trouve les BIOS des périphériques (dont celui de la carte vidéo, s'il existe), qui sont nécessaires pour les initialiser et pour communiquer avec eux. De plus, on y trouve la mémoire de la carte vidéo et éventuellement la mémoire d'autres périphériques comme la carte son. {| class="wikitable" style="text-align:center;" |+ Espace d'adressage en mode réel |- ! Numéro du bloc !! Contenu du bloc |- | 1024 - 960 Kio || class="f_jaune" | BIOS principal (ROM) |- | 960 Kio - 640 Kio | class="f_vert" | ROM d'extension des périphériques (XT, EGA, 3270 PC) </br> Mémoire vidéo des cartes EGA, MDA ou CGA |- | 640 Kio - 0 || class="f_rouge" | Mémoire RAM, mémoire conventionnelle |} Pour détailler un peu plus, il faut tenir compte du découpage de l'espace d'adressage en blocs de 64 kibioctets. Le découpage en blocs de 64 kibioctets est particulièrement important pour la mémoire haute. Des blocs sont attribués à un périphérique spécifique. Par exemple, le 16ème bloc est réservé au BIOS, le 12ème aux ROM des périphériques, le 10ème et le 11ème à la mémoire vidéo, etc. {| class="wikitable" style="text-align:center;" |+ Espace d'adressage en mode réel |- ! Numéro du bloc !! Contenu du bloc |- | 0 || rowspan="10" class="f_rouge" | Mémoire RAM, mémoire conventionnelle |- | 1 |- | 2 |- | 3 |- | 4 |- | 5 |- | 6 |- | 7 |- | 8 |- | 9 |- | 10 || class="f_vert" | Mémoire vidéo des cartes EGA |- | 11 || class="f_vert" | Mémoire vidéo des cartes MDA ou CGA |- | 12 || class="f_vert" | ROM d'extension des périphériques (XT, EGA, 3270 PC) |- | 13 || class="f_gris" | Autre, non-réservé |- | 14 || class="f_gris" | Autre, non-réservé |- | 15 || class="f_jaune" | ROM du BIOS |} Vous remarquerez que les blocs 13 et 14 sont non-réservés. Ils peuvent être utilisés pour mapper des périphériques en mémoire. Mais si aucun périphérique n'est mappé dedans, les blocs sont laissés libres. Les OS n'hésitaient pas à les utiliser pour adresser de la RAM. Par exemple, si l'ordinateur n'a pas de carte vidéo EGA, le bloc qui leur était réservé était réutilisé comme mémoire RAM. La mémoire conventionnelle passait alors de 640 à 704 kibioctets de RAM disponibles. L'espace d'adressage correspond à la ROM du BIOS. Mais avec le temps, la ROM est devenue plus lente au point qu'exécuter du code directement depuis la ROM du BIOS était un problème niveau performance. Je rappelle que le BIOS contient de nombreuses routines d'interruptions qui étaient appelées par les systèmes d'exploitation de l'époque, notamment le DOS. Heureusement, diverses optimisations permettaient de copier le BIOS dans la mémoire RAM, plus rapide que la ROM du BIOS. Il s'agit des techniques de '''''BIOS Shadowing'''''. Elles s'appliquaient au BIOS proprement dit, mais le BIOS vidéo pouvait aussi être optimisé ainsi, mais les ROM d'extension ne le sont généralement pas. Le ''BIOS Shadowing'' peut s'implémenter de plusieurs manières, mais la plus courante mappait temporairement de la RAM dans le même espace d'adressage que la ROM du BIOS. Le code du BIOS copiait le BIOS avec une boucle, dont chaque itération lisait une adresse du BIOS et écrivait dans cette même adresse. Juste avant la boucle, le ''chipset'' était configuré par le BIOS pour que les lectures atterrissent dans la ROM du BIOS, mais que les écritures tombaient dans une mémoire RAM mappée. Une fois la copie/boucle terminée, l'adressage tombait dans la RAM aussi bien pour les lectures et écritures. Elle demandait un support du ''chipset'' et du logiciel, pour détourner les adresses temporairement. ===Interlude : les technologies d'expanded memory=== Déjà à l'époque, 1 mébioctet était une limite assez faible, même si cela n'a pas empêché à un grand dirigeant de l'industrie informatique de dire que "640K ought to be enough for anybody". Aussi, diverses solutions matérielles ont vu le jour. Elles étaient peu utilisées et ont concrètement servi à des applications spécifiques. La principale est l''''''expanded memory''''' (EMS), une solution utilisant une carte d'extension avec plusieurs modules de RAM installés dessus. Les modules étaient accédés via la technique de la commutation de banque. [[File:Expanded memory.svg|centre|vignette|upright=2|L'espace d'adressage avec ''Expanded memory''. La carte d'extension et le matériel requis ne sont pas représentés.]] Le terme ''expanded memory'' regroupe plusieurs technologies propriétaires semblables sur le principe mais différentes dans l'exécution. Les premières utilisaient des banques de 64 kibioctets, qui étaient mappées dans un bloc de 64 kibioctets libre en mémoire haute. L'usage de banques de 64 kibioctets collait bien avec l'adressage particulier du mode réel, où la mémoire était gérée par blocs de 64 kibioctets. Les technologies ultérieures utilisaient 2 à 4 pages de 16 kibioctets, qui pouvaient être placée n'importe où en mémoire conventionnelle. [[File:EmulexPersyst 4M ISA.jpeg|centre|vignette|upright=2|Carte d'extension ''Expanded memory'' de marque Emulex Persyst, de 4 mébioctets.]] L'''expanded memory'' demandait que les programmes soient codés pour utiliser cette fonctionnalité. Pour cela, ils devaient utiliser une technique de programmation nommée ''Overlay programming''. L'idée est de découper le programme en plusieurs blocs séparés, appelés des '''''overlays'''''. Certains blocs sont en permanence en RAM, mais d'autres sont soit chargés en RAM, soit stockés ailleurs. Le ailleurs est souvent le disque dur, ou dans la RAM ''expanded memory'' si elle est disponible. Le va-et-vient des ''overlays'' entre RAM et disque dur était réalisé en logiciel, par le programme lui-même. [[File:Overlay Programming.svg|centre|vignette|upright=1.5|''Overlay Programming''.]] L'utilisation de l'''expanded memory'' utilisait l'interruption logicielle 67h, une interruption peu utilisée à l'époque, qui était libre. La carte d'extension était utilisée à travers un pilote de périphérique dédié, qui était appelé via l'interruption 67h. ===Le mode protégé : l'adressage sur 24 et 32 bits=== Par la suite, le mode réel a été remplacé par le '''mode protégé'''. Il utilise la mémoire virtuelle, ce qui fait qu'on devrait le détailler dans le chapitre suivant. Il permettait aussi de dépasser la limite du mébioctet de mémoire. L'espace d'adressage en mode protégé est passé de 24 bits sur le CPU 286, à 32 bits sur le 386 et les CPU suivants. Les programmes conçus pour le mode réel ne pouvaient pas s'exécuter en mode protégé. Pour corriger cela, le 286 a introduit le '''mode virtuel 8086''', aussi appelé mode V86. Il s'agit d'une technique de virtualisation, ce qui fait qu'on en parlera en détail dans le chapitre sur la virtualisation assistée en matériel. Tout ce que l'on peut dire ici est que le mode V86 a été détourné pour que les applications DOS utilisent la mémoire au-delà du mébioctet, appelée la '''mémoire étendue'''. Les logiciels DOS accédaient à la mémoire étendue à travers un driver DOS dédié (HIMEM.SYS). Les logiciels DOS déplaçaient des données dans la mémoire étendue, puis les rapatriaient en mémoire conventionnelle quand ils en avaient besoin. Il ne faut pas confondre mémoire étendue et ''expanded memory''. La technique de ''expanded memory'' repose sur de la commutation de banque, demande une carte d'extension dédiée, fonctionne sans mode V86. A l'inverse, la mémoire étendue ne gère pas de commutation de banque, ne demande pas de carte d'extension, mais utilise le mode V86. Par contre, il est possible d'émuler l'''expanded memory'' sans carte d'extension, avec la mémoire étendue. Quelques cartes mères intégraient des techniques pour, mais une émulation logicielle était aussi possible en réécrivant l'interruption 67h. ===Le mode long : l'adressage 64 bits=== Par la suite, les processeurs ont introduit le '''mode long''', dans lequel les adresses font 64 bits. Le CPU en mode long a aussi un jeu d'instruction totalement différent, des registres en plus et d'autres fonctionnalités actives uniquement dans ce mode. Les programmes compilés pour le 64 bits s'exécutent nativement dans ce mode, alors que les programmes 32 et 16 bits s'exécutent dans un mode de compatibilité dédié. Sur les systèmes 64 bits, le bus d'adresse fait seulement 48 bits afin d'économiser des interconnexions. Avec 48 bits, il manque 64 - 48 = 16 bits d'adresses, qui ne peuvent pas être utilisés pour adresser quoi que ce soit. La règle est que ces 16 bits doivent être tous égaux : soit ils valent tous 0, soient ils sont tous à 1. Les adresses qui respectent cette contrainte sont appelées des '''adresses canoniques'''. Le résultat est que l'espace d'adressage est coupé en trois sections, comme illustré ci-dessous. * Les '''adresses canoniques basses''' ont leurs 16 bits de poids fort à 0 et sont situées en bas de l'espace d'adressage, dans les premières adresses. * Les '''adresses canoniques hautes''' ont leurs 16 bits de poids fort à 1 et sont situées au sommet de l'espace d'adressage, dans les dernières adresses. * Entre les deux, se trouvent des '''adresses non-canoniques''', qui ne sont pas accessibles. Y accéder déclenche la levée d'une exception matérielle. {| |[[File:AMD64-canonical--48-bit.png|vignette|Espace d'adressage x86-64 bits, avec des adresses physiques de 48 bits.]] |[[File:AMD64-canonical--56-bit.png|vignette|Espace d'adressage x86-64 bits, avec des adresses physiques de 57 bits.]] |} : Les futurs systèmes x86 devraient passer à des adresses de 57 bits, les trois sections auront donc des frontières différentes. Les adresses non-canoniques sont censées être inutilisables. Mais les programmeurs aimeraient bien pouvoir les utiliser pour des ''pointeurs tagués'', à savoir des pointeurs/adresses associées à des informations. L'idée serait d'utiliser les 16 bits de poids fort pour stocker des informations liées au pointeur, seuls les 48 bits restant codant l'adresse. Les 16 bits peuvent être utilisés de manières très diverses. Un exemple est la technique du '''''memory tagging''''', qui crée une somme de contrôle au pointeur. La somme de contrôle est générée par un algorithme cryptographique, à partir de l'adresse du pointeur. Elle est vérifiée à chaque utilisation du pointeur. Si la somme de contrôle ne correspond pas au pointeur, une erreur est levée. Si jamais un virus ou un code malveillant modifie un pointeur, il ne saura pas comment calculer la somme de contrôle, la somme de contrôle a énormément de chances d'être incorrecte. Quelques fonctionnalités des processeurs visent à autoriser l'utilisation des adresses non-canoniques. L'idée est que les 16 bits de poids fort sont ignorées lors des accès mémoire, ce qui permet d'utiliser les 16 bits de poids fort à volonté. Il s'agit des techniques ''Top Byte Ignore'' d'ARM, ''Upper Address Ignore'' d'AMD, et ''Linear Address Masking'' d'Intel. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs 8 bits et moins | prevText=Les processeurs 8 bits et moins | next=L'abstraction mémoire et la mémoire virtuelle | nextText=L'abstraction mémoire et la mémoire virtuelle }} </noinclude> e2nrjby6odx1gttgoafa59z1vttrp6i Mathc initiation/a592 0 81086 774356 774335 2026-10-07T12:02:05Z Xhungab 23827 774356 wikitext text/x-wiki __NOTOC__ [[Catégorie:Mathc initiation (livre)]] [[Mathc initiation/a06| Sommaire]] {{Partie{{{type|}}}|Calculer le module et l'argument d'un nombre complexe}} '''Calculer le module d'un nombre complexe.'''<br> Soit z = a + bi ... alors ... |z| = sqrt(a^2 + b^2)<br> ---- '''Calculer l'argument d'un nombre complexe.'''<br> [[File:Unit circle angles color.svg|thumb|]] '''''a) Si l'argument du nombre complexe est une valeur remarquable'''''.<br> cos(θ) = a/|z| ... et ... sin(θ) = b/|z|<br> Il faut trouver la valeur remarquable de θ sur le cercle trigonométrique <br> '''''b) Si l'argument du nombre complexe n'est pas une valeur remarquable.'''''<br> * '''Si a est différent de zéro.''' # a > 0 ............... (I ou IV quadrants) : ... θ = atan(b/a) # a<0 et b> 0 ..... ((II quadrants) : .......... θ = atan(b/a) +PI # a<0 et b< 0 ..... ((III quadrants) : ......... θ = atan(b/a) -PI * '''Si a est égal à zéro.''' # b> 0 ...... θ = +PI/2 # b< 0 ...... θ = -PI/2 * '''Si b est égal à zéro.''' # a> 0 ...... θ = +0 # a< 0 ...... θ = PI ---- ---- '''''c) Exemples numériques.'''''<br> '''Si a est différent de zéro.''' * '''a > 0''' ............... z = +5 -7i ** '''θ = atan(b/a)''' ** θ = atan(-7/5) = -0.951 rad = 5.332 rad * '''a<0 et b> 0''' ..... z = -5 +7i ** '''θ = atan(b/a) + PI''' ** θ = atan(7/(-5)) +PI = -0.951 + 3.142 = 2,191 rad * '''a<0 et b< 0''' ..... z = -5 -7i ** '''θ = atan(b/a) - PI''' ** θ = atan(-7/(-5)) -PI = 0.951 - 3.142 = -2,191 rad = 4,092rad ---- '''Si a est égal à zéro.''' * '''b > 0''' ... z = +3i ... '''θ = +PI/2''' * '''b < 0''' ... z = -5i ... '''θ = -PI/2''' ---- '''Si b est égal à zéro.''' * '''a > 0''' ... z = +8 ... '''θ = +0''' * '''a < 0''' ... z = -5 ... '''θ = PI''' {{AutoCat}} d8l0i10l3d4da3u7ew1rng853ex56sr Mathc initiation/a594 0 81090 774420 765576 2026-10-08T09:25:47Z Xhungab 23827 774420 wikitext text/x-wiki __NOTOC__ [[Catégorie:Mathc initiation (livre)]] [[File:Fourier serie for my wikibook.png|thumb|Fourier serie f(x) = x with x[0..Pi]]] '''L'étude de ce chapitre peut ce faire à l'aide de cette [[https://youtube.com/playlist?list=PLi6peGpf8EPOQGZMpyjDEHjnf1wsQbDJG&si=mhV8SOb34ohQKvKm Playlist]].''' En analyse mathématique, les séries de Fourier sont un outil fondamental dans l'étude des fonctions périodiques. C'est à partir de ce concept que s'est développée la branche des mathématiques connue sous le nom d'analyse harmonique. [https://fr.wikipedia.org/wiki/S%C3%A9rie_de_Fourier wikipedia] '''Pour les définitions de a0, an et bn j'utilise la [https://fr.wikipedia.org/wiki/S%C3%A9rie_de_Fourier# Première convention]''' . ---- '''Se familiariser avec les series de Fourier : ''' {{Partie{{{type|}}}|[[Mathc initiation/a593|* La fonction : f(x) = x ........................ avec x = [0..PI]]]}} {{Partie{{{type|}}}|[[Mathc initiation/a602|* La fonction : f(x) = (x*(x+1)) .......... avec x = [-1..1]]]}} {{AutoCat}} aor1wdg6ila2iulhidrpf1fhtpvvguy 774421 774420 2026-10-08T09:26:43Z Xhungab 23827 774421 wikitext text/x-wiki __NOTOC__ [[Catégorie:Mathc initiation (livre)]] [[File:Fourier serie for my wikibook.png|thumb|Fourier serie f(x) = x with x[0..Pi]]] '''L'étude de ce chapitre peut ce faire à l'aide de cette [[https://youtube.com/playlist?list=PLi6peGpf8EPOQGZMpyjDEHjnf1wsQbDJG&si=mhV8SOb34ohQKvKm Playlist]].''' En analyse mathématique, les séries de Fourier sont un outil fondamental dans l'étude des fonctions périodiques. C'est à partir de ce concept que s'est développée la branche des mathématiques connue sous le nom d'analyse harmonique. [https://fr.wikipedia.org/wiki/S%C3%A9rie_de_Fourier wikipedia] '''Pour les définitions de a0, an et bn j'utilise la [https://fr.wikipedia.org/wiki/S%C3%A9rie_de_Fourier#Première convention]''' . ---- '''Se familiariser avec les series de Fourier : ''' {{Partie{{{type|}}}|[[Mathc initiation/a593|* La fonction : f(x) = x ........................ avec x = [0..PI]]]}} {{Partie{{{type|}}}|[[Mathc initiation/a602|* La fonction : f(x) = (x*(x+1)) .......... avec x = [-1..1]]]}} {{AutoCat}} ddgfiqp6jobuhpec3xjytgs2yp8f7od 774422 774421 2026-10-08T09:27:27Z Xhungab 23827 774422 wikitext text/x-wiki __NOTOC__ [[Catégorie:Mathc initiation (livre)]] [[File:Fourier serie for my wikibook.png|thumb|Fourier serie f(x) = x with x[0..Pi]]] '''L'étude de ce chapitre peut ce faire à l'aide de cette [[https://youtube.com/playlist?list=PLi6peGpf8EPOQGZMpyjDEHjnf1wsQbDJG&si=mhV8SOb34ohQKvKm Playlist]].''' En analyse mathématique, les séries de Fourier sont un outil fondamental dans l'étude des fonctions périodiques. C'est à partir de ce concept que s'est développée la branche des mathématiques connue sous le nom d'analyse harmonique. [https://fr.wikipedia.org/wiki/S%C3%A9rie_de_Fourier wikipedia] '''Pour les définitions de a0, an et bn j'utilise la [https://fr.wikipedia.org/wiki/S%C3%A9rie_de_Fourier#Coefficients_réels Première convention]''' . ---- '''Se familiariser avec les series de Fourier : ''' {{Partie{{{type|}}}|[[Mathc initiation/a593|* La fonction : f(x) = x ........................ avec x = [0..PI]]]}} {{Partie{{{type|}}}|[[Mathc initiation/a602|* La fonction : f(x) = (x*(x+1)) .......... avec x = [-1..1]]]}} {{AutoCat}} 6jlbjr9bygucj6wzcs46iijvy2hiujb 774423 774422 2026-10-08T09:37:46Z Xhungab 23827 774423 wikitext text/x-wiki __NOTOC__ [[Catégorie:Mathc initiation (livre)]] [[File:Fourier serie for my wikibook.png|thumb|Fourier serie f(x) = x with x[0..Pi]]] '''L'étude de ce chapitre peut ce faire à l'aide de cette [[https://youtube.com/playlist?list=PLi6peGpf8EPOQGZMpyjDEHjnf1wsQbDJG&si=mhV8SOb34ohQKvKm Playlist]].''' En analyse mathématique, les séries de Fourier sont un outil fondamental dans l'étude des fonctions périodiques. C'est à partir de ce concept que s'est développée la branche des mathématiques connue sous le nom d'analyse harmonique. [https://fr.wikipedia.org/wiki/S%C3%A9rie_de_Fourier wikipedia] . '''Pour les définitions de a0, b0, an et bn j'utilise la [https://fr.wikipedia.org/wiki/S%C3%A9rie_de_Fourier#Coefficients_réels Première convention]''' * <math>a_0(f)=\frac1T\int_Tf(t)\,\mathrm{d}t=c_0(f)</math> * <math>b_0(f)=0</math> * pour <math>n>0</math> : <math>a_n(f)=\frac2T\int_Tf(t)\cos\left(nt\frac{2\pi}T\right)\,\mathrm dt</math> * pour <math>n>0</math> : <math>b_n(f)=\frac2T\int_Tf(t)\sin\left(nt\frac{2\pi}T\right)\,\mathrm dt</math> . ---- '''Se familiariser avec les series de Fourier : ''' {{Partie{{{type|}}}|[[Mathc initiation/a593|* La fonction : f(x) = x ........................ avec x = [0..PI]]]}} {{Partie{{{type|}}}|[[Mathc initiation/a602|* La fonction : f(x) = (x*(x+1)) .......... avec x = [-1..1]]]}} {{AutoCat}} fws6ivkovzta0rb6t1miuu1ss9mj6uu 774430 774423 2026-10-08T10:08:49Z Xhungab 23827 774430 wikitext text/x-wiki __NOTOC__ [[Catégorie:Mathc initiation (livre)]] [[File:Fourier serie for my wikibook.png|thumb|Fourier serie f(x) = x with x[0..Pi]]] '''L'étude de ce chapitre peut ce faire à l'aide de cette [[https://youtube.com/playlist?list=PLi6peGpf8EPOQGZMpyjDEHjnf1wsQbDJG&si=mhV8SOb34ohQKvKm Playlist]].''' En analyse mathématique, les séries de Fourier sont un outil fondamental dans l'étude des fonctions périodiques. C'est à partir de ce concept que s'est développée la branche des mathématiques connue sous le nom d'analyse harmonique. [https://fr.wikipedia.org/wiki/S%C3%A9rie_de_Fourier wikipedia] . '''Pour les définitions de a0, b0, an et bn j'utilise la [https://fr.wikipedia.org/wiki/S%C3%A9rie_de_Fourier#Coefficients_réels Première convention]''' * <math> \mathbf{a_0(f)=\frac1T\int_Tf(t)\,\mathrm{d}t}</math> * <math>\mathbf{b_0(f)=0}</math> * '''pour''' <math>\mathbf{n>0}</math> : <math>\mathbf{a_n(f)=\frac2T\int_Tf(t)\cos\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math> * '''pour''' <math>\mathbf{n>0}</math> : <math>\mathbf{b_n(f)=\frac2T\int_Tf(t)\sin\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math> . ---- '''Se familiariser avec les series de Fourier : ''' {{Partie{{{type|}}}|[[Mathc initiation/a593|* La fonction : f(x) = x ........................ avec x = [0..PI]]]}} {{Partie{{{type|}}}|[[Mathc initiation/a602|* La fonction : f(x) = (x*(x+1)) .......... avec x = [-1..1]]]}} {{AutoCat}} 8kct3mp4yw8ttla9pmmvqxf9ruhvi4k 774431 774430 2026-10-08T10:10:28Z Xhungab 23827 774431 wikitext text/x-wiki __NOTOC__ [[Catégorie:Mathc initiation (livre)]] [[Mathc initiation/a522#Analyse IV : Se familiariser avec les séries de Fourier|Sommaire]] [[File:Fourier serie for my wikibook.png|thumb|Fourier serie f(x) = x with x[0..Pi]]] '''L'étude de ce chapitre peut ce faire à l'aide de cette [[https://youtube.com/playlist?list=PLi6peGpf8EPOQGZMpyjDEHjnf1wsQbDJG&si=mhV8SOb34ohQKvKm Playlist]].''' En analyse mathématique, les séries de Fourier sont un outil fondamental dans l'étude des fonctions périodiques. C'est à partir de ce concept que s'est développée la branche des mathématiques connue sous le nom d'analyse harmonique. [https://fr.wikipedia.org/wiki/S%C3%A9rie_de_Fourier wikipedia] . '''Pour les définitions de a0, b0, an et bn j'utilise la [https://fr.wikipedia.org/wiki/S%C3%A9rie_de_Fourier#Coefficients_réels Première convention]''' * <math> \mathbf{a_0(f)=\frac1T\int_Tf(t)\,\mathrm{d}t}</math> * <math>\mathbf{b_0(f)=0}</math> * '''pour''' <math>\mathbf{n>0}</math> : <math>\mathbf{a_n(f)=\frac2T\int_Tf(t)\cos\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math> * '''pour''' <math>\mathbf{n>0}</math> : <math>\mathbf{b_n(f)=\frac2T\int_Tf(t)\sin\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math> . ---- '''Se familiariser avec les series de Fourier : ''' {{Partie{{{type|}}}|[[Mathc initiation/a593|* La fonction : f(x) = x ........................ avec x = [0..PI]]]}} {{Partie{{{type|}}}|[[Mathc initiation/a602|* La fonction : f(x) = (x*(x+1)) .......... avec x = [-1..1]]]}} {{AutoCat}} eaz663chiajmk7h7snv3v2yj7wd4rsg 774440 774431 2026-10-08T11:12:12Z Xhungab 23827 774440 wikitext text/x-wiki __NOTOC__ [[Catégorie:Mathc initiation (livre)]] [[Mathc initiation/a522#Analyse IV : Se familiariser avec les séries de Fourier|Sommaire]] [[File:Fourier serie for my wikibook.png|thumb|Fourier serie f(x) = x with x[0..Pi]]] '''L'étude de ce chapitre peut ce faire à l'aide de cette [[https://youtube.com/playlist?list=PLi6peGpf8EPOQGZMpyjDEHjnf1wsQbDJG&si=mhV8SOb34ohQKvKm Playlist]].''' En analyse mathématique, les séries de Fourier sont un outil fondamental dans l'étude des fonctions périodiques. C'est à partir de ce concept que s'est développée la branche des mathématiques connue sous le nom d'analyse harmonique. [https://fr.wikipedia.org/wiki/S%C3%A9rie_de_Fourier wikipedia] . '''Pour les définitions de a0, b0, an et bn j'utilise la [https://fr.wikipedia.org/wiki/S%C3%A9rie_de_Fourier#Coefficients_réels Première convention]''' * <math> \mathbf{a_0(f)=\frac1T\int_Tf(t)\,\mathrm{d}t}</math> * <math>\mathbf{b_0(f)=0}</math> * '''pour''' <math>\mathbf{n>0}</math> : <math>\mathbf{a_n(f)=\frac2T\int_Tf(t)\cos\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math> * '''pour''' <math>\mathbf{n>0}</math> : <math>\mathbf{b_n(f)=\frac2T\int_Tf(t)\sin\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math> . ---- '''Se familiariser avec les series de Fourier : ''' {{Partie{{{type|}}}|[[Mathc initiation/a593|* La fonction : f(x) = x ....................... avec x = [0..PI]]]}} {{Partie{{{type|}}}|[[Mathc initiation/a602|* La fonction : f(x) = (x(x+1)) ........... avec x = [-1..1]]]}} {{AutoCat}} phm4aa9mytursrwnhajnl8lsbeer6eb Mathc initiation/a593 0 81091 774424 725844 2026-10-08T09:48:48Z Xhungab 23827 774424 wikitext text/x-wiki __NOTOC__ [[Catégorie:Mathc initiation (livre)]] : [[Mathc initiation/a594| Sommaire]] : : {{Partie{{{type|}}}|Se familiariser avec les series de Fourier}} : . : Copier la bibliothèque dans votre répertoire de travail : * [[Mathc initiation/a599|x_hfile.h ............. Déclaration des fichiers h]] * [[Mathc initiation/Fichiers h : c30a2|x_def.h .............. Déclaration des utilitaires]] * [[Mathc initiation/a600|x_fcoef.h ........... Coefficients de Fourier]] : . : La fonction f : * [[Mathc initiation/a595|f.h ]] ...... '''f(x) = x avec x = [0..PI]''' : . : Les séries de Fourier : * [[Mathc initiation/a601|c0a0.c]] ..... '''Coefficient a0''' ... ... <math>a_0(f)=\frac1T\int_Tf(t)\,\mathrm{d}t=c_0(f)</math> ; * [[Mathc initiation/a596|c0an.c]] ..... '''Coefficient an''' ... ... pour <math>n>0</math> : <math>a_n(f)=\frac2T\int_Tf(t)\cos\left(nt\frac{2\pi}T\right)\,\mathrm dt</math> * [[Mathc initiation/a597|c0bn.c]] ..... '''Coefficient bn''' ... ... pour <math>n>0</math> : <math>b_n(f)=\frac2T\int_Tf(t)\sin\left(nt\frac{2\pi}T\right)\,\mathrm dt</math>. * [[Mathc initiation/a598|c0fs.c]] ...... '''Les séries de Fourier''' * [[Mathc initiation/a615|c0fsN.c]] ... '''Les séries de Fourier avec N lignes''' {{AutoCat}} dtkc1pinyx34v25klw01r07s3vjnytd 774425 774424 2026-10-08T09:49:42Z Xhungab 23827 774425 wikitext text/x-wiki __NOTOC__ [[Catégorie:Mathc initiation (livre)]] : [[Mathc initiation/a594| Sommaire]] : : {{Partie{{{type|}}}|Se familiariser avec les series de Fourier}} : . : Copier la bibliothèque dans votre répertoire de travail : * [[Mathc initiation/a599|x_hfile.h ............. Déclaration des fichiers h]] * [[Mathc initiation/Fichiers h : c30a2|x_def.h .............. Déclaration des utilitaires]] * [[Mathc initiation/a600|x_fcoef.h ........... Coefficients de Fourier]] : . : La fonction f : * [[Mathc initiation/a595|f.h ]] ...... '''f(x) = x avec x = [0..PI]''' : . : Les séries de Fourier : * [[Mathc initiation/a601|c0a0.c]] ..... '''Coefficient a0''' ... ... <math>a_0(f)=\frac1T\int_Tf(t)\,\mathrm{d}t=c_0(f)</math> ; * [[Mathc initiation/a596|c0an.c]] ..... '''Coefficient an''' ... ... <math>a_n(f)=\frac2T\int_Tf(t)\cos\left(nt\frac{2\pi}T\right)\,\mathrm dt</math> * [[Mathc initiation/a597|c0bn.c]] ..... '''Coefficient bn''' ... ... <math>b_n(f)=\frac2T\int_Tf(t)\sin\left(nt\frac{2\pi}T\right)\,\mathrm dt</math>. * [[Mathc initiation/a598|c0fs.c]] ...... '''Les séries de Fourier''' * [[Mathc initiation/a615|c0fsN.c]] ... '''Les séries de Fourier avec N lignes''' {{AutoCat}} 4x2a55ofl704x6l1pkyjkj4xhzoaoyf 774428 774425 2026-10-08T10:01:27Z Xhungab 23827 774428 wikitext text/x-wiki __NOTOC__ [[Catégorie:Mathc initiation (livre)]] : [[Mathc initiation/a594| Sommaire]] : : {{Partie{{{type|}}}|Se familiariser avec les series de Fourier}} : . : Copier la bibliothèque dans votre répertoire de travail : * [[Mathc initiation/a599|x_hfile.h ............. Déclaration des fichiers h]] * [[Mathc initiation/Fichiers h : c30a2|x_def.h .............. Déclaration des utilitaires]] * [[Mathc initiation/a600|x_fcoef.h ........... Coefficients de Fourier]] : . : La fonction f : * [[Mathc initiation/a595|f.h ]] ...... '''f(x) = x avec x = [0..PI]''' : . : Les séries de Fourier : * [[Mathc initiation/a601|c0a0.c]] ..... '''Coefficient a0''' ... ... <math> \mathbf{a_0(f)=\frac1T\int_Tf(t)\,\mathrm{d}t}</math> * [[Mathc initiation/a596|c0an.c]] ..... '''Coefficient an''' ... ... <math>\mathbf{a_n(f)=\frac2T\int_Tf(t)\cos\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math> * [[Mathc initiation/a597|c0bn.c]] ..... '''Coefficient bn''' ... ... <math>\mathbf{b_n(f)=\frac2T\int_Tf(t)\sin\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math> . * [[Mathc initiation/a598|c0fs.c]] ...... '''Les séries de Fourier''' * [[Mathc initiation/a615|c0fsN.c]] ... '''Les séries de Fourier avec N lignes''' {{AutoCat}} 6uglpw43zuw1kazmn41g3y16y1wfm0a 774432 774428 2026-10-08T10:51:58Z Xhungab 23827 774432 wikitext text/x-wiki __NOTOC__ [[Catégorie:Mathc initiation (livre)]] : [[Mathc initiation/a594| Sommaire]] : : {{Partie{{{type|}}}|Se familiariser avec les series de Fourier}} : . : Copier la bibliothèque dans votre répertoire de travail : * [[Mathc initiation/a599|x_hfile.h ............. Déclaration des fichiers h]] * [[Mathc initiation/Fichiers h : c30a2|x_def.h .............. Déclaration des utilitaires]] * [[Mathc initiation/a600|x_fcoef.h ........... Coefficients de Fourier]] : . : La fonction f : * [[Mathc initiation/a595|f.h ]] ...... '''f(x) = x avec x = [0..PI]''' : . : Les séries de Fourier : * [[Mathc initiation/a601|c0a0.c]] ..... '''Coefficient a0''' ... ... <math> \mathbf{a_0(f)=\frac1T\int_Tf(t)\,\mathrm{d}t}</math> * [[Mathc initiation/a596|c0an.c]] ..... '''Coefficient an''' ... ... <math>\mathbf{a_n(f)=\frac2T\int_Tf(t)\cos\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math> * [[Mathc initiation/a597|c0bn.c]] ..... '''Coefficient bn''' ... ... <math>\mathbf{b_n(f)=\frac2T\int_Tf(t)\sin\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math> . Dans Ce fichier nous allons pouvoir dessiner avec gnuplot '''les 9 premiers terme'''s de la série. * [[Mathc initiation/a598|c0fs.c]] Dans Ce fichier nous allons pouvoir dessiner avec gnuplot '''les N premiers terme'''s de la série. * [[Mathc initiation/a615|c0fsN.c]] {{AutoCat}} 7gwsmz19rycjykyw0m3mac5eugm2jpk 774433 774432 2026-10-08T11:00:41Z Xhungab 23827 774433 wikitext text/x-wiki __NOTOC__ [[Catégorie:Mathc initiation (livre)]] : [[Mathc initiation/a594| Sommaire]] : : {{Partie{{{type|}}}|Se familiariser avec les series de Fourier}} : . : Copier la bibliothèque dans votre répertoire de travail : * [[Mathc initiation/a599|x_hfile.h ............. Déclaration des fichiers h]] * [[Mathc initiation/Fichiers h : c30a2|x_def.h .............. Déclaration des utilitaires]] * [[Mathc initiation/a600|x_fcoef.h ........... Coefficients de Fourier]] : . : La fonction f : * [[Mathc initiation/a595|f.h ]] ...... '''f(x) = x avec x = [0..PI]''' : . : Les séries de Fourier : * [[Mathc initiation/a601|c0a0.c]] ..... '''Coefficient a0''' ... ... <math> \mathbf{a_0(f)=\frac1T\int_Tf(t)\,\mathrm{d}t}</math> * [[Mathc initiation/a596|c0an.c]] ..... '''Coefficient an''' ... ... <math>\mathbf{a_n(f)=\frac2T\int_Tf(t)\cos\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math> * [[Mathc initiation/a597|c0bn.c]] ..... '''Coefficient bn''' ... ... <math>\mathbf{b_n(f)=\frac2T\int_Tf(t)\sin\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math> . Avec ce fichier nous allons pouvoir dessiner avec '''[[Mathc initiation/a9a|gnuplot]]''' '''les 9 premiers terme'''s de la série. * [[Mathc initiation/a598|c0fs.c]] Avec ce fichier nous allons pouvoir dessiner avec '''[[Mathc initiation/a9a|gnuplot]]''' '''les N premiers terme'''s de la série. * [[Mathc initiation/a615|c0fsN.c]] {{AutoCat}} fl680pxta6ta1eg4q0zss7i8kewqq9f 774435 774433 2026-10-08T11:02:23Z Xhungab 23827 774435 wikitext text/x-wiki __NOTOC__ [[Catégorie:Mathc initiation (livre)]] : [[Mathc initiation/a594| Sommaire]] : : {{Partie{{{type|}}}|Se familiariser avec les series de Fourier}} : . : Copier la bibliothèque dans votre répertoire de travail : * [[Mathc initiation/a599|x_hfile.h ............. Déclaration des fichiers h]] * [[Mathc initiation/Fichiers h : c30a2|x_def.h .............. Déclaration des utilitaires]] * [[Mathc initiation/a600|x_fcoef.h ........... Coefficients de Fourier]] : . : La fonction f : * [[Mathc initiation/a595|f.h ]] ...... '''f(x) = x avec x = [0..PI]''' : . : Les séries de Fourier : * [[Mathc initiation/a601|c0a0.c]] ..... '''Coefficient a0''' ... ... <math> \mathbf{a_0(f)=\frac1T\int_Tf(t)\,\mathrm{d}t}</math> * [[Mathc initiation/a596|c0an.c]] ..... '''Coefficient an''' ... ... <math>\mathbf{a_n(f)=\frac2T\int_Tf(t)\cos\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math> * [[Mathc initiation/a597|c0bn.c]] ..... '''Coefficient bn''' ... ... <math>\mathbf{b_n(f)=\frac2T\int_Tf(t)\sin\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math> . Avec ce fichier C nous allons pouvoir dessiner avec '''[[Mathc initiation/a9a|gnuplot]]''' '''les 9 premiers terme'''s de la série. * [[Mathc initiation/a598|c0fs.c]] Avec ce fichier C nous allons pouvoir dessiner avec '''[[Mathc initiation/a9a|gnuplot]]''' '''les N premiers terme'''s de la série. * [[Mathc initiation/a615|c0fsN.c]] {{AutoCat}} m7wewllwrv3njmzdov56kwe6mpkekuh 774437 774435 2026-10-08T11:09:18Z Xhungab 23827 774437 wikitext text/x-wiki __NOTOC__ [[Catégorie:Mathc initiation (livre)]] : [[Mathc initiation/a594| Sommaire]] : : {{Partie{{{type|}}}|Se familiariser avec les series de Fourier avec f -> x}} : . : Copier la bibliothèque dans votre répertoire de travail : * [[Mathc initiation/a599|x_hfile.h ............. Déclaration des fichiers h]] * [[Mathc initiation/Fichiers h : c30a2|x_def.h .............. Déclaration des utilitaires]] * [[Mathc initiation/a600|x_fcoef.h ........... Coefficients de Fourier]] : . : La fonction f : * [[Mathc initiation/a595|f.h ]] ...... '''f(x) = x avec x = [0..PI]''' : . : Les séries de Fourier : * [[Mathc initiation/a601|c0a0.c]] ..... '''Coefficient a0''' ... ... <math> \mathbf{a_0(f)=\frac1T\int_Tf(t)\,\mathrm{d}t}</math> * [[Mathc initiation/a596|c0an.c]] ..... '''Coefficient an''' ... ... <math>\mathbf{a_n(f)=\frac2T\int_Tf(t)\cos\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math> * [[Mathc initiation/a597|c0bn.c]] ..... '''Coefficient bn''' ... ... <math>\mathbf{b_n(f)=\frac2T\int_Tf(t)\sin\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math> . Avec ce fichier C nous allons pouvoir dessiner avec '''[[Mathc initiation/a9a|gnuplot]]''' '''les 9 premiers terme'''s de la série. * [[Mathc initiation/a598|c0fs.c]] Avec ce fichier C nous allons pouvoir dessiner avec '''[[Mathc initiation/a9a|gnuplot]]''' '''les N premiers terme'''s de la série. * [[Mathc initiation/a615|c0fsN.c]] {{AutoCat}} 3gieferah9idgsdvw9412ta230jvayj 774439 774437 2026-10-08T11:11:32Z Xhungab 23827 774439 wikitext text/x-wiki __NOTOC__ [[Catégorie:Mathc initiation (livre)]] : [[Mathc initiation/a594| Sommaire]] : : {{Partie{{{type|}}}|Se familiariser avec les series de Fourier avec f : -> x}} : . : Copier la bibliothèque dans votre répertoire de travail : * [[Mathc initiation/a599|x_hfile.h ............. Déclaration des fichiers h]] * [[Mathc initiation/Fichiers h : c30a2|x_def.h .............. Déclaration des utilitaires]] * [[Mathc initiation/a600|x_fcoef.h ........... Coefficients de Fourier]] : . : La fonction f : * [[Mathc initiation/a595|f.h ]] ...... '''f(x) = x avec x = [0..PI]''' : . : Les séries de Fourier : * [[Mathc initiation/a601|c0a0.c]] ..... '''Coefficient a0''' ... ... <math> \mathbf{a_0(f)=\frac1T\int_Tf(t)\,\mathrm{d}t}</math> * [[Mathc initiation/a596|c0an.c]] ..... '''Coefficient an''' ... ... <math>\mathbf{a_n(f)=\frac2T\int_Tf(t)\cos\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math> * [[Mathc initiation/a597|c0bn.c]] ..... '''Coefficient bn''' ... ... <math>\mathbf{b_n(f)=\frac2T\int_Tf(t)\sin\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math> . Avec ce fichier C nous allons pouvoir dessiner avec '''[[Mathc initiation/a9a|gnuplot]]''' '''les 9 premiers terme'''s de la série. * [[Mathc initiation/a598|c0fs.c]] Avec ce fichier C nous allons pouvoir dessiner avec '''[[Mathc initiation/a9a|gnuplot]]''' '''les N premiers terme'''s de la série. * [[Mathc initiation/a615|c0fsN.c]] {{AutoCat}} qnj0rgf4hpxsdvpgne9k9svjz49h56m Mathc initiation/a602 0 81099 774426 725845 2026-10-08T09:58:41Z Xhungab 23827 774426 wikitext text/x-wiki __NOTOC__ [[Catégorie:Mathc initiation (livre)]] : [[Mathc initiation/a594| Sommaire]] : : {{Partie{{{type|}}}|Se familiariser avec les series de Fourier}} : . : Copier la bibliothèque dans votre répertoire de travail : * [[Mathc initiation/a599|x_hfile.h ............. Déclaration des fichiers h]] * [[Mathc initiation/Fichiers h : c30a2|x_def.h .............. Déclaration des utilitaires]] * [[Mathc initiation/a600|x_fcoef.h ........... Coefficients de Fourier]] : . : La fonction f : * [[Mathc initiation/a603|f.h]] ...... '''f(x) = (x*(x+1)) avec x = [-1..1]''' : . : Les séries de Fourier : * [[Mathc initiation/a604|c0a0.c]] ..... '''Coefficient a0''' ... ... <math> \mathbf{a_0(f)=\frac1T\int_Tf(t)\,\mathrm{d}t=c_0(f)}</math> * [[Mathc initiation/a605|c0an.c]] ..... '''Coefficient an''' ... ... <math>\mathbf{a_n(f)=\frac2T\int_Tf(t)\cos\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math> * [[Mathc initiation/a606|c0bn.c]] ..... '''Coefficient bn''' ... ... <math>\mathbf{b_n(f)=\frac2T\int_Tf(t)\sin\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math> * [[Mathc initiation/a607|c0fs.c]] ...... '''Les séries de Fourier''' * [[Mathc initiation/a616|c0fsN.c]] ... '''Les séries de Fourier avec N lignes''' : . : {{AutoCat}} 3jalvd4x9nf26th9poafrj4sixmibkn 774427 774426 2026-10-08T09:59:15Z Xhungab 23827 774427 wikitext text/x-wiki __NOTOC__ [[Catégorie:Mathc initiation (livre)]] : [[Mathc initiation/a594| Sommaire]] : : {{Partie{{{type|}}}|Se familiariser avec les series de Fourier}} : . : Copier la bibliothèque dans votre répertoire de travail : * [[Mathc initiation/a599|x_hfile.h ............. Déclaration des fichiers h]] * [[Mathc initiation/Fichiers h : c30a2|x_def.h .............. Déclaration des utilitaires]] * [[Mathc initiation/a600|x_fcoef.h ........... Coefficients de Fourier]] : . : La fonction f : * [[Mathc initiation/a603|f.h]] ...... '''f(x) = (x*(x+1)) avec x = [-1..1]''' : . : Les séries de Fourier : * [[Mathc initiation/a604|c0a0.c]] ..... '''Coefficient a0''' ... ... <math> \mathbf{a_0(f)=\frac1T\int_Tf(t)\,\mathrm{d}t=c_0(f)}</math> * [[Mathc initiation/a605|c0an.c]] ..... '''Coefficient an''' ... ... <math>\mathbf{a_n(f)=\frac2T\int_Tf(t)\cos\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math> * [[Mathc initiation/a606|c0bn.c]] ..... '''Coefficient bn''' ... ... <math>\mathbf{b_n(f)=\frac2T\int_Tf(t)\sin\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math> . * [[Mathc initiation/a607|c0fs.c]] ...... '''Les séries de Fourier''' * [[Mathc initiation/a616|c0fsN.c]] ... '''Les séries de Fourier avec N lignes''' : . : {{AutoCat}} f7b9m0vjnlo31jt53fob96e05kojjqx 774429 774427 2026-10-08T10:02:08Z Xhungab 23827 774429 wikitext text/x-wiki __NOTOC__ [[Catégorie:Mathc initiation (livre)]] : [[Mathc initiation/a594| Sommaire]] : : {{Partie{{{type|}}}|Se familiariser avec les series de Fourier}} : . : Copier la bibliothèque dans votre répertoire de travail : * [[Mathc initiation/a599|x_hfile.h ............. Déclaration des fichiers h]] * [[Mathc initiation/Fichiers h : c30a2|x_def.h .............. Déclaration des utilitaires]] * [[Mathc initiation/a600|x_fcoef.h ........... Coefficients de Fourier]] : . : La fonction f : * [[Mathc initiation/a603|f.h]] ...... '''f(x) = (x*(x+1)) avec x = [-1..1]''' : . : Les séries de Fourier : * [[Mathc initiation/a604|c0a0.c]] ..... '''Coefficient a0''' ... ... <math> \mathbf{a_0(f)=\frac1T\int_Tf(t)\,\mathrm{d}t}</math> * [[Mathc initiation/a605|c0an.c]] ..... '''Coefficient an''' ... ... <math>\mathbf{a_n(f)=\frac2T\int_Tf(t)\cos\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math> * [[Mathc initiation/a606|c0bn.c]] ..... '''Coefficient bn''' ... ... <math>\mathbf{b_n(f)=\frac2T\int_Tf(t)\sin\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math> . * [[Mathc initiation/a607|c0fs.c]] ...... '''Les séries de Fourier''' * [[Mathc initiation/a616|c0fsN.c]] ... '''Les séries de Fourier avec N lignes''' : . : {{AutoCat}} sm6x6g1pnvvxvwu3sm7kr8rx51wae2u 774436 774429 2026-10-08T11:04:10Z Xhungab 23827 774436 wikitext text/x-wiki __NOTOC__ [[Catégorie:Mathc initiation (livre)]] : [[Mathc initiation/a594| Sommaire]] : : {{Partie{{{type|}}}|Se familiariser avec les series de Fourier}} : . : Copier la bibliothèque dans votre répertoire de travail : * [[Mathc initiation/a599|x_hfile.h ............. Déclaration des fichiers h]] * [[Mathc initiation/Fichiers h : c30a2|x_def.h .............. Déclaration des utilitaires]] * [[Mathc initiation/a600|x_fcoef.h ........... Coefficients de Fourier]] : . : La fonction f : * [[Mathc initiation/a603|f.h]] ...... '''f(x) = (x*(x+1)) avec x = [-1..1]''' : . : Les séries de Fourier : * [[Mathc initiation/a604|c0a0.c]] ..... '''Coefficient a0''' ... ... <math> \mathbf{a_0(f)=\frac1T\int_Tf(t)\,\mathrm{d}t}</math> * [[Mathc initiation/a605|c0an.c]] ..... '''Coefficient an''' ... ... <math>\mathbf{a_n(f)=\frac2T\int_Tf(t)\cos\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math> * [[Mathc initiation/a606|c0bn.c]] ..... '''Coefficient bn''' ... ... <math>\mathbf{b_n(f)=\frac2T\int_Tf(t)\sin\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math> . Avec ce fichier C nous allons pouvoir dessiner avec '''[[Mathc initiation/a9a|gnuplot]]''' '''les 9 premiers terme'''s de la série. * [[Mathc initiation/a607|c0fs.c]] Avec ce fichier C nous allons pouvoir dessiner avec '''[[Mathc initiation/a9a|gnuplot]]''' '''les N premiers terme'''s de la série. * [[Mathc initiation/a616|c0fsN.c]] {{AutoCat}} o5xex38fdaobyj1dbghcqilirz8hp50 774438 774436 2026-10-08T11:10:55Z Xhungab 23827 774438 wikitext text/x-wiki __NOTOC__ [[Catégorie:Mathc initiation (livre)]] : [[Mathc initiation/a594| Sommaire]] : : {{Partie{{{type|}}}|Se familiariser avec les séries de Fourier avec f: -> (x(x+1))}} : . : Copier la bibliothèque dans votre répertoire de travail : * [[Mathc initiation/a599|x_hfile.h ............. Déclaration des fichiers h]] * [[Mathc initiation/Fichiers h : c30a2|x_def.h .............. Déclaration des utilitaires]] * [[Mathc initiation/a600|x_fcoef.h ........... Coefficients de Fourier]] : . : La fonction f : * [[Mathc initiation/a603|f.h]] ...... '''f(x) = (x*(x+1)) avec x = [-1..1]''' : . : Les séries de Fourier : * [[Mathc initiation/a604|c0a0.c]] ..... '''Coefficient a0''' ... ... <math> \mathbf{a_0(f)=\frac1T\int_Tf(t)\,\mathrm{d}t}</math> * [[Mathc initiation/a605|c0an.c]] ..... '''Coefficient an''' ... ... <math>\mathbf{a_n(f)=\frac2T\int_Tf(t)\cos\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math> * [[Mathc initiation/a606|c0bn.c]] ..... '''Coefficient bn''' ... ... <math>\mathbf{b_n(f)=\frac2T\int_Tf(t)\sin\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math> . Avec ce fichier C nous allons pouvoir dessiner avec '''[[Mathc initiation/a9a|gnuplot]]''' '''les 9 premiers terme'''s de la série. * [[Mathc initiation/a607|c0fs.c]] Avec ce fichier C nous allons pouvoir dessiner avec '''[[Mathc initiation/a9a|gnuplot]]''' '''les N premiers terme'''s de la série. * [[Mathc initiation/a616|c0fsN.c]] {{AutoCat}} l144u5kyxpcwxpsahuvw2jj2twja5vz 774441 774438 2026-10-08T11:12:32Z Xhungab 23827 774441 wikitext text/x-wiki __NOTOC__ [[Catégorie:Mathc initiation (livre)]] : [[Mathc initiation/a594| Sommaire]] : : {{Partie{{{type|}}}|Se familiariser avec les séries de Fourier avec f: -> (x(x+1))}} : . : Copier la bibliothèque dans votre répertoire de travail : * [[Mathc initiation/a599|x_hfile.h ............. Déclaration des fichiers h]] * [[Mathc initiation/Fichiers h : c30a2|x_def.h .............. Déclaration des utilitaires]] * [[Mathc initiation/a600|x_fcoef.h ........... Coefficients de Fourier]] : . : La fonction f : * [[Mathc initiation/a603|f.h]] ...... '''f(x) = (x(x+1)) avec x = [-1..1]''' : . : Les séries de Fourier : * [[Mathc initiation/a604|c0a0.c]] ..... '''Coefficient a0''' ... ... <math> \mathbf{a_0(f)=\frac1T\int_Tf(t)\,\mathrm{d}t}</math> * [[Mathc initiation/a605|c0an.c]] ..... '''Coefficient an''' ... ... <math>\mathbf{a_n(f)=\frac2T\int_Tf(t)\cos\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math> * [[Mathc initiation/a606|c0bn.c]] ..... '''Coefficient bn''' ... ... <math>\mathbf{b_n(f)=\frac2T\int_Tf(t)\sin\left(nt\frac{2\pi}T\right)\,\mathrm dt}</math> . Avec ce fichier C nous allons pouvoir dessiner avec '''[[Mathc initiation/a9a|gnuplot]]''' '''les 9 premiers terme'''s de la série. * [[Mathc initiation/a607|c0fs.c]] Avec ce fichier C nous allons pouvoir dessiner avec '''[[Mathc initiation/a9a|gnuplot]]''' '''les N premiers terme'''s de la série. * [[Mathc initiation/a616|c0fsN.c]] {{AutoCat}} qgk7z0ewbkgztnnlfdr307plyqohtlu Mathc initiation/a603 0 81100 774442 720253 2026-10-08T11:13:11Z Xhungab 23827 774442 wikitext text/x-wiki [[Catégorie:Mathc initiation (livre)]] [[Mathc initiation/a602| Sommaire]] Installer ce fichier dans votre répertoire de travail. {{Fichier|f1.h|largeur=70%|info=|icon=Crystal Clear mimetype source h.png}} <syntaxhighlight lang="c"> /* ---------------------------------- */ /* save as f1.h */ /* ---------------------------------- */ double f( double x) { return(x*(x+1)); } char feq[] = "(x(x+1))"; /* ---------------------------------- */ /* ---------------------------------- */ </syntaxhighlight> ---- {{AutoCat}} 0jexi1twsa2migkoa9bqk2a74bn2ya3 Mathc initiation/a604 0 81101 774444 737549 2026-10-08T11:14:18Z Xhungab 23827 774444 wikitext text/x-wiki [[Catégorie:Mathc initiation (livre)]] [[Mathc initiation/a602| Sommaire]] Installer et compiler ces fichiers dans votre répertoire de travail. {{Fichier|c1a0.c|largeur=70%|info=|icon=Crystal128-source-c.svg}} <syntaxhighlight lang="c"> /* ---------------------------------- */ /* save as c1a0.c */ /* ---------------------------------- */ #include "x_hfile.h" /* ---------------------------------- */ #include "f1.h" /* ---------------------------------- */ int main(void) { double T = 2.; double a = -1; double b = +1; double a0 = 1./T * fourier_a0(f, a, b); clrscrn(); printf(" _+oo_ \n" " \\ \n" " S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)\n" " /_____ \n" " n = 1 \n\n\n\n"); printf(" (a + T\n" " a_0 = 1/T int( (%s) dx = %.6f\n" " (a \n\n\n\n", feq, a0); stop(); return 0; } /* ---------------------------------- */ /* ---------------------------------- */ </syntaxhighlight> '''Exemple de sortie écran :''' <syntaxhighlight lang="C"> _+oo_ \ S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t) /_____ n = 1 (a + T a_0 = 1/T int( ((x(x+1))) dx = 0.333333 (a Press return to continue. </syntaxhighlight> {{AutoCat}} mi0pxmigilsi3ptz3exrmjn816sidyr Mathc initiation/a605 0 81102 774443 737550 2026-10-08T11:13:47Z Xhungab 23827 774443 wikitext text/x-wiki [[Catégorie:Mathc initiation (livre)]] [[Mathc initiation/a602| Sommaire]] Installer et compiler ces fichiers dans votre répertoire de travail. {{Fichier|c1an.c|largeur=70%|info=|icon=Crystal128-source-c.svg}} <syntaxhighlight lang="c"> /* ---------------------------------- */ /* save as c1an.c */ /* ---------------------------------- */ #include "x_hfile.h" /* ---------------------------------- */ #include "f1.h" /* ---------------------------------- */ int main(void) { double T = 2.; double omega = (2.*PI/T); double a = -1; double b = +1; double n = 1.; double an = 2./T * fourier_an(f, a,b, omega, n); clrscrn(); printf(" _+oo_ \n" " \\ \n" " S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)\n" " /_____ \n" " n = 1 \n\n\n\n"); printf(" (a + T\n" " a_n = 2/T int( (%s) cos(n omega t) dt = %.6f\n" " (a \n\n\n\n", feq, an); stop(); return 0; } /* ---------------------------------- */ /* ---------------------------------- */ </syntaxhighlight> '''Exemple de sortie écran :''' <syntaxhighlight lang="C"> _+oo_ \ S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t) /_____ n = 1 (a + T a_n = 2/T int( ((x(x+1))) cos(n omega t) dt = -0.405285 (a Press return to continue. </syntaxhighlight> {{AutoCat}} cfhxqjtz9h990o7ucb1utipgd21a8dn Mathc initiation/a606 0 81103 774445 737551 2026-10-08T11:14:42Z Xhungab 23827 774445 wikitext text/x-wiki [[Catégorie:Mathc initiation (livre)]] [[Mathc initiation/a602| Sommaire]] Installer et compiler ces fichiers dans votre répertoire de travail. {{Fichier|c1bn.c|largeur=70%|info=|icon=Crystal128-source-c.svg}} <syntaxhighlight lang="c"> /* ---------------------------------- */ /* save as c1bn.c */ /* ---------------------------------- */ #include "x_hfile.h" /* ---------------------------------- */ #include "f1.h" /* ---------------------------------- */ int main(void) { double T = 2.; double omega = (2.*PI/T); double a = -1; double b = +1; double n = 1.; double bn = 2./T * fourier_bn(f, a,b, omega, n); clrscrn(); printf(" _+oo_ \n" " \\ \n" " S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t)\n" " /_____ \n" " n = 1 \n\n\n\n"); printf(" (a + T\n" " b_n = 2/T int( (%s) sin(n omega t) dt = %.6f\n" " (a \n\n\n\n", feq, bn); stop(); return 0; } /* ---------------------------------- */ /* ---------------------------------- */ </syntaxhighlight> '''Exemple de sortie écran :''' <syntaxhighlight lang="C"> _+oo_ \ S(t) = a0 + | an cos(omega* n*t) + bn sin(omega *n*t) /_____ n = 1 (a + T b_n = 2/T int( ((x(x+1))) sin(n omega t) dt = 0.636620 (a Press return to continue. </syntaxhighlight> {{AutoCat}} hjcv9mwy2gobitbiwbbnjq1lk9bw8rf Mathc initiation/a9a 0 84560 774434 2026-10-08T11:01:31Z Xhungab 23827 news 774434 wikitext text/x-wiki [[Catégorie:Mathc initiation (livre)]] [[Mathc initiation/a593| Sommaire]] '''Fichier de commande gnuplot :''' <syntaxhighlight lang="gnuplot"> # --------------------- # Copy and past this file into the screen of gnuplot # # plot [0.:pi] [0.:pi]\ x,\ 1.570796327\ -1.000000005*sin(2.000000000* 1*x)\ -0.500000043*sin(2.000000000* 2*x)\ -0.333333480*sin(2.000000000* 3*x)\ -0.250000347*sin(2.000000000* 4*x)\ -0.200000678*sin(2.000000000* 5*x)\ -0.166667841*sin(2.000000000* 6*x)\ -0.142859010*sin(2.000000000* 7*x)\ -0.125002792*sin(2.000000000* 8*x) reset </syntaxhighlight> {{AutoCat}} fro49tstzmaxr9gaznikbmgwn2k5ye2