Wikilivres
frwikibooks
https://fr.wikibooks.org/wiki/Accueil
MediaWiki 1.46.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
Photographie/Fabricants/Olympus
0
6159
763716
759818
2026-04-15T10:45:13Z
Banffy
34456
/* Série OM-System */
763716
wikitext
text/x-wiki
{{Ph s Fabricants}}
{{EnTravaux}}
== À classer ==
<gallery>
Olympus Superzoom 110 BW 1.JPG|Superzoom 110 BW
Quick Flash AFL.jpg|Quick flash
Olympus SZIII stereo microscope.jpg
Olympus Stylus.jpg|Stylus
Olympus mju ii.jpg|[[/Olympus Mju II/]]
Olympus C-960 Zoom.jpg|C 960
Olympus Superzoom 120TC.jpg|Olympus
</gallery>
== Appareils 18 x 24 ==
<gallery>
Olympus Pen img 0048.jpg|[[/Olympus Pen|Olympus Pen EE]]
Olympus Pen img 1197.jpg|Olympus Pen
Olympus pen camera.JPG|Olympus Pen
Olympus Pen 6867.jpg
Olympus Pen.jpg
Olympus Pen 4397.jpg|[[/Olympus Pen révision 3|Olympus Pen révision 3]] (1959)
Pen s 130503 019 (8705222446).jpg|[[/Olympus Pen S/]] (vers 1960)
Image:IMG.svg|Olympus Pen EM
Olympus Pen EE (type 1).jpg|[[/Olympus Pen EE|Olympus Pen EE (type 1)]] (vers 1968) {{25}}
Olympus Pen EE-2 241-2599.jpg|[[/Olympus Pen EE-2|Olympus Pen EE-2]]
Olympus pen ee3.jpg|[[/Olympus Pen EE-3|Olympus Pen EE-3]]
Olympus Pen EE3.jpg|Olympus Pen EE-3
Olympus pen ees.jpg|[[/Olympus Pen EE S|Olympus Pen EE S]]
Olympus PEN-EE S (meio quadro).jpg|Olympus Pen EE S
Olympus Pen EES2.jpg|[[/Olympus Pen EES-2|Olympus Pen EES-2]]
Olympus Pen EED.jpg|Olympus Pen EED
Olympus pen eed.jpg|Olympus Pen EED
0607 Olympus EED with lens cap (9122191695).jpg|Olympus Pen EED
0606 Olympus EED no lens cap (9124412452).jpg|Olympus Pen EED
MelvL P4290007 (5669556412).jpg|Olympus Pen EED
MelvL P4290003 (5669548202).jpg|Olympus Pen EED
MelvL P4290009 (5669557980).jpg|Olympus Pen EED
MelvL P4290006 (5669403335).jpg|Olympus Pen EED
MelvL P4290004 (5668984407).jpg|Olympus Pen EED
MelvL P4290008 (5668985957).jpg|Olympus Pen EED
MelvL P1040065 (5703691290).jpg|Olympus Pen EED
MelvL (5703980674).jpg|Olympus Pen EED
MelvL P1040116 (5709471065).jpg|Olympus Pen EED
MelvL P1040153 (5730856146).jpg|Olympus Pen EED
MelvL P1040155 (5726118704).jpg|Olympus Pen EED
Olympus Pen EES-2 (6717094541).jpg|Olympus Pen EES-2
Pen D3.jpg|[[/Olympus Pen D3|Olympus Pen D3]] (1965-1969)
Olympus PenF.jpg|[[/Olympus Pen F|Olympus Pen F]] (vers 1963)
Olympus-Pen-FT-with-38mm1 8.jpg|[[/Olympus Pen FT|Olympus Pen FT]] (vers 1968) {{75}}
</gallery>
== Appareils 24 x 36 reflex ==
<gallery>
Olympus FTL front.jpg|[[/Olympus FTL/]] (1971-1972) {{25}}
Olympus OM-1 (13573573703).jpg|[[/Olympus OM-1/]] (1973-1974) {{25}}
OM1NB 1.jpg|[[/Olympus OM-1N/]]
Olympus OM1MD.jpg|[[/Olympus OM-1 MD/]] (avant 1979) {{50}}
OM1-n MD (4072626146).jpg|[[/Olympus OM1-n MD/]] (1979-1983)
Olympus OM-2 with Zuiko 50mm f1.8.jpg|[[/Olympus OM-2/]] (1976) {{75}}
Olympus OM-2N img 0732.jpg|[[/Olympus OM-2N/]] {{25}}
Olympus OM-2 SP.jpg|[[/Olympus OM-2 SP/]] {{25}}
Olympus OM10 35-70mm.jpg|[[/Olympus OM10/]] (1978) {{100}}
Olympusom3.jpg|[[/Olympus OM-3/]] {{25}}
Olympus OM3 ti.jpg|OM-3 Ti
OM-3Ti Black.jpg
Olympus OM3 ti OM4 ti.jpg
Olympus OM20 - Tokino 70-210.jpg|[[/Olympus OM20|Olympus OM20 = Olympus OMG (A)]] (1982)
Olympus OM-30 (bottom).jpg|[[/Olympus OM30/]] (1982)
OlympusOM4 1.JPG|[[/Olympus OM-4/]] {{50}}
Olympus OM4 ti 01.jpg|OM-4 Ti
Olympus OM-4 Ti.JPG|OM-4 Ti
Olympus OM-4Ti worn black body with Zuiko 1.8-50mm lens and neckstrap.jpg
Vintage Olympus OM-PC (aka OM-40) 35mm SLR Film Camera, Made In Japan, Circa 1985 (13517132323).jpg|[[/Olympus OM-40|Olympus OM-40 = OM-PC]] (vers 1985)
</gallery>
== Appareils 24 x 36 compacts ==
<gallery>
Olympus LT1 (3007523325).jpg|[[/Olympus LT1/]]
Olympus Superzoom 3000.jpg|Olympus Superzoom 3000
Olympus XA camera and film.jpg|[[/Olympus XA/]] {{25}}
My Olympus XA1 (4379061989).jpg|[[/Olympus XA1/]]
My Olympus XA2 (4989175842).jpg|[[/Olympus XA2/]]
Olympus Ecru.jpg|Olympus Ecru
Olympus Ecru (4766955124).jpg|[[/Olympus Ecru/]] série limitée du Mju
Olympus Ecru cap.jpg
Olympus Ecru back.jpg
Olympus Ecru front.jpg
Olympus Ecru 01.jpg
Olympus Wide.jpg|Olympus wide
Olympus Trip 35.jpeg|[[/Olympus Trip 35/]] (vers 1968) {{50}}
Olympus-35 ECR.jpg|Olympus-35 ECR
Olympus-35 SP.jpg|[[/Olympus 35 SP/]] (1968) {{25}}
Olympus35DC3.jpg|35 DC
Olympus35DC2.jpg|35 DC
Olympus35DC1.jpg|35 DC
My Olympus 35DC (4797809987).jpg|35 DC
Olympus 35 RC img 1850.jpg|[[/Olympus 35 RC/]] (avant 1977) {{50}}
Olympus 35RD.jpg|35 RD
Olympus Stylus Epic 1118.jpg|[[/Olympus mju II|Olympus mju II = Olympus Stylus Epic]] {{25}}
My Olympus XA-3 (4024574761).jpg|[[/Olympus XA3/]]
Olympus XA4 Macro (2388651901).jpg|[[/Olympus XA4 Macro/]]
Olympus mju i.jpg|mju 1
Mju (3645746098).jpg
2009-11-26-Olympus-700BF-1.jpg|700 BF
2009-11-26-Olympus-700BF-2.jpg|700 BF
2009-11-26-Olympus-700BF-3.jpg|700 BF
Olympus-stylus hg.jpg[Stylus zoom 115
Olympus Superzoom 120 1a.jpg|Superzoom 120
Olympus Superzoom 120TC.jpg|Olympus Superzoom 120TC
My Olympus AF-1 Infinity (4876749434).jpg|[[/Olympus AF-1 Infinity/]]
Olympus Infinity Jr. (4815671398).jpg|[[/Olympus Infinity Jr./]]
Olympus AZ-200 Superzoom.jpg|Olympus AZ-200 Superzoom
Olympus Trip MD3.jpg|Olympus Trip MD3
Olympus LT-105Z (6733278979).jpg|Olympus LT-105Z
</gallery>
== Appareils 24x36 bridge ==
<gallery>
My Olympus IS-1 (4662576887).jpg|[[/Olympus IS-1|Olympus IS-1]]
Olympus IS10 (3) (5789273975).jpg|[[/Olympus IS-10|Olympus IS-10]] {{25}}
Olympus ED 35-180 (6175609523).jpg|[[/Olympus IS-3000/]] (1993)
Olympus-IS-100-07.jpg|[[/Olympus IS-100|Olympus IS-100]] (1994) {{25}}
Olympus IS100S (5) (5789275039).jpg|[[/Olympus IS-100S|Olympus IS-100S]] {{25}}
Olympus Alvesgaspar.jpg|[[/Olympus IS-1000|Olympus IS-1000]] {{25}}
</gallery>
== Appareils pour le format AGFA Rapid ==
<gallery>
Image:IMG.svg|[[/Olympus Pen RAPID EES|Olympus Pen RAPID EES]]
Image:IMG.svg|[[/Olympus Pen RAPID EED|Olympus Pen RAPID EED]]
</gallery>
== Appareils pour le format 126 ==
<gallery>
Olympus Quickmatic 600 (2759484117).jpg|[[/Olympus Quickmatic 600|Olympus Quickmatic 600]]
</gallery>
== Appareils pour le format APS ==
<gallery>
Olympus i zoom 2000 (3854940049).jpg|[[/Olympus i zoom 2000/]] (2000)
</gallery>
== Appareils numériques non reflex ==
=== année 1996 ===
<gallery>
Image:IMG.svg|[[/Olympus D-200L|Olympus D-200L]] {{50}} (5 septembre 1996)
Image:IMG.svg|[[/Olympus D-300L|Olympus D-300L]] {{50}} (5 septembre 1996)
</gallery>
=== année 1997 ===
<gallery>
Image:Olympus C-820L.jpg|[[/Olympus Camedia C-820L|Olympus Camedia C-820L]] {{50}} (septembre 1997)
File:2009-11-26-Olympus-C-820L-1.jpg|C-820L
File:2009-11-26-Olympus-C-820L-2.jpg|C-820L
File:2009-11-26-Olympus-C-820L-3.jpg|C-820L
File:2009-11-26-Olympus-C-820L-5.jpg|C-820L
File:2009-11-26-Olympus-C-820L-6.jpg|C-820L
File:2009-11-26-Olympus-C-820L-4.jpg|C-820L
File:Camedia-C-820L-05.jpg|C-820L
File:Camedia-C-820L-02.jpg|C-820L
</gallery>
=== année 1998 ===
<gallery>
Image:IMG.svg|[[/Olympus D-340L|Olympus D-340L]] {{50}} (28 septembre 1998)
File:Olympus C-900 ZOOM.jpg|[[/Olympus D-400|Olympus D-400 = Stylus Digital 400 = Olympus C900Z)]] {{75}} (2 novembre 1998)
</gallery>
=== année 1999 ===
<gallery>
Image:IMG.svg|[[/Olympus D-340R|Olympus D-340R]] {{50}} (2 janvier 1999)
File:Olympus Camedia C-2000 Z.jpg|[[/Olympus C-2000 Zoom|Olympus C-2000 Zoom]] {{50}} (16 février 1999)
Image:IMG.svg|[[/Olympus C-21|Olympus C-21]] {{50}} (28 juin 1999)
File:Olympus Camedia C-21T.commu CP+ 2011.jpg|Olympus Camedia C-21T.commu
Image:IMG.svg|[[/Olympus D-450 Zoom|Olympus D-450 Zoom = Olympus C920Z]] {{50}} (31 juillet 1999)
Image:IMG.svg|[[/Olympus C-2020 Zoom|Olympus C-2020 Zoom]] (19 octobre 1999)
</gallery>
=== année 2000 ===
<gallery>
Olympos-Camedia-C3000.jpg|C3000
Image:IMG.svg|[[/Olympus C-3030 Zoom|Olympus C-3030 Zoom]] (27 janvier 2000)
Image:IMG.svg|[[/Olympus D-360L|Olympus D-360L]] (2 février 2000)
Image:IMG.svg|[[/Olympus C-460 Zoom|Olympus C-460 Zoom]] (8 février 2000)
Image:IMG.svg|[[/Olympus C-3000 Zoom|Olympus C-3000 Zoom]] (24 avril 2000)
Image:Olympus UZ-2100 03.jpg|[[/Olympus C-2100 Ultra Zoom|Olympus C-2100 Ultra Zoom]] (15 juin 2000)
Image:Olympus UZ-2100 01.jpg
Image:Olympus UZ-2100 02.jpg
Image:IMG.svg|[[/Olympus D-490 Zoom|Olympus D-490 Zoom]] (1er août 2000)
File:Olympus E100RS.jpg|[[/Olympus E-100 RS|Olympus E-100 RS]] (22 août 2000)
File:Olympus Camera E-100RS.jpg|E-100 RS
Image:IMG.svg|[[/Olympus C-3040 Zoom|Olympus 3-2040 Zoom]] (21 novembre 2000)
Image:IMG.svg|[[/Olympus C-2040 Zoom|Olympus C-2040 Zoom]] (21 novembre 2000)
</gallery>
=== année 2001 ===
<gallery>
Olympus Camedia C-1.jpg|[[/Olympus C-1|Olympus C-1]] (6 mars 2001)
Olympus C-700 Ultra Zoom.jpg|[[/Olympus C-700 Ultra Zoom|Olympus C-700 Ultra Zoom]] (19 mars 2001)
IMG.svg|[[/Olympus D-150 Zoom|Olympus D-150 Zoom]] (8 mai 2001)
IMG.svg|[[/Olympus D-510 Zoom|Olympus D-510 Zoom]] (8 mai 2001)
IMG.svg|[[/Olympus D-370|Olympus D-370]] (5 juin 2001)
IMG.svg|[[/Olympus C-4040 Zoom|Olympus C-4040 Zoom]] (20 juin 2001)
IMG.svg|[[/Olympus D-40 Zoom|Olympus D-40 Zoom]] (2 septembre 2001)
Olympus Camedia C-2.jpg|[[/Olympus C-2|Olympus C-2]] (13 septembre 2001)
Olympus Camedia C-3020.jpg|[[/Olympus C-3020 Zoom|Olympus C-3020 Zoom]] (15 octobre 2001)
</gallery>
=== année 2002 ===
<gallery>
File:My Olympus D-520Z (4794377895).jpg|[[/Olympus D-520 Zoom|Olympus D-520 Zoom]] (13 mars 2002)
File:Olympus D-380.jpg|[[/Olympus D-380|Olympus D-380 = Olympus C-120]] (13 mars 2002)
Olympus C-2020Z.jpg|Olympus Camedia C-2020Z
OlympusC220ZoomCamera.jpg|C220Z
File:Olympus Camedia C-720.jpg|[[/Olympus C-720 Ultra Zoom|Olympus C-720 Ultra Zoom]] (8 mai 2002)
Image:IMG.svg|[[/Olympus C-300 Zoom|Olympus C-300 Zoom]] (8 mai 2002)
Image:IMG.svg|[[/Olympus C-4000 Zoom|Olympus C-4000 Zoom]] (25 juillet 2002)
File:Olympus C-5050Z, -Apr. 2007 a.jpg|[[/Olympus C-5050 Zoom|Olympus C-5050 Zoom]] (19 août 2002)
File:Olympus C-5050Z, -6 Aug. 2006 a.jpg|C-5050
File:Olympus C-5050Z, -19 Nov. 2005 a.jpg|C-5050
Image:Olympus C-730UZ Front Left.jpg|[[/Olympus C-730 UZ|Olympus C-730 UZ]] (12 septembre 2002)
Image:IMG.svg|[[/Olympus C-50 Zoom|Olympus C-50 Zoom]] (24 septembre 2002)
</gallery>
=== année 2003 ===
<gallery>
Image:IMG.svg|[[/Olympus Stylus 400|Olympus Stylus 400 = Olympus µ 400 Digital]] (9 janvier 2003)
Image:IMG.svg|[[/Olympus Stylus 300|Olympus Stylus 300 = Olympus µ 300 Digital]] (9 janvier 2003)
Fichier:Olympus Camedia C-740 Ultra Zoom 10.JPG|[[/Olympus C-740 Ultra Zoom|Olympus C-740 Ultra Zoom]] {{75}} (2 mars 2003)
File:Olympus C-150.JPG|[[/Olympus Camedia C-150|Olympus Camedia C-150 = Olympus D-390]] (2 mars 2003)
Image:Olympus Camedia C-350 Zoom -3.JPG|[[/Olympus D-560 Zoom|Olympus D-560 Zoom = Camedia C-350 zoom]] (2 mars 2003)
Image:Olympus Camedia C-350 Zoom -2.JPG
Image:Olympus Camedia C-350 Zoom -1.JPG
Image:Olympus Camedia C-350 Zoom.JPG
Image:Olympus-C350Z.jpg
Image:Olympus C-750.jpg|[[/Olympus C-750 Ultra Zoom|Olympus C-750 Ultra Zoom]] (2 mars 2003)
Image:Olympus C-750 back.jpg
Image:Olympus C-750 front right-1.jpg
Image:Olympus C-750 front right.jpg
Image:Olympus C-750 front left.jpg
Image:Digital Camera.jpg|[[/Olympus C-5000 Zoom|Olympus C-5000 Zoom]] (29 août 2003)
Olympus Camedia C-5000Z 3750.jpg
Olympus Camedia C-5000Z 3751.jpg
Olympus Camedia C-5000Z 3752.jpg
Olympus Camedia C-5000Z 3753.jpg
Olympus Camedia C-5000Z 3754.jpg
Olympus Camedia C-5000Z 3755.jpg
Olympus Camedia C-5000Z 3756.jpg
Image:IMG.svg|[[/Olympus C-5060 Zoom|Olympus C-5060 Zoom]] (29 septembre 2003)
</gallery>
=== année 2004 ===
<gallery>
IMG.svg|[[/Olympus D-540 Zoom|Olympus D-540 Zoom]] (14 février 2004)
IMG.svg|[[/Olympus D-580 Zoom|Olympus D-580 Zoom]] (14 février 2004)
Stylus410specs.jpg|[[/Olympus Stylus 410|Olympus Stylus 410]] (14 février 2004)
OLYMPUS C-8080WZ 01.jpg|[[/Olympus C-8080 WideZoom|Olympus C-8080 WideZoom]] (14 février 2004)
Olympus CAMEDIA C-8080.JPG|C-8080
C-8080WZ rear.JPG|C-8080
C-8080WZ tele.JPG|C-8080
Olympus C-760 UltraZoom (2178205925).jpg|[[/Olympus Camedia C-760 UZ/]]
Olympus C-765UZ, -13 juni 2006 a.jpg|[[/Olympus C-765 Ultra Zoom|Olympus C-765 Ultra Zoom]] (14 février 2004)
Olympus C-766 UZ back.jpg
Olympus C-765 UZ front.jpg
IMG.svg|[[/Olympus C-770 Ultra Zoom|Olympus C-770 Ultra Zoom]] (14 février 2004)
Olympus D-395.JPG|[[/Olympus D-395|Olympus D-395]] (18 mars 2004)
Olympus C-60 Zoom.JPG|[[/Olympus C-60 Zoom|Olympus C-60 Zoom]] (18 mars 2004)
Olympus µ-mini.jpeg|[[/Olympus Stylus Verve|Olympus Stylus Verve = Olympus Mju-mini = Olympus mju-ii]] (3 septembre 2004)
IMG.svg|[[/Olympus C-7000 Zoom|Olympus C-7000 Zoom]] (16 septembre 2004)
IMG.svg|[[/Olympus D-535 Zoom|Olympus D-535 Zoom]] (16 septembre 2004)
IMG.svg|[[/Olympus Stylus 500|Olympus Stylus 500]] (29 novembre 2004)
</gallery>
=== année 2005 ===
<gallery>
IMG.svg|[[/Olympus D-425/]] (5 janvier 2005)
IMG.svg|[[/Olympus C-7070 Wide Zoom/]] (5 janvier 2005)
IMG.svg|[[/Olympus C-5500 Sport Zoom/]] (5 janvier 2005)
IMG.svg|[[/Olympus Stylus Verve S/]] (17 février 2005)
IMG.svg|[[/Olympus D-545 Zoom/]] (17 février 2005)
Olympus C-500Z 3.JPG|[[/Olympus D-595 Zoom|Olympus D-595 Zoom = Olympus C-500Z]] (17 février 2005)
Olympus C-500Z 2.JPG
Olympus C-500Z 1.JPG
Olympus IR-300.jpg|[[/Olympus IR-300/]] (17 février 2005)
IMG.svg|[[/Olympus D-630 Zoom/]] (17 février 2005)
IMG.svg|[[/Olympus Stylus 800/]] (12 mai 2005)
IMG.svg|[[/Olympus D-435/]] (20 mai 2005)
Olympus FE 110 (2254131662).jpg|[[/Olympus FE-110/]] (29 août 2005)
Olympus-SP-310-p1030353.jpg|[[/Olympus SP-310/]] {{00}} (29 août 2005)
Olympus FE-120 01.jpg|[[/Olympus FE-120/]] {{50}} (29 août 2005)
IMG.svg|[[/Olympus Stylus 600/]] (29 août 2005)
Oly SP-350-1.jpg|[[/Olympus SP-350/]] {{25}} (29 août 2005)
IMG.svg|[[/Olympus SP-500 UZ/]] {{25}} (29 août 2005)
Olympus FE-100 front.jpg|[[/Olympus FE-100/]] (29 août 2005)
IMG.svg|[[/Olympus SP-700/]] (4 octobre 2005)
</gallery>
=== année 2006 ===
<gallery>
File:My Olympus SP-320 (4171943306).jpg|[[/Olympus SP-320|Olympus SP-320]] (26 janvier 2006) {{50}}
Image:IMG.svg|[[/Olympus FE-115|Olympus FE-115]] (26 janvier 2006) {{25}}
File:Olympus-digitale-camera-FE-130.JPG|[[/Olympus FE-130|Olympus FE-130]] (26 janvier 2006) {{50}}
Image:IMG.svg|[[/Olympus FE-140|Olympus FE-140]] (26 janvier 2006) {{25}}
Image:IMG.svg|[[/Olympus FE-150|Olympus FE-150]] (26 janvier 2006) {{25}}
Image:Olympus µ 700.jpg|[[/Olympus Stylus 700|Olympus Stylus 700 = Mju 700 Digital]] (26 janvier 2006) {{50}}
Image:IMG.svg|[[/Olympus Stylus 720 SW|Olympus Stylus 720 SW = Olympus Mju 720 SW Digital]] (26 janvier 2006)
Image:IMG.svg|[[/Olympus Stylus 810|Olympus Stylus 810 = Olympus Mju 810 Digital]] (26 janvier 2006) {{50}}
Image:Olympus X760 01.jpg|[[/Olympus FE-170|Olympus FE-170 = Olympux X-760]] (24 août 2006)
Image:IMG.svg|[[/Olympus FE-180|Olympus FE-180]] (24 août 2006) {{25}}
Image:Olympus FE190.JPG|[[/Olympus FE-190|Olympus FE-190]] (24 août 2006) {{50}}
Image:IMG.svg|[[/Olympus FE-200|Olympus FE-200]] (24 août 2006) {{50}}
File:Olympus μ 725 SW.jpg|[[/Olympus Stylus 725 SW|Olympus Stylus 725 SW = Olympus Mju 725 SW Digital]] (24 août 2006)
Image:IMG.svg|[[/Olympus Stylus 730|Olympus Stylus 730 = Olympus Mju 730 Digital]] (24 août 2006) {{50}}
Image:IMG.svg|[[/Olympus Stylus 740|Olympus Stylus 740 = Olympus Mju 740 Digital]] (24 août 2006) {{50}}
Image:IMG.svg|[[/Olympus Stylus 750|Olympus Stylus 750 = Olympus Mju 750 Digital]] (24 août 2006) {{75}}
Image:IMG.svg|[[/Olympus Stylus 1000|Olympus Stylus 1000 = Olympus Mju 1000 Digital]] (24 août 2006) {{75}}
File:OlympusSP510UZ.jpg|[[/Olympus SP-510 UZ|Olympus SP-510 UZ]] (24 août 2006) {{50}}
</gallery>
=== année 2007 ===
<gallery>
Image:Olympus SP 550UZ.jpg|[[/Olympus SP-550 UZ|Olympus SP-550 UZ]] (25 janvier 2007) {{100}}
File:Fe-210.png|[[/Olympus FE-210|Olympus FE-210 = X-775]] (25 janvier 2007) {{75}}
Image:IMG.svg|[[/Olympus FE-230|Olympus FE-230]] (25 janvier 2007) {{75}}
Image:IMG.svg|[[/Olympus FE-240|Olympus FE-240]] (25 janvier 2007) {{75}}
Image:IMG.svg|[[/Olympus FE-250|Olympus FE-250]] (25 janvier 2007) {{75}}
Image:Olympus µ 760.jpg|[[/Olympus Stylus 760|Olympus Stylus 760 = Olympus mju 760 Digital]] (25 janvier 2007) {{75}}
Image:Stylus 770SW.jpg|[[/Olympus Stylus 770 SW|Olympus Stylus 770 SW = Olympus mju 770 SW Digital]] (25 janvier 2007) {{100}}
Image:IMG.svg|[[/Olympus Stylus 780|Olympus Stylus 780]] (5 mars 2007)
Image:IMG.svg|[[/Olympus FE-270|Olympus FE-270]] (23 août 2007) {{75}}
Image:IMG.svg|[[/Olympus FE-280|Olympus FE-280]] (23 août 2007) {{75}}
Image:IMG.svg|[[/Olympus FE-290|Olympus FE-290]] (23 août 2007) {{75}}
Image:IMG.svg|[[/Olympus FE-300|Olympus FE-300]] (23 août 2007) {{75}}
Image:IMG.svg|[[/Olympus Stylus 790 SW|Olympus Stylus 790 SW = Olympus mju 790 SW Digital]] (23 août 2007) {{75}}
Image:IMG.svg|[[/Olympus Stylus 820|Olympus Stylus 820 = Olympus mju 820 Digital]] (23 août 2007) {{75}}
File:OLYMPUS Mu 830.jpeg|[[/Olympus Stylus 830|Olympus Stylus 830 = Olympus mju 830 Digital]] (23 août 2007) {{100}}
Image:IMG.svg|[[/Olympus Stylus 1200|Olympus Stylus 1200 = Olympus mju 1200 Digital]] (23 août 2007) {{75}}
Image:IMG.svg|[[/Olympus SP-560 UZ|Olympus SP-560 UZ]] (25 janvier 2007) {{75}}
</gallery>
=== année 2008 ===
<gallery>
File:Olympus SP-570UZ, -Nov. 2008 a.jpg|[[/Olympus SP-570 UZ|Olympus SP-570 UZ]] (2008) {{50}}
Image:IMG.svg|[[/Olympus Mju 1040|Olympus Mju 1040]] (2008) {{25}}
Image:IMG.svg|[[/Olympus Mju 1050sw|Olympus Mju 1050sw]] (2008) {{25}}
Image:IMG.svg|[[/Olympus Mju 1060|Olympus Mju 1060]] (2008) {{25}}
Image:IMG.svg|[[/Olympus FE-20|Olympus FE-20]] (2008) {{25}}
Image:IMG.svg|[[/Olympus FE-360|Olympus FE-360]] (19 août 2008) {{75}}
Image:IMG.svg|[[/Olympus FE-370|Olympus FE-370]] (2008) {{25}}
</gallery>
=== année 2009 ===
<gallery>
Bfishadow Olympus E-P1.jpg|Olympus Pen E-P1
Bfishadow Olympus E-P1 bottom.jpg|Olympus Pen E-P1
Bfishadow Olympus E-P1 top.jpg|Olympus Pen E-P1
Bfishadow Olympus E-P1 back.jpg|Olympus Pen E-P1
Olympus Pen img 3486.jpg|Olympus Pen E-P1
Olympus IMG 2163.jpg|Olympus Pen E-P1
Olympus E-P1 (3634318402).jpg
Olympus E-P1 (3634895524).jpg
Olympus E-P1 (3634080929).jpg
Olympus E-P1- Sleek frame (3634087049).jpg
Olympus E-P1 (3634889300).jpg
Olympus E-P1 (3634079289).jpg
Olympus E-P1 (3633504211).jpg
Olympus E-P1 (3634318984).jpg
Olympus E-P1 (3634318918).jpg
Olympus E-P1 (3633503717).jpg
</gallery>
=== année 2010 ===
<gallery>
File:Olympus PEN E-PL1.jpg|[[/Olympus PEN E-PL1|Olympus PEN E-PL1]] (10 février 2010) {{100}}
</gallery>
=== année 2011 ===
<gallery>
File:Olympus E-PL2 with Leica Summicron 50 f2 LTM lens.jpg|[[/Olympus Pen E-PL2|Olympus Pen E-PL2]] (6 janvier 2011) {{75}}
Image:IMG.svg|[[/Olympus SP-610UZ|Olympus SP-610UZ]] (6 janvier 2011) {{75}}
File:Olympus-XZ-1.jpg|[[/Olympus XZ-1|Olympus XZ-1]] (6 janvier 2011) {{100}}
</gallery>
=== année 2012 ===
<gallery>
Image:IMG.svg|[[/Olympus Tough TG-1 iHS|Olympus Tough TG-1 iHS]] (8 mai 2012) {{75}}
</gallery>
=== année 2013 ===
<gallery>
</gallery>
=== année 2014 ===
<gallery>
</gallery>
=== année 2015 ===
<gallery>
Olympus OM-D E-M5II (18421339075).jpg|[[/Olympus OM-D E-M5 II]] (5 février 2015) {{100}}
IMG.svg|[[/Olympus Tough TG-4/]] (13 avril 2015) {{75}}
Olympus OM-D E-M10 Mark II.JPG|[[/Olympus OM-D E-M10 II/]] (25 août 2015) {{75}}
</gallery>
=== année 2016 ===
<gallery>
Olympus PEN-F.jpg|[[/Olympus Pen-F/]] (27 janvier 2016) {{00}}
</gallery>
==== à classer ====
<gallery>
2013-265-121 Tough New Toy (8700211111).jpg|Olympus Tough TG-2
OLYMPUS PEN MINI E-PM2 (8651180173).jpg|Olympus E-PM2
Olympus E-20P 01.jpg|Olympus E-20P
Olympus E-20P 02.jpg
Olympus E-20P 03.jpg
Olympus E-20P 04.jpg
Olympus E-20P 05.jpg
Olympus E-20P 06.jpg
Olympus E-20P 07.jpg
Olympus E-20P 08.jpg
Olympus E-20P 09.jpg
Olympus E-20P 10.jpg
Olympus E-20P 11.jpg
Olympus X-750.jpg|Olympus X-750
Olympus PEN F.jpg|Olympus PEN F
Olympus Pen F (digital).jpg|Olympus PEN F
Olympus Pen F-IMG 9925.jpg|Olympus PEN F
Olympus Pen F-IMG 9927.JPG|Olympus PEN F
Olympus PEN F.jpg|Olympus PEN F
Olympus PEN-F.jpg|Olympus PEN F
Olympus PEN E-PL6 black kit lens 2016-03-03.jpg|Olympus PEN E-PL6
Olympus OM D E-M1 - Johnragai Gear - 09.11.2014 (15772393942).jpg|Olympus OM-D E-M1
Olympus OM D E-M1 - Johnragai Gear - 09.11.2014 (15770833985).jpg
OM D E-M1 with 75mm f-1.8 (9869006524).jpg
OM D E-M1 with 12-60mm f-2.8-4 ED SWD (9869086256).jpg
Olympus OM-D E-M1 Mark II mock-up (rough model) 2017 CP+.jpg|Olympus OM-D E-M1 Mark II
Olympus OM-D E-M1 Mark II mock-up (design model) 2017 CP+.jpg
Olympus OM-D E-M1 Mark II mock-up (body+power battery holder model) 2017 CP+.jpg
Olympus OM-D E-M1 Mark II magnesium-alloy chassis 2017 CP+.jpg
Olympus OM-D E-M1 Mark II D81 8378-2.jpg
Olympus.OM-D.E-M1.Mark.II.back.view.jpg
Olympus C-760 UltraZoom (2178205925).jpg|[[/Olympus Camedia C-760 UZ/]]
Olympus camedia C-800L.jpg|[[/Olympus Camedia C-800L/]]
Olympus SH-1 zilver, -2015 a.jpg|Olympus SH-1
Olympus SH-1 zilver, -2015 b.jpg
Olympus SH-1 zilver, -2015 c.jpg
Olympus Pen EPL7.JPG|[[/Olympus Pen E-PL7/]]
Digitalkamera von Olympus.JPG|FE-5020
Olympus XZ-10, -februari 2013 a.jpg|Olympus XZ-10
Leong IMG 2989 (6338669929).jpg
Leong IMG 2986 (6339413622).jpg
Leong IMG 0497 (6689370435).jpg
P1000963 (6707919805).jpg
Olympus OM-D E-M10 2014 CP+.jpg|Olympus OM-D E-M10
Olympus OM-D E-M10 01.jpg
Olympus OM-D E-M10 cutaway 2014 CP+.jpg|Olympus OM-D E-M10
Olympus OM-D E-M10 cutted 1.jpg
Olympus OM-D E-M10 cutted 2.jpg
Olympus PEN E-P5 Back 1.jpg|Olympus PEN E-P5
Olympus PEN E-P5 Front 1.jpg
Olympus PEN E-P5 Front 2.jpg
EP5 with 45mm F1.8.jpg
Olympus E-P5.jpg
Olympus X-500 D-590Z C-470Z.jpg|X-500 = D-590Z = C-470Z
Olympus E-PM1 + BCL-15.jpg|Olympus E-PM1
Olympus TG-820 Camera.jpg|Olympus TG-820
Olympus TG-820 front with lens cover open.jpg|Olympus TG-820
Olympus TG-820 Top.JPG|Olympus TG-820
Olympus E-P2.jpg|Olympus E-P2
Micro Four Thirds Olympus E-P2 with Panasonic Lumix G 20mm F1.7 ASPH aspherical pancake lens.jpg
Olympus EPL5 top.jpg|E-PL5
Olympus EPL5 back 01.jpg|E-PL5
Olympus EPL5 back 02.jpg|E-PL5
Olympus EPL5 front lens.jpg|E-PL5
Olympus EPL5 front.jpg|E-PL5
Olympus epl5 vf4.jpg
Olympus epl5 vf4 45mm 01.jpg
Olympus epl5 vf4 45mm 02.jpg
Olympus epl5 vf4 45mm 03.jpg
Olympus PEN Lite and Nissin i40.jpg
Olympus VR-340.JPG|Olympus VR-340
Olympus-digitale-camera-X-720.JPG|Olympus X-720
Olympus Camedia C-310 Zoom Digital Camera.JPG|Olympus Camedia C-310 Zoom
Olympus PEN E-PL3.jpg|[[/Olympus Pen E-PL3|Olympus Pen E-PL3]]
Olypmus SP-810UZ, closed.jpg|Olympus SP-810UZ
Olympus_SP-810UZ,_no_zoom,_flash_closed.jpg|Olympus SP-810UZ
Olympus_SP-810UZ,_full_zoom,_flash_opened.jpg|Olympus SP-810UZ
Olympus E-P3 006.JPG|[[/Olympus E-P3/]]
Olympus AZ-300 Superzoom.jpg|[[/Olympus AZ-300 Superzoom/]]
Olympus SP560UZ DSCF9120.jpg|SP560 UZ
Olympus u5000.jpg|Mju 5000
Stylus Tough 8000.jpg|Stylus Tough 8000
Olympus_SP590_UZ_01_(RaBoe).jpg|SP590 UZ
Olympus_SP590_UZ_02_(RaBoe).jpg|SP590 UZ
Olympus_SP590_UZ_03_(RaBoe).jpg|SP590 UZ
Olympus_SP590_UZ_04_(RaBoe).jpg|SP590 UZ
Olympus_SP590_UZ_05_(RaBoe).jpg|SP590 UZ
Olympus_SP590_UZ_06_(RaBoe).jpg|SP590 UZ
Olympus_SP590_UZ_07_(RaBoe).jpg|SP590 UZ
Olympus SP590 UZ 2010-by-RaBoe-02.jpg
Olympus SP590 UZ 2010-by-RaBoe-01.jpg
FE-310.jpg|FE-310
Image:Olympus u850SW.jpg|mju 850SW
Olympus µ850SW, -1 mei 2010 a.jpg
Image:Olympus img 1845.jpg|C-1400
Image:Olympus_FE-340_8MP_camera_01.jpg|FE-340
Olympus-MicroFT-Model.jpg|Micro four thirds
OlyE-P2Test10112009-01.jpg|EP-2
Olympus VR-310.jpg|Olympus VR-310
</gallery>
== Appareils reflex numériques ==
=== année 1997 ===
<gallery>
Image:IMG.svg|[[/Olympus D-500L|Olympus D-500L]] {{50}} (10 septembre 1997)
Image:IMG.svg|[[/Olympus D-600L|Olympus D-600L]] {{50}} (10 septembre 1997)
Image:Olympus img 1846.jpg|C-1400
Image:Olympus img 1845.jpg|C-1400
</gallery>
=== année 1998 ===
<gallery>
Image:Olympus C-1400 01.jpg|[[/Olympus D-620L|Olympus D-620L = Olympus C1400XL]] (2 novembre 1998) {{75}}
Image:Olympus Camedia C 1400 XL 61.jpg|[[/Olympus D-620L|Olympus D-620L = Olympus C1400XL]] (2 novembre 1998) {{75}}
Image:Olympus Camedia C 1400 XL 57.jpg|[[/Olympus D-620L|Olympus D-620L = Olympus C1400XL]] (2 novembre 1998) {{75}}
</gallery>
=== année 1999 ===
<gallery>
Image:IMG.svg|[[/Olympus C-2500 L|Olympus C-2500 L]] {{50}} (18 mars 1999)
</gallery>
=== année 2000 ===
<gallery>
File:Olympus E-10.jpg|[[/Olympus E-10|Olympus E-10]] (22 août 2000)
File:Olympus E-20P.JPG|[[/Olympus E-20|Olympus E-20]]
Olympus E-20n.jpg|Olympus E-20n
</gallery>
=== année 2003 ===
<gallery>
Image:Olympus E-1 2.jpg|[[/Olympus E-1|Olympus E-1]] (24 juin 2003)
Image:E-1 hinten oben.jpg
Image:E-1 Seite hinten.jpg
Image:E-1 hinten.jpg
File:E-1 vorne.jpg
File:Olympus E-1 body.jpg
</gallery>
=== année 2004 ===
<gallery>
E-300.jpg|[[/Olympus E-300|Olympus E-300 (EVOLT E-300)]] (27 septembre 2004)
</gallery>
=== année 2005 ===
<gallery>
File:E-500 Body.jpg|[[/Olympus E-500|Olympus E-500]] {{25}} (2005)
Olympus E-500 with Minolta MD Lens (5391265164).jpg|[[/Olympus E-500/]] (26 septembre 2005)
</gallery>
=== année 2006 ===
<gallery>
File:Olympus E-330. Zuiko Digital ED.jpg|[[/Olympus E-330|Olympus E-330 = EVOLT E-330]] {{25}} (26 janvier 2006)
Image:Oly e 400 voorkant.jpg|[[/Olympus E-400|Olympus E-400]] {{75}} (14 septembre 2006)
</gallery>
=== année 2007 ===
<gallery>
Olympus E410 img 1030.jpg|[[/Olympus E-410/]] (5 mars 2007)
Olympus E510 img 1029.jpg|[[/Olympus E-510/]] (5 mars 2007)
P3069465 (3333310515).jpg|[[/Olympus E-3/]]
Olympus_E-3_Camera.jpg|[[/Olympus E3/]] {{75}} (16 octobre 2007)
</gallery>
=== année 2008 ===
<gallery>
Olympus E-420.jpg|E-420
Olympus E-420 EZ40150.jpg|E-420
Olympus E-420 Body Front.jpg|E-420
Olympus E-420 Pancake25mm Top.jpg|E-420
Olympus E-420 Body Top XL.jpg|E-420
Image:Olymous E420 img 1248.jpg|E-420
Image:Olympus E-420 (back).jpg|E-420
Image:Olympus E-420 (front).jpg|E-420
</gallery>
=== année 2009 ===
<gallery>
Olympus E-450.JPG|[[/Olympus E-450|Olympus E-450]] {{75}} (31 mars 2009)
Olympus E-30 01.jpg|Olympus E-30
Olympus E-30 02.jpg|Olympus E-30
Olympus E-30 03.jpg|Olympus E-30
Olympus E-30 04.jpg|Olympus E-30
Olympus E-30 rear01.jpg|E30
Olympus E-30 front01.jpg|E30
E-30-back.jpg|E3
Olympus E-30 with ZD 14-54mm f2.8-3.5II 01.JPG|E30
Olympus E-30-Cutmodel.jpg|E30 coupé
E-30-with-14-54.jpg|E30
Olympus E30-IMG 2445.jpg|E30
Olympus E30-IMG 2442.jpg|E30
Olympus E30-IMG 2441.jpg|E30
Olympus E-620 front.jpg|E-620
Olympus E-620.jpg|E-620
Olympus E-620 with battery grip.jpg|E-620
Olympus E-620 without lens.jpg|E620
Olympus E-620 swivel screen open.JPG|E620
Olympus E620 DSLR.jpg|E620
</gallery>
=== année 2010 ===
<gallery>
Olympus-E5.jpg|E-5
</gallery>
'''à classer'''
<gallery>
Olympus OM-D E-M1- 20131118.jpg|Olympus OM-D E-M1
OM-D EM-1.jpg|OM-D EM-1
Oly-EM1-connector.jpg
Olympus OM-D E-M1 01.jpg
Olympus OM-D E-M1 cutted 1.jpg
Olympus OM-D E-M1 cutted 2.jpg
Olympus OM-D E-M1 cutted 3.jpg
Olympus OM-D E-M1 image stabilization unit.jpg
Olympus E-M5 (front, cropped).jpg|OM-D E-M5
Oly-E-M5.jpg
OLYMPUS OM-D E-M5.jpg
Olympus OM-D E-M5.jpg
Olympus E-M5, Nokton 25mm.jpg
Olympus E-M5 01.jpg
Olympus E-M5 02.jpg
Olympus E-M5 03.jpg
Olympus E-M5 04.jpg
Olympus E-M5 05.jpg
Olympus E-M5 06.jpg
Olympus E-M5 08.jpg
Olympus E-M5 07.jpg
Olympus E-M5 09.jpg
Olympus E-M5 10.jpg
Olympus E-M5 11.jpg
Olympus E-M5 12.jpg
Olympus E-M5 13.jpg
Olympus E-M5 14.jpg
Olympus E-M5 15.jpg
Olympus E-M5 16.jpg
Olympus OM-D E-M5, Taipei, TW.jpg
Olympus E-M5 + Bigma.jpg
Micro Four Thirds Olympus OM-D E-M5 digital camera.jpg
Oly-E-M5.jpg
Olympus OM-D E-M5 Elite black kit.jpg
Olympus OM-D E-M5 Elite black.jpg
Olympus E-M5 with 45mm F1.8.jpg
Olympus OM 50mm f1.8.jpg|E-420
Olympus-e-520-front.png|[[/Olympus E-520|Olympus E-520]]
</gallery>
== Modules pour smartphone ==
<gallery>
Olympus Air A01,mounted lens and phone.jpg|Olympus Air A01
</gallery>
== Objectifs à mise au point manuelle ==
=== Série Pen ===
<gallery>
Olympus-20mm-F3 5-with-TTL-No.jpg|[[/Olympus Zuiko 20 mm f/3,5/]]
Image:PenF-Zuiko-20mm.JPG
</gallery>
=== Série FTL (M42) ===
<gallery>
M42.OffenblendOLYMPUS.jpg|Olympus M 42
Olympus FTL G.Zuiko 28 mm f 3,5.jpg|Olympus G.Zuiko 28 mm f/3,5
IMG.svg|Olympus Zuiko 35 mm f/2,8/
IMG.svg|Olympus Zuiko 50 mm f/1,4/
Olympus FTL F. Zuiko 50 mm f 1,8.jpg|Olympus Zuiko 50 mm f/1,8
IMG.svg|Olympus Zuiko 135 mm f/3,5/
IMG.svg|Olympus Zuiko 200 mm f/4,0/
</gallery>
=== Série OM-System ===
<gallery>
OMLenses.jpg
OM Zuiko f2 lenses.jpg|Zuiko f/2
</gallery>
<gallery>
IMG.svg|[[/Olympus Zuiko 8 mm f/2,8/]] (avant 1978) {{25}}
IMG.svg|[[/Olympus Zuiko 16 mm f/3,5/]] (avant 1978) {{25}}
Olympus Zuiko Auto-Macro 20mm 1-2 lens (4243439258).jpg|[[/Olympus Zuiko Auto-Macro 20 mm f/1,2/]]
ZUIKO21mmF2.jpg|[[/Olympus Zuiko 21 mm f/2/]]
Olympus Zuiko 2,0 21mm.jpg|[[/Olympus Zuiko 21 mm f/2/]]
IMG.svg|[[/Olympus Zuiko 21 mm f/3,5/]] (avant 1978) {{25}}
IMG.svg|[[/Olympus Zuiko 24 mm f/2/]] (avant 1978) {{25}}
Obiettivo fotografico ultragrandangolare, messa a fuoco elicoidale, con innesto a baionetta - Museo scienza tecnologia Milano 13087.jpg|Olympus Zuiko Auto-W 24 mm f/2,8 (1991)
Zuiko shift 24mm.jpg|Olympus Zuiko shift 24 mm f/3,5 à décentrement
Olympus Zuiko 24mm f 2.8.JPG|[[/Olympus Zuiko 24 mm f/2,8/]] (avant 1978) {{50}}
IMG.svg|[[/Olympus Zuiko 28 mm f/2/]] (avant 1978) {{25}}
Olympus G. Zuiko 3,5 28mm.jpg|[[/Olympus Zuiko 28 mm f/3,5/]] (avant 1978) {{25}}
Olympus OM 2,8 35 Shift.jpg|Olympus Zuiko shift 35 mm f/2,8 à décentrement
IMG.svg|[[/Olympus Zuiko 35 mm f/2/]] (avant 1978) {{25}}
Olympus G. Zuiko 2,8 35mm.jpg|[[/Olympus Zuiko 35 mm f/2,8/]] (avant 1978) {{25}}
IMG.svg|[[/Olympus Zuiko 35 mm f/3,5 Macro/]] (26 septembre 2005) {{75}}
Olympus Zuiko MC-Macro 1-3,5 f=38mm lens (4243438710).jpg|[[/Olympus Zuiko MB 38 mm f/3,5 Macro/]]
Olympus OM Zuiko Zoom 3570 mm f 4,0.jpg|[[/Olympus Zuiko Auto-zoom 35-70 mm f/4/]] (1982)
OM Auto Zoom 3,6 f=35-70mm-19840912-RM-123616.jpg|[[/Olympus Zuiko MC Auto-zoom 35-70 mm f/3,6/]]
Olympus OM Zuiko Zoom 35-105 f 3,5-4,5.jpg|[[/Olympus Zuiko Auto-zoom 35-105 mm f/3,5/4.5 close focus/]]
IMG.svg|[[/Olympus Zuiko 50 mm f/1,2/]] (1982) {{50}}
Olympus Zuiko 50mm f 1.4.JPG|[[/Olympus Zuiko 50 mm f/1,4/]] (avant 1978) {{25}}
Olympus OM F.Zuiko 50 mm f 1,8.jpg|[[/Olympus Zuiko 50 mm f/1,8/]]
Zuiko macro50F2.jpg|[[/Olympus Zuiko Auto-Macro 50 mm f/2/]]
Olympus OM 3,5 50mm Makroobjektiv.jpg|[[/Olympus Zuiko Auto-macro 50 mm f/3,5]]
IMG.svg|[[/Olympus Zuiko 55 mm f/1,2/]]
IMG.svg|[[/Olympus Zuiko 85 mm f/2,0/]]
IMG.svg|[[/Olympus Zuiko Zoom 65-200 mm f/4]]
Olympus OM Zoom 4.0 75-150 mm.jpg|[[/Olympus Zuiko 75-150 mm f/4]]
Zuiko macro 80mm.jpg|Olympus Zuiko macro 80 mm f/4
Olympus Zuiko 100mm f 2.8.JPG|[[/Olympus Zuiko 100 mm f/2,8/]] (avant 1978) {{25}}
IMG.svg|[[/Olympus Zuiko 135 mm f/2,8/]]
IMG.svg|[[/Olympus Zuiko 135 mm f/3,5/]]
Olympus OM Zuiko Macro 135 mm f 4,5.jpg|[[/Olympus Zuiko macro 135 mm f/4,5/]]
IMG.svg|[[/Olympus Zuiko 180 mm f/2,8/]]
IMG.svg|[[/Olympus Zuiko 200 mm f/5/]]
Olympus Zuiko 200mm f 4.JPG|[[/Olympus Zuiko 200 mm f/4/]] (avant 1978) {{25}}
Olympus F.Zuiko 4.5 300 mm 06.jpg|[[Olympus F Zuiko 300 mm f/4,5]]
Olympus F.Zuiko 4.5 300 mm 07.jpg|[[Olympus F Zuiko 300 mm f/4,5]]
IMG.svg|[[/Olympus Zuiko 600 mm f/5,6/]]
IMG.svg|[[/Olympus Zuiko Reflex 500 mm f/8/]]
IMG.svg|[[/Olympus Zuiko 1000 mm f/11/]]
</gallery>
== Objectifs autofocus ==
=== Séries Zuiko anciennes ===
<gallery>
IMG.svg|Olympus Zuiko AF 24 mm f/2,8
IMG.svg|Olympus Zuiko AF 28 mm f/2,8
IMG.svg|Olympus Zuiko AF 50 mm f/2,8 Macro
IMG.svg|Olympus Zuiko AF 50 mm f/1,8
IMG.svg|Olympus Lens AF 28-85 mm f/3.5/4.5
IMG.svg|[[/Olympus Lens AF 35-70 mm f/3.5/4.5/]] (1982)
IMG.svg|Olympus Lens AF 35-105 mm f/3.5/4.5
IMG.svg|Olympus Lens AF 70-210 mm f/3.5/4.5 (1982)
</gallery>
=== Série Zuiko Digital ===
<gallery>
</gallery>
=== Série Four-Thirds ===
<gallery>
Olympus four thirds camera.JPG
Olympus four thirds lenses.JPG
IMG.svg|[[/Olympus Zuiko Digital ED 7-14 mm f/4/]] (2005) {{75}}
IMG.svg|[[/Olympus Zuiko Digital ED 9-18 mm f/4-5,6/]] (2008) {{75}}
Olympus lens EZ1122.jpg|[[/Olympus Zuiko Digital 11-22 mm f/2,8-3,5|Olympus Zuiko Digital 11-22 mm f/2,8-3,5]] {{75}}
Olympus Zuiko Digital ED 12-60mm F2.8-4.0 SWD lens with Olympus Lens Hood LH-75B.jpg|[[/Olympus Zuiko Digital ED 12-60 mm f/2,8-4 SWD/]] (octobre 2007) {{100}}
Olympus Zuiko Digital 14-42mm 3.5-5.6 ED (3795070609).jpg|[[/Olympus Zuiko Digital ED 14-42 mm f/3,5-5,6/]] (septembre 2006) {{100}}
ZD 14 54 I DSC 5350.jpg|[[/Olympus Zuiko Digital 14-54 mm f/2,8-3,5/]]
Olympus Zuiko Digital 14-45mm 3.5-5.6 (2179004620).jpg|[[/Olympus Zuiko Digital 14-45 mm f/3,5-5,6/]]
Zuiko 14-35mm.jpg|[[/Olympus Zuiko Digital 14-35 f/2/]]
Olympus Zuiko Digital 17.5-45mm 3.5-5.6 (2178211561).jpg|[[/Olympus Zuiko Digital 17,5-45 mm f/3,5-5,6/]]
Olympus Zuiko Digital 25mm lens - front.jpg|[[/Olympus Zuiko Digital 25 mm f/2,8/ (Pancake)]] (mars 2008) {{100}}
Olympus Zuiko Digital 35mm Macro 3.5 (2178211317).jpg|[[/Olympus Zuiko Digital 35 mm f/3,5 Macro/]]
Objektiv Olympus ZUIKO DIGITAL 50mm Macro stehend.jpg|[[/Olympus Zuiko Digital ED 50 mm f/2 Macro/]] (juin 2008) {{100}}
Olympus Zuiko Digital 40-150mm f3.5-4.5 lens - front.jpg|[[/Olympus Zuiko Digital ED 40-150 mm f/3,5-4,5/]] (2006) {{100}}
Olympus 50–200 2.8–3.5.jpg|50-200 mm f/2,8-3,5 ED
Olympus E-500 + EC-20 + Zuiko 50-200mm.jpg|50-200 mm f/2,8-3,5 ED
Zuiko Digital ED 50-200mm F2.8-3.5 SWD.jpg|50-200 mm f/2,8-3,5 ED
Olympus E-330 + Zuiko 50-200mm.jpg|50-200 mm f/2,8-3,5 ED
Objektiv Olympus ZUIKO DIGITAL 70-300mm by 300mm.jpg|[[/Olympus Zuiko Digital ED 70-300 mm f/4,0-5,6/]] (2007) {{75}}
IMG.svg|[[/Olympus Zuiko Digital ED 90-250 mm f/2,8/]] (2007) {{25}}
</gallery>
=== Série Micro Four-Thirds ===
<gallery>
Objektiv Olympus M.ZUIKO DIGITAL 7-14mm stehend.jpg|Olympus M.Zuiko Digital 7-14 mm
Objektiv Olympus M.ZUIKO DIGITAL 7-14mm.jpg
MelvL P3180491 (5536855633).jpg|[[/Olympus M Zuiko Digital ED 9-18 mm f/4-5,6|Olympus M Zuiko Digital ED 9-18 mm f/4-5,6]] (avril 2010) {{50}}
Olympus 9mm F8 bodycap lens on Air A01.jpg|Olympus 9 mm f/8
Olympus 9mm F8 bodycap lens on ep5.jpg
Olympus 9mm F8 bodycap lens on GM5.jpg
Olympus 9mm F8 Fisheye bodycap lens on E-P5.jpg
IMG.svg|[[/Olympus M.Zuiko Digital ED 12 mm f/2|Olympus M.Zuiko Digital ED 12 mm f/2]] (30 juin 2011) {{75}}
Olympus M.Zuiko Digital 14-42mm.png|Olympus M.Zuiko Digital 14-42 mm f/3,5-5,6 L ED
Olympus M.Zuiko Digital 14-42mm F3.5-5.6 cutted.jpg
Olympus M.Zuiko digital 14-42mm f3.5-5.6 II R.jpg|[[/Olympus M.Zuiko Digital 14-42 mm f/3,5-5,6 II R|Olympus M.Zuiko Digital 14-42 mm f/3,5-5,6 II R]]
M.Zuiko 12-50mm 02.jpg|[[/Olympus M Zuiko Digital ED 12-50 mm f/3,5-6,3 EZ|Olympus M Zuiko Digital ED 12-50 mm f/3,5-6,3 EZ]]
IMG.svg|[[/Olympus M.Zuiko Digital ED 14-150 mm f/4-5,6 II|Olympus M.Zuiko Digital ED 14-150 mm f/4-5,6 II]] (5 février 2015) {{75}}
Olympus Body Cap lens 15mm F8 n01.jpg|[[/Olympus Body Cap lens 15 mm f/8|Olympus Body Cap lens 15 mm f/8]] (septembre 2012) {{100}}
2016 0212 Olympus mft 25mm1.8.jpg|[[/Olympus M-Zuiko Digital 25 mm f/1,8/]]
M.Zuiko 12-50mm 01.jpg|Olympus M.Zuiko Digital 12-50 mm
Olympus M.Zuiko Digital 40-150mm.png|Olympus M.Zuiko Digital 40-150 mm
Olympus E-M5 15.jpg|12-50 mm
Olympus M.Zuiko Digital 40-150mm F2.8.jpg|[[/Olympus M.Zuiko Digital ED 40-150 mm f/2,8 Pro|Olympus M.Zuiko Digital ED 40-150 mm f/2,8 Pro]] (15 septembre 2014) {{100}}
Olympus M Zuiko Digital ED 45mm F1.8.jpg|Olympus M Zuiko Digital ED 45 mm f/1,8
Olympus lens M.Zuiko 75 mm f1.8.jpg|[[/Olympus M.Zuiko Digital ED 75 mm f/1,8|Olympus M.Zuiko Digital ED 75 mm f/1,8]] (8 février 2012) {{100}}
Olympus M.Zuiko Digital 300mm F4.0.jpg|Olympus M.Zuiko Digital 300 mm f/4,0
</gallery>
=== Objectifs spéciaux ===
<gallery>
Image:24mmPCleft.jpg|PC 24 mm
</gallery>
== Compléments optiques ==
<gallery>
File:Olympus TCON-14B.JPG|[[/Olympus TCON-14B|Olympus TCON-14B]] Complément optique télé destiné aux appareils E-10 et E-20
File:Olympus E-20 with TCON-14B.JPG|Olympus E-20 avec TCON-14B
Fichier:OlympusTeleconv2x.png|Téléconvertisseur EC-20 2x
File:Olympus EC-20.jpg|Téléconvertisseur EC-20 2x
Image:IMG.svg|[[/Olympus EC14|Olympus EC14]] multiplicateur de focale 1,4x (2010)
File:Olympus TCON-300S ohne Gegenlichtblende.JPG|Olympus TCON-300S
File:Olympus E-20P mit TCON-300S.JPG|E-20P avec TCON-300S
</gallery>
== Flashes ==
<gallery>
Olympus XA1 (2404583547).jpg|[[/Olympus A9M|Olympus A9M]]
Olympus XA4 Macro (2388651901).jpg|[[/Olympus A11|Olympus A11 monté sur Olympus XA4 Macro]]
Olympus XA3 (2405412636).jpg|[[/Olympus A16|Olympus A16 monté sur Olympus XA1]]
Blixt jm2.jpg|flash T32 pour OM-2
Blixt jm3.jpg|flash T32 pour OM-2
Blixt jm4.jpg|flash T32 pour OM-2
Blixt jm5.jpg|flash T32 pour OM-2
2006-07-07 00-35-52b.jpg
Olympus FL-40.jpg|FL-40
Olympus FL-40 1.jpg|FL-40
Olympus FL-40 8.jpg
Olympus FL-40 7.jpg
Olympus FL-40 6.jpg
Olympus FL-40 5.jpg
Olympus FL-40 4.jpg
Olympus Blitzgerät Auto Quick 310 38.jpg|flash Auto Quick 310 pour OM-2
</gallery>
== Bagues-allonges ==
Elles sont utilisées pour la [[proxiphotographie]] et pour la [[macrophotographie]].
* '''Olympus EX-25''' : longueur 25 mm, monture 4/3 Olympus, 150 €
<gallery>
File:Olympus OM Zwischenringe 25 + 14mm.jpg|Bagues de 14 et 25 mm pour Olympus OM
</gallery>
== Accessoires divers ==
<gallery>
MelvL P1260102 (5388033078).jpg|Viseur électronique VF-2
MelvL P1260105 (5387430951).jpg|Viseur électronique VF-2
MelvL P3180492 (5536856995).jpg|Pare-soleil LH-55B
Adattatore per esposizioni manuali - Museo scienza tecnologia Milano 13097.jpg|Adaptateur pour l'exposition manuelle pour [[/Olympus OM10/]]
Olympus OM Winder 2.jpg|OM winder 2
Olympus OM MD1 Motor.jpg|OM motor drive
Olympus focusing screen 1-1 (5344261324).jpg|verre de visée pour Olympus OM-1
Olympus Winkelsucher OM.jpg|Viseur d'angle pour Olympus OM
Olympus-OM-Macro-Flash-Shoe-Ring.JPG|support pour flash macro
Olympus slide copier hg.jpg|duplicateur de diapositives
Slide copier - Olympus bellows unit, modified to take a Pentax body.jpg
Slide copier - Olympus bellows unit, modified to take a Pentax body - (1).jpg
Olympus Aufbewahrungsmappe für SmartMedia Speicherkarten 06.jpg
Olympus Aufbewahrungsmappe für SmartMedia Speicherkarten 08.jpg
Olympus Kabelfernauslöser RM-UC1.jpg
Olympus Li-ion Akkuladegerät BCM-2 21.jpg
Carcasa y cámara de fotos subacuática.jpg|Caisson étanche PT-029 pour Olympus Stylus 600 (2001)
</gallery>
== Sacs et fourre-tout ==
La marque vend des étuis, sacs et fourre-tout adaptés à ses produits.
{{Ph Fabricants}}
tcyz5tcvbc4h4oec04ypzjd3318ct6k
763717
763716
2026-04-15T10:50:47Z
Banffy
34456
/* Objectifs à mise au point manuelle */
763717
wikitext
text/x-wiki
{{Ph s Fabricants}}
{{EnTravaux}}
== À classer ==
<gallery>
Olympus Superzoom 110 BW 1.JPG|Superzoom 110 BW
Quick Flash AFL.jpg|Quick flash
Olympus SZIII stereo microscope.jpg
Olympus Stylus.jpg|Stylus
Olympus mju ii.jpg|[[/Olympus Mju II/]]
Olympus C-960 Zoom.jpg|C 960
Olympus Superzoom 120TC.jpg|Olympus
</gallery>
== Appareils 18 x 24 ==
<gallery>
Olympus Pen img 0048.jpg|[[/Olympus Pen|Olympus Pen EE]]
Olympus Pen img 1197.jpg|Olympus Pen
Olympus pen camera.JPG|Olympus Pen
Olympus Pen 6867.jpg
Olympus Pen.jpg
Olympus Pen 4397.jpg|[[/Olympus Pen révision 3|Olympus Pen révision 3]] (1959)
Pen s 130503 019 (8705222446).jpg|[[/Olympus Pen S/]] (vers 1960)
Image:IMG.svg|Olympus Pen EM
Olympus Pen EE (type 1).jpg|[[/Olympus Pen EE|Olympus Pen EE (type 1)]] (vers 1968) {{25}}
Olympus Pen EE-2 241-2599.jpg|[[/Olympus Pen EE-2|Olympus Pen EE-2]]
Olympus pen ee3.jpg|[[/Olympus Pen EE-3|Olympus Pen EE-3]]
Olympus Pen EE3.jpg|Olympus Pen EE-3
Olympus pen ees.jpg|[[/Olympus Pen EE S|Olympus Pen EE S]]
Olympus PEN-EE S (meio quadro).jpg|Olympus Pen EE S
Olympus Pen EES2.jpg|[[/Olympus Pen EES-2|Olympus Pen EES-2]]
Olympus Pen EED.jpg|Olympus Pen EED
Olympus pen eed.jpg|Olympus Pen EED
0607 Olympus EED with lens cap (9122191695).jpg|Olympus Pen EED
0606 Olympus EED no lens cap (9124412452).jpg|Olympus Pen EED
MelvL P4290007 (5669556412).jpg|Olympus Pen EED
MelvL P4290003 (5669548202).jpg|Olympus Pen EED
MelvL P4290009 (5669557980).jpg|Olympus Pen EED
MelvL P4290006 (5669403335).jpg|Olympus Pen EED
MelvL P4290004 (5668984407).jpg|Olympus Pen EED
MelvL P4290008 (5668985957).jpg|Olympus Pen EED
MelvL P1040065 (5703691290).jpg|Olympus Pen EED
MelvL (5703980674).jpg|Olympus Pen EED
MelvL P1040116 (5709471065).jpg|Olympus Pen EED
MelvL P1040153 (5730856146).jpg|Olympus Pen EED
MelvL P1040155 (5726118704).jpg|Olympus Pen EED
Olympus Pen EES-2 (6717094541).jpg|Olympus Pen EES-2
Pen D3.jpg|[[/Olympus Pen D3|Olympus Pen D3]] (1965-1969)
Olympus PenF.jpg|[[/Olympus Pen F|Olympus Pen F]] (vers 1963)
Olympus-Pen-FT-with-38mm1 8.jpg|[[/Olympus Pen FT|Olympus Pen FT]] (vers 1968) {{75}}
</gallery>
== Appareils 24 x 36 reflex ==
<gallery>
Olympus FTL front.jpg|[[/Olympus FTL/]] (1971-1972) {{25}}
Olympus OM-1 (13573573703).jpg|[[/Olympus OM-1/]] (1973-1974) {{25}}
OM1NB 1.jpg|[[/Olympus OM-1N/]]
Olympus OM1MD.jpg|[[/Olympus OM-1 MD/]] (avant 1979) {{50}}
OM1-n MD (4072626146).jpg|[[/Olympus OM1-n MD/]] (1979-1983)
Olympus OM-2 with Zuiko 50mm f1.8.jpg|[[/Olympus OM-2/]] (1976) {{75}}
Olympus OM-2N img 0732.jpg|[[/Olympus OM-2N/]] {{25}}
Olympus OM-2 SP.jpg|[[/Olympus OM-2 SP/]] {{25}}
Olympus OM10 35-70mm.jpg|[[/Olympus OM10/]] (1978) {{100}}
Olympusom3.jpg|[[/Olympus OM-3/]] {{25}}
Olympus OM3 ti.jpg|OM-3 Ti
OM-3Ti Black.jpg
Olympus OM3 ti OM4 ti.jpg
Olympus OM20 - Tokino 70-210.jpg|[[/Olympus OM20|Olympus OM20 = Olympus OMG (A)]] (1982)
Olympus OM-30 (bottom).jpg|[[/Olympus OM30/]] (1982)
OlympusOM4 1.JPG|[[/Olympus OM-4/]] {{50}}
Olympus OM4 ti 01.jpg|OM-4 Ti
Olympus OM-4 Ti.JPG|OM-4 Ti
Olympus OM-4Ti worn black body with Zuiko 1.8-50mm lens and neckstrap.jpg
Vintage Olympus OM-PC (aka OM-40) 35mm SLR Film Camera, Made In Japan, Circa 1985 (13517132323).jpg|[[/Olympus OM-40|Olympus OM-40 = OM-PC]] (vers 1985)
</gallery>
== Appareils 24 x 36 compacts ==
<gallery>
Olympus LT1 (3007523325).jpg|[[/Olympus LT1/]]
Olympus Superzoom 3000.jpg|Olympus Superzoom 3000
Olympus XA camera and film.jpg|[[/Olympus XA/]] {{25}}
My Olympus XA1 (4379061989).jpg|[[/Olympus XA1/]]
My Olympus XA2 (4989175842).jpg|[[/Olympus XA2/]]
Olympus Ecru.jpg|Olympus Ecru
Olympus Ecru (4766955124).jpg|[[/Olympus Ecru/]] série limitée du Mju
Olympus Ecru cap.jpg
Olympus Ecru back.jpg
Olympus Ecru front.jpg
Olympus Ecru 01.jpg
Olympus Wide.jpg|Olympus wide
Olympus Trip 35.jpeg|[[/Olympus Trip 35/]] (vers 1968) {{50}}
Olympus-35 ECR.jpg|Olympus-35 ECR
Olympus-35 SP.jpg|[[/Olympus 35 SP/]] (1968) {{25}}
Olympus35DC3.jpg|35 DC
Olympus35DC2.jpg|35 DC
Olympus35DC1.jpg|35 DC
My Olympus 35DC (4797809987).jpg|35 DC
Olympus 35 RC img 1850.jpg|[[/Olympus 35 RC/]] (avant 1977) {{50}}
Olympus 35RD.jpg|35 RD
Olympus Stylus Epic 1118.jpg|[[/Olympus mju II|Olympus mju II = Olympus Stylus Epic]] {{25}}
My Olympus XA-3 (4024574761).jpg|[[/Olympus XA3/]]
Olympus XA4 Macro (2388651901).jpg|[[/Olympus XA4 Macro/]]
Olympus mju i.jpg|mju 1
Mju (3645746098).jpg
2009-11-26-Olympus-700BF-1.jpg|700 BF
2009-11-26-Olympus-700BF-2.jpg|700 BF
2009-11-26-Olympus-700BF-3.jpg|700 BF
Olympus-stylus hg.jpg[Stylus zoom 115
Olympus Superzoom 120 1a.jpg|Superzoom 120
Olympus Superzoom 120TC.jpg|Olympus Superzoom 120TC
My Olympus AF-1 Infinity (4876749434).jpg|[[/Olympus AF-1 Infinity/]]
Olympus Infinity Jr. (4815671398).jpg|[[/Olympus Infinity Jr./]]
Olympus AZ-200 Superzoom.jpg|Olympus AZ-200 Superzoom
Olympus Trip MD3.jpg|Olympus Trip MD3
Olympus LT-105Z (6733278979).jpg|Olympus LT-105Z
</gallery>
== Appareils 24x36 bridge ==
<gallery>
My Olympus IS-1 (4662576887).jpg|[[/Olympus IS-1|Olympus IS-1]]
Olympus IS10 (3) (5789273975).jpg|[[/Olympus IS-10|Olympus IS-10]] {{25}}
Olympus ED 35-180 (6175609523).jpg|[[/Olympus IS-3000/]] (1993)
Olympus-IS-100-07.jpg|[[/Olympus IS-100|Olympus IS-100]] (1994) {{25}}
Olympus IS100S (5) (5789275039).jpg|[[/Olympus IS-100S|Olympus IS-100S]] {{25}}
Olympus Alvesgaspar.jpg|[[/Olympus IS-1000|Olympus IS-1000]] {{25}}
</gallery>
== Appareils pour le format AGFA Rapid ==
<gallery>
Image:IMG.svg|[[/Olympus Pen RAPID EES|Olympus Pen RAPID EES]]
Image:IMG.svg|[[/Olympus Pen RAPID EED|Olympus Pen RAPID EED]]
</gallery>
== Appareils pour le format 126 ==
<gallery>
Olympus Quickmatic 600 (2759484117).jpg|[[/Olympus Quickmatic 600|Olympus Quickmatic 600]]
</gallery>
== Appareils pour le format APS ==
<gallery>
Olympus i zoom 2000 (3854940049).jpg|[[/Olympus i zoom 2000/]] (2000)
</gallery>
== Appareils numériques non reflex ==
=== année 1996 ===
<gallery>
Image:IMG.svg|[[/Olympus D-200L|Olympus D-200L]] {{50}} (5 septembre 1996)
Image:IMG.svg|[[/Olympus D-300L|Olympus D-300L]] {{50}} (5 septembre 1996)
</gallery>
=== année 1997 ===
<gallery>
Image:Olympus C-820L.jpg|[[/Olympus Camedia C-820L|Olympus Camedia C-820L]] {{50}} (septembre 1997)
File:2009-11-26-Olympus-C-820L-1.jpg|C-820L
File:2009-11-26-Olympus-C-820L-2.jpg|C-820L
File:2009-11-26-Olympus-C-820L-3.jpg|C-820L
File:2009-11-26-Olympus-C-820L-5.jpg|C-820L
File:2009-11-26-Olympus-C-820L-6.jpg|C-820L
File:2009-11-26-Olympus-C-820L-4.jpg|C-820L
File:Camedia-C-820L-05.jpg|C-820L
File:Camedia-C-820L-02.jpg|C-820L
</gallery>
=== année 1998 ===
<gallery>
Image:IMG.svg|[[/Olympus D-340L|Olympus D-340L]] {{50}} (28 septembre 1998)
File:Olympus C-900 ZOOM.jpg|[[/Olympus D-400|Olympus D-400 = Stylus Digital 400 = Olympus C900Z)]] {{75}} (2 novembre 1998)
</gallery>
=== année 1999 ===
<gallery>
Image:IMG.svg|[[/Olympus D-340R|Olympus D-340R]] {{50}} (2 janvier 1999)
File:Olympus Camedia C-2000 Z.jpg|[[/Olympus C-2000 Zoom|Olympus C-2000 Zoom]] {{50}} (16 février 1999)
Image:IMG.svg|[[/Olympus C-21|Olympus C-21]] {{50}} (28 juin 1999)
File:Olympus Camedia C-21T.commu CP+ 2011.jpg|Olympus Camedia C-21T.commu
Image:IMG.svg|[[/Olympus D-450 Zoom|Olympus D-450 Zoom = Olympus C920Z]] {{50}} (31 juillet 1999)
Image:IMG.svg|[[/Olympus C-2020 Zoom|Olympus C-2020 Zoom]] (19 octobre 1999)
</gallery>
=== année 2000 ===
<gallery>
Olympos-Camedia-C3000.jpg|C3000
Image:IMG.svg|[[/Olympus C-3030 Zoom|Olympus C-3030 Zoom]] (27 janvier 2000)
Image:IMG.svg|[[/Olympus D-360L|Olympus D-360L]] (2 février 2000)
Image:IMG.svg|[[/Olympus C-460 Zoom|Olympus C-460 Zoom]] (8 février 2000)
Image:IMG.svg|[[/Olympus C-3000 Zoom|Olympus C-3000 Zoom]] (24 avril 2000)
Image:Olympus UZ-2100 03.jpg|[[/Olympus C-2100 Ultra Zoom|Olympus C-2100 Ultra Zoom]] (15 juin 2000)
Image:Olympus UZ-2100 01.jpg
Image:Olympus UZ-2100 02.jpg
Image:IMG.svg|[[/Olympus D-490 Zoom|Olympus D-490 Zoom]] (1er août 2000)
File:Olympus E100RS.jpg|[[/Olympus E-100 RS|Olympus E-100 RS]] (22 août 2000)
File:Olympus Camera E-100RS.jpg|E-100 RS
Image:IMG.svg|[[/Olympus C-3040 Zoom|Olympus 3-2040 Zoom]] (21 novembre 2000)
Image:IMG.svg|[[/Olympus C-2040 Zoom|Olympus C-2040 Zoom]] (21 novembre 2000)
</gallery>
=== année 2001 ===
<gallery>
Olympus Camedia C-1.jpg|[[/Olympus C-1|Olympus C-1]] (6 mars 2001)
Olympus C-700 Ultra Zoom.jpg|[[/Olympus C-700 Ultra Zoom|Olympus C-700 Ultra Zoom]] (19 mars 2001)
IMG.svg|[[/Olympus D-150 Zoom|Olympus D-150 Zoom]] (8 mai 2001)
IMG.svg|[[/Olympus D-510 Zoom|Olympus D-510 Zoom]] (8 mai 2001)
IMG.svg|[[/Olympus D-370|Olympus D-370]] (5 juin 2001)
IMG.svg|[[/Olympus C-4040 Zoom|Olympus C-4040 Zoom]] (20 juin 2001)
IMG.svg|[[/Olympus D-40 Zoom|Olympus D-40 Zoom]] (2 septembre 2001)
Olympus Camedia C-2.jpg|[[/Olympus C-2|Olympus C-2]] (13 septembre 2001)
Olympus Camedia C-3020.jpg|[[/Olympus C-3020 Zoom|Olympus C-3020 Zoom]] (15 octobre 2001)
</gallery>
=== année 2002 ===
<gallery>
File:My Olympus D-520Z (4794377895).jpg|[[/Olympus D-520 Zoom|Olympus D-520 Zoom]] (13 mars 2002)
File:Olympus D-380.jpg|[[/Olympus D-380|Olympus D-380 = Olympus C-120]] (13 mars 2002)
Olympus C-2020Z.jpg|Olympus Camedia C-2020Z
OlympusC220ZoomCamera.jpg|C220Z
File:Olympus Camedia C-720.jpg|[[/Olympus C-720 Ultra Zoom|Olympus C-720 Ultra Zoom]] (8 mai 2002)
Image:IMG.svg|[[/Olympus C-300 Zoom|Olympus C-300 Zoom]] (8 mai 2002)
Image:IMG.svg|[[/Olympus C-4000 Zoom|Olympus C-4000 Zoom]] (25 juillet 2002)
File:Olympus C-5050Z, -Apr. 2007 a.jpg|[[/Olympus C-5050 Zoom|Olympus C-5050 Zoom]] (19 août 2002)
File:Olympus C-5050Z, -6 Aug. 2006 a.jpg|C-5050
File:Olympus C-5050Z, -19 Nov. 2005 a.jpg|C-5050
Image:Olympus C-730UZ Front Left.jpg|[[/Olympus C-730 UZ|Olympus C-730 UZ]] (12 septembre 2002)
Image:IMG.svg|[[/Olympus C-50 Zoom|Olympus C-50 Zoom]] (24 septembre 2002)
</gallery>
=== année 2003 ===
<gallery>
Image:IMG.svg|[[/Olympus Stylus 400|Olympus Stylus 400 = Olympus µ 400 Digital]] (9 janvier 2003)
Image:IMG.svg|[[/Olympus Stylus 300|Olympus Stylus 300 = Olympus µ 300 Digital]] (9 janvier 2003)
Fichier:Olympus Camedia C-740 Ultra Zoom 10.JPG|[[/Olympus C-740 Ultra Zoom|Olympus C-740 Ultra Zoom]] {{75}} (2 mars 2003)
File:Olympus C-150.JPG|[[/Olympus Camedia C-150|Olympus Camedia C-150 = Olympus D-390]] (2 mars 2003)
Image:Olympus Camedia C-350 Zoom -3.JPG|[[/Olympus D-560 Zoom|Olympus D-560 Zoom = Camedia C-350 zoom]] (2 mars 2003)
Image:Olympus Camedia C-350 Zoom -2.JPG
Image:Olympus Camedia C-350 Zoom -1.JPG
Image:Olympus Camedia C-350 Zoom.JPG
Image:Olympus-C350Z.jpg
Image:Olympus C-750.jpg|[[/Olympus C-750 Ultra Zoom|Olympus C-750 Ultra Zoom]] (2 mars 2003)
Image:Olympus C-750 back.jpg
Image:Olympus C-750 front right-1.jpg
Image:Olympus C-750 front right.jpg
Image:Olympus C-750 front left.jpg
Image:Digital Camera.jpg|[[/Olympus C-5000 Zoom|Olympus C-5000 Zoom]] (29 août 2003)
Olympus Camedia C-5000Z 3750.jpg
Olympus Camedia C-5000Z 3751.jpg
Olympus Camedia C-5000Z 3752.jpg
Olympus Camedia C-5000Z 3753.jpg
Olympus Camedia C-5000Z 3754.jpg
Olympus Camedia C-5000Z 3755.jpg
Olympus Camedia C-5000Z 3756.jpg
Image:IMG.svg|[[/Olympus C-5060 Zoom|Olympus C-5060 Zoom]] (29 septembre 2003)
</gallery>
=== année 2004 ===
<gallery>
IMG.svg|[[/Olympus D-540 Zoom|Olympus D-540 Zoom]] (14 février 2004)
IMG.svg|[[/Olympus D-580 Zoom|Olympus D-580 Zoom]] (14 février 2004)
Stylus410specs.jpg|[[/Olympus Stylus 410|Olympus Stylus 410]] (14 février 2004)
OLYMPUS C-8080WZ 01.jpg|[[/Olympus C-8080 WideZoom|Olympus C-8080 WideZoom]] (14 février 2004)
Olympus CAMEDIA C-8080.JPG|C-8080
C-8080WZ rear.JPG|C-8080
C-8080WZ tele.JPG|C-8080
Olympus C-760 UltraZoom (2178205925).jpg|[[/Olympus Camedia C-760 UZ/]]
Olympus C-765UZ, -13 juni 2006 a.jpg|[[/Olympus C-765 Ultra Zoom|Olympus C-765 Ultra Zoom]] (14 février 2004)
Olympus C-766 UZ back.jpg
Olympus C-765 UZ front.jpg
IMG.svg|[[/Olympus C-770 Ultra Zoom|Olympus C-770 Ultra Zoom]] (14 février 2004)
Olympus D-395.JPG|[[/Olympus D-395|Olympus D-395]] (18 mars 2004)
Olympus C-60 Zoom.JPG|[[/Olympus C-60 Zoom|Olympus C-60 Zoom]] (18 mars 2004)
Olympus µ-mini.jpeg|[[/Olympus Stylus Verve|Olympus Stylus Verve = Olympus Mju-mini = Olympus mju-ii]] (3 septembre 2004)
IMG.svg|[[/Olympus C-7000 Zoom|Olympus C-7000 Zoom]] (16 septembre 2004)
IMG.svg|[[/Olympus D-535 Zoom|Olympus D-535 Zoom]] (16 septembre 2004)
IMG.svg|[[/Olympus Stylus 500|Olympus Stylus 500]] (29 novembre 2004)
</gallery>
=== année 2005 ===
<gallery>
IMG.svg|[[/Olympus D-425/]] (5 janvier 2005)
IMG.svg|[[/Olympus C-7070 Wide Zoom/]] (5 janvier 2005)
IMG.svg|[[/Olympus C-5500 Sport Zoom/]] (5 janvier 2005)
IMG.svg|[[/Olympus Stylus Verve S/]] (17 février 2005)
IMG.svg|[[/Olympus D-545 Zoom/]] (17 février 2005)
Olympus C-500Z 3.JPG|[[/Olympus D-595 Zoom|Olympus D-595 Zoom = Olympus C-500Z]] (17 février 2005)
Olympus C-500Z 2.JPG
Olympus C-500Z 1.JPG
Olympus IR-300.jpg|[[/Olympus IR-300/]] (17 février 2005)
IMG.svg|[[/Olympus D-630 Zoom/]] (17 février 2005)
IMG.svg|[[/Olympus Stylus 800/]] (12 mai 2005)
IMG.svg|[[/Olympus D-435/]] (20 mai 2005)
Olympus FE 110 (2254131662).jpg|[[/Olympus FE-110/]] (29 août 2005)
Olympus-SP-310-p1030353.jpg|[[/Olympus SP-310/]] {{00}} (29 août 2005)
Olympus FE-120 01.jpg|[[/Olympus FE-120/]] {{50}} (29 août 2005)
IMG.svg|[[/Olympus Stylus 600/]] (29 août 2005)
Oly SP-350-1.jpg|[[/Olympus SP-350/]] {{25}} (29 août 2005)
IMG.svg|[[/Olympus SP-500 UZ/]] {{25}} (29 août 2005)
Olympus FE-100 front.jpg|[[/Olympus FE-100/]] (29 août 2005)
IMG.svg|[[/Olympus SP-700/]] (4 octobre 2005)
</gallery>
=== année 2006 ===
<gallery>
File:My Olympus SP-320 (4171943306).jpg|[[/Olympus SP-320|Olympus SP-320]] (26 janvier 2006) {{50}}
Image:IMG.svg|[[/Olympus FE-115|Olympus FE-115]] (26 janvier 2006) {{25}}
File:Olympus-digitale-camera-FE-130.JPG|[[/Olympus FE-130|Olympus FE-130]] (26 janvier 2006) {{50}}
Image:IMG.svg|[[/Olympus FE-140|Olympus FE-140]] (26 janvier 2006) {{25}}
Image:IMG.svg|[[/Olympus FE-150|Olympus FE-150]] (26 janvier 2006) {{25}}
Image:Olympus µ 700.jpg|[[/Olympus Stylus 700|Olympus Stylus 700 = Mju 700 Digital]] (26 janvier 2006) {{50}}
Image:IMG.svg|[[/Olympus Stylus 720 SW|Olympus Stylus 720 SW = Olympus Mju 720 SW Digital]] (26 janvier 2006)
Image:IMG.svg|[[/Olympus Stylus 810|Olympus Stylus 810 = Olympus Mju 810 Digital]] (26 janvier 2006) {{50}}
Image:Olympus X760 01.jpg|[[/Olympus FE-170|Olympus FE-170 = Olympux X-760]] (24 août 2006)
Image:IMG.svg|[[/Olympus FE-180|Olympus FE-180]] (24 août 2006) {{25}}
Image:Olympus FE190.JPG|[[/Olympus FE-190|Olympus FE-190]] (24 août 2006) {{50}}
Image:IMG.svg|[[/Olympus FE-200|Olympus FE-200]] (24 août 2006) {{50}}
File:Olympus μ 725 SW.jpg|[[/Olympus Stylus 725 SW|Olympus Stylus 725 SW = Olympus Mju 725 SW Digital]] (24 août 2006)
Image:IMG.svg|[[/Olympus Stylus 730|Olympus Stylus 730 = Olympus Mju 730 Digital]] (24 août 2006) {{50}}
Image:IMG.svg|[[/Olympus Stylus 740|Olympus Stylus 740 = Olympus Mju 740 Digital]] (24 août 2006) {{50}}
Image:IMG.svg|[[/Olympus Stylus 750|Olympus Stylus 750 = Olympus Mju 750 Digital]] (24 août 2006) {{75}}
Image:IMG.svg|[[/Olympus Stylus 1000|Olympus Stylus 1000 = Olympus Mju 1000 Digital]] (24 août 2006) {{75}}
File:OlympusSP510UZ.jpg|[[/Olympus SP-510 UZ|Olympus SP-510 UZ]] (24 août 2006) {{50}}
</gallery>
=== année 2007 ===
<gallery>
Image:Olympus SP 550UZ.jpg|[[/Olympus SP-550 UZ|Olympus SP-550 UZ]] (25 janvier 2007) {{100}}
File:Fe-210.png|[[/Olympus FE-210|Olympus FE-210 = X-775]] (25 janvier 2007) {{75}}
Image:IMG.svg|[[/Olympus FE-230|Olympus FE-230]] (25 janvier 2007) {{75}}
Image:IMG.svg|[[/Olympus FE-240|Olympus FE-240]] (25 janvier 2007) {{75}}
Image:IMG.svg|[[/Olympus FE-250|Olympus FE-250]] (25 janvier 2007) {{75}}
Image:Olympus µ 760.jpg|[[/Olympus Stylus 760|Olympus Stylus 760 = Olympus mju 760 Digital]] (25 janvier 2007) {{75}}
Image:Stylus 770SW.jpg|[[/Olympus Stylus 770 SW|Olympus Stylus 770 SW = Olympus mju 770 SW Digital]] (25 janvier 2007) {{100}}
Image:IMG.svg|[[/Olympus Stylus 780|Olympus Stylus 780]] (5 mars 2007)
Image:IMG.svg|[[/Olympus FE-270|Olympus FE-270]] (23 août 2007) {{75}}
Image:IMG.svg|[[/Olympus FE-280|Olympus FE-280]] (23 août 2007) {{75}}
Image:IMG.svg|[[/Olympus FE-290|Olympus FE-290]] (23 août 2007) {{75}}
Image:IMG.svg|[[/Olympus FE-300|Olympus FE-300]] (23 août 2007) {{75}}
Image:IMG.svg|[[/Olympus Stylus 790 SW|Olympus Stylus 790 SW = Olympus mju 790 SW Digital]] (23 août 2007) {{75}}
Image:IMG.svg|[[/Olympus Stylus 820|Olympus Stylus 820 = Olympus mju 820 Digital]] (23 août 2007) {{75}}
File:OLYMPUS Mu 830.jpeg|[[/Olympus Stylus 830|Olympus Stylus 830 = Olympus mju 830 Digital]] (23 août 2007) {{100}}
Image:IMG.svg|[[/Olympus Stylus 1200|Olympus Stylus 1200 = Olympus mju 1200 Digital]] (23 août 2007) {{75}}
Image:IMG.svg|[[/Olympus SP-560 UZ|Olympus SP-560 UZ]] (25 janvier 2007) {{75}}
</gallery>
=== année 2008 ===
<gallery>
File:Olympus SP-570UZ, -Nov. 2008 a.jpg|[[/Olympus SP-570 UZ|Olympus SP-570 UZ]] (2008) {{50}}
Image:IMG.svg|[[/Olympus Mju 1040|Olympus Mju 1040]] (2008) {{25}}
Image:IMG.svg|[[/Olympus Mju 1050sw|Olympus Mju 1050sw]] (2008) {{25}}
Image:IMG.svg|[[/Olympus Mju 1060|Olympus Mju 1060]] (2008) {{25}}
Image:IMG.svg|[[/Olympus FE-20|Olympus FE-20]] (2008) {{25}}
Image:IMG.svg|[[/Olympus FE-360|Olympus FE-360]] (19 août 2008) {{75}}
Image:IMG.svg|[[/Olympus FE-370|Olympus FE-370]] (2008) {{25}}
</gallery>
=== année 2009 ===
<gallery>
Bfishadow Olympus E-P1.jpg|Olympus Pen E-P1
Bfishadow Olympus E-P1 bottom.jpg|Olympus Pen E-P1
Bfishadow Olympus E-P1 top.jpg|Olympus Pen E-P1
Bfishadow Olympus E-P1 back.jpg|Olympus Pen E-P1
Olympus Pen img 3486.jpg|Olympus Pen E-P1
Olympus IMG 2163.jpg|Olympus Pen E-P1
Olympus E-P1 (3634318402).jpg
Olympus E-P1 (3634895524).jpg
Olympus E-P1 (3634080929).jpg
Olympus E-P1- Sleek frame (3634087049).jpg
Olympus E-P1 (3634889300).jpg
Olympus E-P1 (3634079289).jpg
Olympus E-P1 (3633504211).jpg
Olympus E-P1 (3634318984).jpg
Olympus E-P1 (3634318918).jpg
Olympus E-P1 (3633503717).jpg
</gallery>
=== année 2010 ===
<gallery>
File:Olympus PEN E-PL1.jpg|[[/Olympus PEN E-PL1|Olympus PEN E-PL1]] (10 février 2010) {{100}}
</gallery>
=== année 2011 ===
<gallery>
File:Olympus E-PL2 with Leica Summicron 50 f2 LTM lens.jpg|[[/Olympus Pen E-PL2|Olympus Pen E-PL2]] (6 janvier 2011) {{75}}
Image:IMG.svg|[[/Olympus SP-610UZ|Olympus SP-610UZ]] (6 janvier 2011) {{75}}
File:Olympus-XZ-1.jpg|[[/Olympus XZ-1|Olympus XZ-1]] (6 janvier 2011) {{100}}
</gallery>
=== année 2012 ===
<gallery>
Image:IMG.svg|[[/Olympus Tough TG-1 iHS|Olympus Tough TG-1 iHS]] (8 mai 2012) {{75}}
</gallery>
=== année 2013 ===
<gallery>
</gallery>
=== année 2014 ===
<gallery>
</gallery>
=== année 2015 ===
<gallery>
Olympus OM-D E-M5II (18421339075).jpg|[[/Olympus OM-D E-M5 II]] (5 février 2015) {{100}}
IMG.svg|[[/Olympus Tough TG-4/]] (13 avril 2015) {{75}}
Olympus OM-D E-M10 Mark II.JPG|[[/Olympus OM-D E-M10 II/]] (25 août 2015) {{75}}
</gallery>
=== année 2016 ===
<gallery>
Olympus PEN-F.jpg|[[/Olympus Pen-F/]] (27 janvier 2016) {{00}}
</gallery>
==== à classer ====
<gallery>
2013-265-121 Tough New Toy (8700211111).jpg|Olympus Tough TG-2
OLYMPUS PEN MINI E-PM2 (8651180173).jpg|Olympus E-PM2
Olympus E-20P 01.jpg|Olympus E-20P
Olympus E-20P 02.jpg
Olympus E-20P 03.jpg
Olympus E-20P 04.jpg
Olympus E-20P 05.jpg
Olympus E-20P 06.jpg
Olympus E-20P 07.jpg
Olympus E-20P 08.jpg
Olympus E-20P 09.jpg
Olympus E-20P 10.jpg
Olympus E-20P 11.jpg
Olympus X-750.jpg|Olympus X-750
Olympus PEN F.jpg|Olympus PEN F
Olympus Pen F (digital).jpg|Olympus PEN F
Olympus Pen F-IMG 9925.jpg|Olympus PEN F
Olympus Pen F-IMG 9927.JPG|Olympus PEN F
Olympus PEN F.jpg|Olympus PEN F
Olympus PEN-F.jpg|Olympus PEN F
Olympus PEN E-PL6 black kit lens 2016-03-03.jpg|Olympus PEN E-PL6
Olympus OM D E-M1 - Johnragai Gear - 09.11.2014 (15772393942).jpg|Olympus OM-D E-M1
Olympus OM D E-M1 - Johnragai Gear - 09.11.2014 (15770833985).jpg
OM D E-M1 with 75mm f-1.8 (9869006524).jpg
OM D E-M1 with 12-60mm f-2.8-4 ED SWD (9869086256).jpg
Olympus OM-D E-M1 Mark II mock-up (rough model) 2017 CP+.jpg|Olympus OM-D E-M1 Mark II
Olympus OM-D E-M1 Mark II mock-up (design model) 2017 CP+.jpg
Olympus OM-D E-M1 Mark II mock-up (body+power battery holder model) 2017 CP+.jpg
Olympus OM-D E-M1 Mark II magnesium-alloy chassis 2017 CP+.jpg
Olympus OM-D E-M1 Mark II D81 8378-2.jpg
Olympus.OM-D.E-M1.Mark.II.back.view.jpg
Olympus C-760 UltraZoom (2178205925).jpg|[[/Olympus Camedia C-760 UZ/]]
Olympus camedia C-800L.jpg|[[/Olympus Camedia C-800L/]]
Olympus SH-1 zilver, -2015 a.jpg|Olympus SH-1
Olympus SH-1 zilver, -2015 b.jpg
Olympus SH-1 zilver, -2015 c.jpg
Olympus Pen EPL7.JPG|[[/Olympus Pen E-PL7/]]
Digitalkamera von Olympus.JPG|FE-5020
Olympus XZ-10, -februari 2013 a.jpg|Olympus XZ-10
Leong IMG 2989 (6338669929).jpg
Leong IMG 2986 (6339413622).jpg
Leong IMG 0497 (6689370435).jpg
P1000963 (6707919805).jpg
Olympus OM-D E-M10 2014 CP+.jpg|Olympus OM-D E-M10
Olympus OM-D E-M10 01.jpg
Olympus OM-D E-M10 cutaway 2014 CP+.jpg|Olympus OM-D E-M10
Olympus OM-D E-M10 cutted 1.jpg
Olympus OM-D E-M10 cutted 2.jpg
Olympus PEN E-P5 Back 1.jpg|Olympus PEN E-P5
Olympus PEN E-P5 Front 1.jpg
Olympus PEN E-P5 Front 2.jpg
EP5 with 45mm F1.8.jpg
Olympus E-P5.jpg
Olympus X-500 D-590Z C-470Z.jpg|X-500 = D-590Z = C-470Z
Olympus E-PM1 + BCL-15.jpg|Olympus E-PM1
Olympus TG-820 Camera.jpg|Olympus TG-820
Olympus TG-820 front with lens cover open.jpg|Olympus TG-820
Olympus TG-820 Top.JPG|Olympus TG-820
Olympus E-P2.jpg|Olympus E-P2
Micro Four Thirds Olympus E-P2 with Panasonic Lumix G 20mm F1.7 ASPH aspherical pancake lens.jpg
Olympus EPL5 top.jpg|E-PL5
Olympus EPL5 back 01.jpg|E-PL5
Olympus EPL5 back 02.jpg|E-PL5
Olympus EPL5 front lens.jpg|E-PL5
Olympus EPL5 front.jpg|E-PL5
Olympus epl5 vf4.jpg
Olympus epl5 vf4 45mm 01.jpg
Olympus epl5 vf4 45mm 02.jpg
Olympus epl5 vf4 45mm 03.jpg
Olympus PEN Lite and Nissin i40.jpg
Olympus VR-340.JPG|Olympus VR-340
Olympus-digitale-camera-X-720.JPG|Olympus X-720
Olympus Camedia C-310 Zoom Digital Camera.JPG|Olympus Camedia C-310 Zoom
Olympus PEN E-PL3.jpg|[[/Olympus Pen E-PL3|Olympus Pen E-PL3]]
Olypmus SP-810UZ, closed.jpg|Olympus SP-810UZ
Olympus_SP-810UZ,_no_zoom,_flash_closed.jpg|Olympus SP-810UZ
Olympus_SP-810UZ,_full_zoom,_flash_opened.jpg|Olympus SP-810UZ
Olympus E-P3 006.JPG|[[/Olympus E-P3/]]
Olympus AZ-300 Superzoom.jpg|[[/Olympus AZ-300 Superzoom/]]
Olympus SP560UZ DSCF9120.jpg|SP560 UZ
Olympus u5000.jpg|Mju 5000
Stylus Tough 8000.jpg|Stylus Tough 8000
Olympus_SP590_UZ_01_(RaBoe).jpg|SP590 UZ
Olympus_SP590_UZ_02_(RaBoe).jpg|SP590 UZ
Olympus_SP590_UZ_03_(RaBoe).jpg|SP590 UZ
Olympus_SP590_UZ_04_(RaBoe).jpg|SP590 UZ
Olympus_SP590_UZ_05_(RaBoe).jpg|SP590 UZ
Olympus_SP590_UZ_06_(RaBoe).jpg|SP590 UZ
Olympus_SP590_UZ_07_(RaBoe).jpg|SP590 UZ
Olympus SP590 UZ 2010-by-RaBoe-02.jpg
Olympus SP590 UZ 2010-by-RaBoe-01.jpg
FE-310.jpg|FE-310
Image:Olympus u850SW.jpg|mju 850SW
Olympus µ850SW, -1 mei 2010 a.jpg
Image:Olympus img 1845.jpg|C-1400
Image:Olympus_FE-340_8MP_camera_01.jpg|FE-340
Olympus-MicroFT-Model.jpg|Micro four thirds
OlyE-P2Test10112009-01.jpg|EP-2
Olympus VR-310.jpg|Olympus VR-310
</gallery>
== Appareils reflex numériques ==
=== année 1997 ===
<gallery>
Image:IMG.svg|[[/Olympus D-500L|Olympus D-500L]] {{50}} (10 septembre 1997)
Image:IMG.svg|[[/Olympus D-600L|Olympus D-600L]] {{50}} (10 septembre 1997)
Image:Olympus img 1846.jpg|C-1400
Image:Olympus img 1845.jpg|C-1400
</gallery>
=== année 1998 ===
<gallery>
Image:Olympus C-1400 01.jpg|[[/Olympus D-620L|Olympus D-620L = Olympus C1400XL]] (2 novembre 1998) {{75}}
Image:Olympus Camedia C 1400 XL 61.jpg|[[/Olympus D-620L|Olympus D-620L = Olympus C1400XL]] (2 novembre 1998) {{75}}
Image:Olympus Camedia C 1400 XL 57.jpg|[[/Olympus D-620L|Olympus D-620L = Olympus C1400XL]] (2 novembre 1998) {{75}}
</gallery>
=== année 1999 ===
<gallery>
Image:IMG.svg|[[/Olympus C-2500 L|Olympus C-2500 L]] {{50}} (18 mars 1999)
</gallery>
=== année 2000 ===
<gallery>
File:Olympus E-10.jpg|[[/Olympus E-10|Olympus E-10]] (22 août 2000)
File:Olympus E-20P.JPG|[[/Olympus E-20|Olympus E-20]]
Olympus E-20n.jpg|Olympus E-20n
</gallery>
=== année 2003 ===
<gallery>
Image:Olympus E-1 2.jpg|[[/Olympus E-1|Olympus E-1]] (24 juin 2003)
Image:E-1 hinten oben.jpg
Image:E-1 Seite hinten.jpg
Image:E-1 hinten.jpg
File:E-1 vorne.jpg
File:Olympus E-1 body.jpg
</gallery>
=== année 2004 ===
<gallery>
E-300.jpg|[[/Olympus E-300|Olympus E-300 (EVOLT E-300)]] (27 septembre 2004)
</gallery>
=== année 2005 ===
<gallery>
File:E-500 Body.jpg|[[/Olympus E-500|Olympus E-500]] {{25}} (2005)
Olympus E-500 with Minolta MD Lens (5391265164).jpg|[[/Olympus E-500/]] (26 septembre 2005)
</gallery>
=== année 2006 ===
<gallery>
File:Olympus E-330. Zuiko Digital ED.jpg|[[/Olympus E-330|Olympus E-330 = EVOLT E-330]] {{25}} (26 janvier 2006)
Image:Oly e 400 voorkant.jpg|[[/Olympus E-400|Olympus E-400]] {{75}} (14 septembre 2006)
</gallery>
=== année 2007 ===
<gallery>
Olympus E410 img 1030.jpg|[[/Olympus E-410/]] (5 mars 2007)
Olympus E510 img 1029.jpg|[[/Olympus E-510/]] (5 mars 2007)
P3069465 (3333310515).jpg|[[/Olympus E-3/]]
Olympus_E-3_Camera.jpg|[[/Olympus E3/]] {{75}} (16 octobre 2007)
</gallery>
=== année 2008 ===
<gallery>
Olympus E-420.jpg|E-420
Olympus E-420 EZ40150.jpg|E-420
Olympus E-420 Body Front.jpg|E-420
Olympus E-420 Pancake25mm Top.jpg|E-420
Olympus E-420 Body Top XL.jpg|E-420
Image:Olymous E420 img 1248.jpg|E-420
Image:Olympus E-420 (back).jpg|E-420
Image:Olympus E-420 (front).jpg|E-420
</gallery>
=== année 2009 ===
<gallery>
Olympus E-450.JPG|[[/Olympus E-450|Olympus E-450]] {{75}} (31 mars 2009)
Olympus E-30 01.jpg|Olympus E-30
Olympus E-30 02.jpg|Olympus E-30
Olympus E-30 03.jpg|Olympus E-30
Olympus E-30 04.jpg|Olympus E-30
Olympus E-30 rear01.jpg|E30
Olympus E-30 front01.jpg|E30
E-30-back.jpg|E3
Olympus E-30 with ZD 14-54mm f2.8-3.5II 01.JPG|E30
Olympus E-30-Cutmodel.jpg|E30 coupé
E-30-with-14-54.jpg|E30
Olympus E30-IMG 2445.jpg|E30
Olympus E30-IMG 2442.jpg|E30
Olympus E30-IMG 2441.jpg|E30
Olympus E-620 front.jpg|E-620
Olympus E-620.jpg|E-620
Olympus E-620 with battery grip.jpg|E-620
Olympus E-620 without lens.jpg|E620
Olympus E-620 swivel screen open.JPG|E620
Olympus E620 DSLR.jpg|E620
</gallery>
=== année 2010 ===
<gallery>
Olympus-E5.jpg|E-5
</gallery>
'''à classer'''
<gallery>
Olympus OM-D E-M1- 20131118.jpg|Olympus OM-D E-M1
OM-D EM-1.jpg|OM-D EM-1
Oly-EM1-connector.jpg
Olympus OM-D E-M1 01.jpg
Olympus OM-D E-M1 cutted 1.jpg
Olympus OM-D E-M1 cutted 2.jpg
Olympus OM-D E-M1 cutted 3.jpg
Olympus OM-D E-M1 image stabilization unit.jpg
Olympus E-M5 (front, cropped).jpg|OM-D E-M5
Oly-E-M5.jpg
OLYMPUS OM-D E-M5.jpg
Olympus OM-D E-M5.jpg
Olympus E-M5, Nokton 25mm.jpg
Olympus E-M5 01.jpg
Olympus E-M5 02.jpg
Olympus E-M5 03.jpg
Olympus E-M5 04.jpg
Olympus E-M5 05.jpg
Olympus E-M5 06.jpg
Olympus E-M5 08.jpg
Olympus E-M5 07.jpg
Olympus E-M5 09.jpg
Olympus E-M5 10.jpg
Olympus E-M5 11.jpg
Olympus E-M5 12.jpg
Olympus E-M5 13.jpg
Olympus E-M5 14.jpg
Olympus E-M5 15.jpg
Olympus E-M5 16.jpg
Olympus OM-D E-M5, Taipei, TW.jpg
Olympus E-M5 + Bigma.jpg
Micro Four Thirds Olympus OM-D E-M5 digital camera.jpg
Oly-E-M5.jpg
Olympus OM-D E-M5 Elite black kit.jpg
Olympus OM-D E-M5 Elite black.jpg
Olympus E-M5 with 45mm F1.8.jpg
Olympus OM 50mm f1.8.jpg|E-420
Olympus-e-520-front.png|[[/Olympus E-520|Olympus E-520]]
</gallery>
== Modules pour smartphone ==
<gallery>
Olympus Air A01,mounted lens and phone.jpg|Olympus Air A01
</gallery>
== Objectifs à mise au point manuelle ==
=== Série Pen ===
<gallery>
Olympus-20mm-F3 5-with-TTL-No.jpg|[[/Olympus Zuiko 20 mm f/3,5/]]
Image:PenF-Zuiko-20mm.JPG
</gallery>
=== Série FTL (M42) ===
<gallery>
M42.OffenblendOLYMPUS.jpg|Olympus M 42
Olympus FTL G.Zuiko 28 mm f 3,5.jpg|Olympus G.Zuiko 28 mm f/3,5
IMG.svg|Olympus Zuiko 35 mm f/2,8/
IMG.svg|Olympus Zuiko 50 mm f/1,4/
Olympus FTL F. Zuiko 50 mm f 1,8.jpg|Olympus Zuiko 50 mm f/1,8
IMG.svg|Olympus Zuiko 135 mm f/3,5/
IMG.svg|Olympus Zuiko 200 mm f/4,0/
</gallery>
=== Série OM-System ===
<gallery>
OMLenses.jpg
OM Zuiko f2 lenses.jpg|Zuiko f/2
</gallery>
<gallery>
IMG.svg|[[/Olympus Zuiko 8 mm f/2,8/]] (avant 1978) {{25}}
IMG.svg|[[/Olympus Zuiko 16 mm f/3,5/]] (avant 1978) {{25}}
Olympus Zuiko Auto-Macro 20mm 1-2 lens (4243439258).jpg|[[/Olympus Zuiko Auto-Macro 20 mm f/1,2/]]
ZUIKO21mmF2.jpg|[[/Olympus Zuiko 21 mm f/2/]]
Olympus Zuiko 2,0 21mm.jpg|[[/Olympus Zuiko 21 mm f/2/]]
IMG.svg|[[/Olympus Zuiko 21 mm f/3,5/]] (avant 1978) {{25}}
IMG.svg|[[/Olympus Zuiko 24 mm f/2/]] (avant 1978) {{25}}
Obiettivo fotografico ultragrandangolare, messa a fuoco elicoidale, con innesto a baionetta - Museo scienza tecnologia Milano 13087.jpg|Olympus Zuiko Auto-W 24 mm f/2,8 (1991)
Zuiko shift 24mm.jpg|Olympus Zuiko shift 24 mm f/3,5 à décentrement
Olympus Zuiko 24mm f 2.8.JPG|[[/Olympus Zuiko 24 mm f/2,8/]] (avant 1978) {{50}}
IMG.svg|[[/Olympus Zuiko 28 mm f/2/]] (avant 1978) {{25}}
Olympus G. Zuiko 3,5 28mm.jpg|[[/Olympus Zuiko 28 mm f/3,5/]] (avant 1978) {{25}}
Olympus OM 2,8 35 Shift.jpg|Olympus Zuiko shift 35 mm f/2,8 à décentrement
IMG.svg|[[/Olympus Zuiko 35 mm f/2/]] (avant 1978) {{25}}
Olympus G. Zuiko 2,8 35mm.jpg|[[/Olympus Zuiko 35 mm f/2,8/]] (avant 1978) {{25}}
IMG.svg|[[/Olympus Zuiko 35 mm f/3,5 Macro/]] (26 septembre 2005) {{75}}
Olympus Zuiko MC-Macro 1-3,5 f=38mm lens (4243438710).jpg|[[/Olympus Zuiko MB 38 mm f/3,5 Macro/]]
Olympus OM Zuiko Zoom 3570 mm f 4,0.jpg|[[/Olympus Zuiko Auto-zoom 35-70 mm f/4/]] (1982)
OM Auto Zoom 3,6 f=35-70mm-19840912-RM-123616.jpg|[[/Olympus Zuiko MC Auto-zoom 35-70 mm f/3,6/]]
Olympus OM Zuiko Zoom 35-105 f 3,5-4,5.jpg|[[/Olympus Zuiko Auto-zoom 35-105 mm f/3,5/4.5 close focus/]]
IMG.svg|[[/Olympus Zuiko 50 mm f/1,2/]] (1982) {{50}}
Olympus Zuiko 50mm f 1.4.JPG|[[/Olympus Zuiko 50 mm f/1,4/]] (avant 1978) {{25}}
Olympus OM F.Zuiko 50 mm f 1,8.jpg|[[/Olympus Zuiko 50 mm f/1,8/]]
Zuiko macro50F2.jpg|[[/Olympus Zuiko Auto-Macro 50 mm f/2/]]
Olympus OM 3,5 50mm Makroobjektiv.jpg|[[/Olympus Zuiko Auto-macro 50 mm f/3,5]]
IMG.svg|[[/Olympus Zuiko 55 mm f/1,2/]]
IMG.svg|[[/Olympus Zuiko 85 mm f/2,0/]]
IMG.svg|[[/Olympus Zuiko Zoom 65-200 mm f/4]]
Olympus OM Zoom 4.0 75-150 mm.jpg|[[/Olympus Zuiko 75-150 mm f/4]]
Zuiko macro 80mm.jpg|Olympus Zuiko macro 80 mm f/4
Olympus Zuiko 100mm f 2.8.JPG|[[/Olympus Zuiko 100 mm f/2,8/]] (avant 1978) {{25}}
IMG.svg|[[/Olympus S Zuiko Zoom 100-200 mm f/5]]
IMG.svg|[[/Olympus Zuiko 135 mm f/2,8/]]
IMG.svg|[[/Olympus Zuiko 135 mm f/3,5/]]
Olympus OM Zuiko Macro 135 mm f 4,5.jpg|[[/Olympus Zuiko macro 135 mm f/4,5/]]
IMG.svg|[[/Olympus Zuiko 180 mm f/2,8/]]
IMG.svg|[[/Olympus Zuiko 200 mm f/5/]]
Olympus Zuiko 200mm f 4.JPG|[[/Olympus Zuiko 200 mm f/4/]] (avant 1978) {{25}}
Olympus F.Zuiko 4.5 300 mm 06.jpg|[[Olympus F Zuiko 300 mm f/4,5]]
Olympus F.Zuiko 4.5 300 mm 07.jpg|[[Olympus F Zuiko 300 mm f/4,5]]
IMG.svg|[[/Olympus Zuiko 600 mm f/5,6/]]
IMG.svg|[[/Olympus Zuiko Reflex 500 mm f/8/]]
IMG.svg|[[/Olympus Zuiko 1000 mm f/11/]]
</gallery>
== Objectifs autofocus ==
=== Séries Zuiko anciennes ===
<gallery>
IMG.svg|Olympus Zuiko AF 24 mm f/2,8
IMG.svg|Olympus Zuiko AF 28 mm f/2,8
IMG.svg|Olympus Zuiko AF 50 mm f/2,8 Macro
IMG.svg|Olympus Zuiko AF 50 mm f/1,8
IMG.svg|Olympus Lens AF 28-85 mm f/3.5/4.5
IMG.svg|[[/Olympus Lens AF 35-70 mm f/3.5/4.5/]] (1982)
IMG.svg|Olympus Lens AF 35-105 mm f/3.5/4.5
IMG.svg|Olympus Lens AF 70-210 mm f/3.5/4.5 (1982)
</gallery>
=== Série Zuiko Digital ===
<gallery>
</gallery>
=== Série Four-Thirds ===
<gallery>
Olympus four thirds camera.JPG
Olympus four thirds lenses.JPG
IMG.svg|[[/Olympus Zuiko Digital ED 7-14 mm f/4/]] (2005) {{75}}
IMG.svg|[[/Olympus Zuiko Digital ED 9-18 mm f/4-5,6/]] (2008) {{75}}
Olympus lens EZ1122.jpg|[[/Olympus Zuiko Digital 11-22 mm f/2,8-3,5|Olympus Zuiko Digital 11-22 mm f/2,8-3,5]] {{75}}
Olympus Zuiko Digital ED 12-60mm F2.8-4.0 SWD lens with Olympus Lens Hood LH-75B.jpg|[[/Olympus Zuiko Digital ED 12-60 mm f/2,8-4 SWD/]] (octobre 2007) {{100}}
Olympus Zuiko Digital 14-42mm 3.5-5.6 ED (3795070609).jpg|[[/Olympus Zuiko Digital ED 14-42 mm f/3,5-5,6/]] (septembre 2006) {{100}}
ZD 14 54 I DSC 5350.jpg|[[/Olympus Zuiko Digital 14-54 mm f/2,8-3,5/]]
Olympus Zuiko Digital 14-45mm 3.5-5.6 (2179004620).jpg|[[/Olympus Zuiko Digital 14-45 mm f/3,5-5,6/]]
Zuiko 14-35mm.jpg|[[/Olympus Zuiko Digital 14-35 f/2/]]
Olympus Zuiko Digital 17.5-45mm 3.5-5.6 (2178211561).jpg|[[/Olympus Zuiko Digital 17,5-45 mm f/3,5-5,6/]]
Olympus Zuiko Digital 25mm lens - front.jpg|[[/Olympus Zuiko Digital 25 mm f/2,8/ (Pancake)]] (mars 2008) {{100}}
Olympus Zuiko Digital 35mm Macro 3.5 (2178211317).jpg|[[/Olympus Zuiko Digital 35 mm f/3,5 Macro/]]
Objektiv Olympus ZUIKO DIGITAL 50mm Macro stehend.jpg|[[/Olympus Zuiko Digital ED 50 mm f/2 Macro/]] (juin 2008) {{100}}
Olympus Zuiko Digital 40-150mm f3.5-4.5 lens - front.jpg|[[/Olympus Zuiko Digital ED 40-150 mm f/3,5-4,5/]] (2006) {{100}}
Olympus 50–200 2.8–3.5.jpg|50-200 mm f/2,8-3,5 ED
Olympus E-500 + EC-20 + Zuiko 50-200mm.jpg|50-200 mm f/2,8-3,5 ED
Zuiko Digital ED 50-200mm F2.8-3.5 SWD.jpg|50-200 mm f/2,8-3,5 ED
Olympus E-330 + Zuiko 50-200mm.jpg|50-200 mm f/2,8-3,5 ED
Objektiv Olympus ZUIKO DIGITAL 70-300mm by 300mm.jpg|[[/Olympus Zuiko Digital ED 70-300 mm f/4,0-5,6/]] (2007) {{75}}
IMG.svg|[[/Olympus Zuiko Digital ED 90-250 mm f/2,8/]] (2007) {{25}}
</gallery>
=== Série Micro Four-Thirds ===
<gallery>
Objektiv Olympus M.ZUIKO DIGITAL 7-14mm stehend.jpg|Olympus M.Zuiko Digital 7-14 mm
Objektiv Olympus M.ZUIKO DIGITAL 7-14mm.jpg
MelvL P3180491 (5536855633).jpg|[[/Olympus M Zuiko Digital ED 9-18 mm f/4-5,6|Olympus M Zuiko Digital ED 9-18 mm f/4-5,6]] (avril 2010) {{50}}
Olympus 9mm F8 bodycap lens on Air A01.jpg|Olympus 9 mm f/8
Olympus 9mm F8 bodycap lens on ep5.jpg
Olympus 9mm F8 bodycap lens on GM5.jpg
Olympus 9mm F8 Fisheye bodycap lens on E-P5.jpg
IMG.svg|[[/Olympus M.Zuiko Digital ED 12 mm f/2|Olympus M.Zuiko Digital ED 12 mm f/2]] (30 juin 2011) {{75}}
Olympus M.Zuiko Digital 14-42mm.png|Olympus M.Zuiko Digital 14-42 mm f/3,5-5,6 L ED
Olympus M.Zuiko Digital 14-42mm F3.5-5.6 cutted.jpg
Olympus M.Zuiko digital 14-42mm f3.5-5.6 II R.jpg|[[/Olympus M.Zuiko Digital 14-42 mm f/3,5-5,6 II R|Olympus M.Zuiko Digital 14-42 mm f/3,5-5,6 II R]]
M.Zuiko 12-50mm 02.jpg|[[/Olympus M Zuiko Digital ED 12-50 mm f/3,5-6,3 EZ|Olympus M Zuiko Digital ED 12-50 mm f/3,5-6,3 EZ]]
IMG.svg|[[/Olympus M.Zuiko Digital ED 14-150 mm f/4-5,6 II|Olympus M.Zuiko Digital ED 14-150 mm f/4-5,6 II]] (5 février 2015) {{75}}
Olympus Body Cap lens 15mm F8 n01.jpg|[[/Olympus Body Cap lens 15 mm f/8|Olympus Body Cap lens 15 mm f/8]] (septembre 2012) {{100}}
2016 0212 Olympus mft 25mm1.8.jpg|[[/Olympus M-Zuiko Digital 25 mm f/1,8/]]
M.Zuiko 12-50mm 01.jpg|Olympus M.Zuiko Digital 12-50 mm
Olympus M.Zuiko Digital 40-150mm.png|Olympus M.Zuiko Digital 40-150 mm
Olympus E-M5 15.jpg|12-50 mm
Olympus M.Zuiko Digital 40-150mm F2.8.jpg|[[/Olympus M.Zuiko Digital ED 40-150 mm f/2,8 Pro|Olympus M.Zuiko Digital ED 40-150 mm f/2,8 Pro]] (15 septembre 2014) {{100}}
Olympus M Zuiko Digital ED 45mm F1.8.jpg|Olympus M Zuiko Digital ED 45 mm f/1,8
Olympus lens M.Zuiko 75 mm f1.8.jpg|[[/Olympus M.Zuiko Digital ED 75 mm f/1,8|Olympus M.Zuiko Digital ED 75 mm f/1,8]] (8 février 2012) {{100}}
Olympus M.Zuiko Digital 300mm F4.0.jpg|Olympus M.Zuiko Digital 300 mm f/4,0
</gallery>
=== Objectifs spéciaux ===
<gallery>
Image:24mmPCleft.jpg|PC 24 mm
</gallery>
== Compléments optiques ==
<gallery>
File:Olympus TCON-14B.JPG|[[/Olympus TCON-14B|Olympus TCON-14B]] Complément optique télé destiné aux appareils E-10 et E-20
File:Olympus E-20 with TCON-14B.JPG|Olympus E-20 avec TCON-14B
Fichier:OlympusTeleconv2x.png|Téléconvertisseur EC-20 2x
File:Olympus EC-20.jpg|Téléconvertisseur EC-20 2x
Image:IMG.svg|[[/Olympus EC14|Olympus EC14]] multiplicateur de focale 1,4x (2010)
File:Olympus TCON-300S ohne Gegenlichtblende.JPG|Olympus TCON-300S
File:Olympus E-20P mit TCON-300S.JPG|E-20P avec TCON-300S
</gallery>
== Flashes ==
<gallery>
Olympus XA1 (2404583547).jpg|[[/Olympus A9M|Olympus A9M]]
Olympus XA4 Macro (2388651901).jpg|[[/Olympus A11|Olympus A11 monté sur Olympus XA4 Macro]]
Olympus XA3 (2405412636).jpg|[[/Olympus A16|Olympus A16 monté sur Olympus XA1]]
Blixt jm2.jpg|flash T32 pour OM-2
Blixt jm3.jpg|flash T32 pour OM-2
Blixt jm4.jpg|flash T32 pour OM-2
Blixt jm5.jpg|flash T32 pour OM-2
2006-07-07 00-35-52b.jpg
Olympus FL-40.jpg|FL-40
Olympus FL-40 1.jpg|FL-40
Olympus FL-40 8.jpg
Olympus FL-40 7.jpg
Olympus FL-40 6.jpg
Olympus FL-40 5.jpg
Olympus FL-40 4.jpg
Olympus Blitzgerät Auto Quick 310 38.jpg|flash Auto Quick 310 pour OM-2
</gallery>
== Bagues-allonges ==
Elles sont utilisées pour la [[proxiphotographie]] et pour la [[macrophotographie]].
* '''Olympus EX-25''' : longueur 25 mm, monture 4/3 Olympus, 150 €
<gallery>
File:Olympus OM Zwischenringe 25 + 14mm.jpg|Bagues de 14 et 25 mm pour Olympus OM
</gallery>
== Accessoires divers ==
<gallery>
MelvL P1260102 (5388033078).jpg|Viseur électronique VF-2
MelvL P1260105 (5387430951).jpg|Viseur électronique VF-2
MelvL P3180492 (5536856995).jpg|Pare-soleil LH-55B
Adattatore per esposizioni manuali - Museo scienza tecnologia Milano 13097.jpg|Adaptateur pour l'exposition manuelle pour [[/Olympus OM10/]]
Olympus OM Winder 2.jpg|OM winder 2
Olympus OM MD1 Motor.jpg|OM motor drive
Olympus focusing screen 1-1 (5344261324).jpg|verre de visée pour Olympus OM-1
Olympus Winkelsucher OM.jpg|Viseur d'angle pour Olympus OM
Olympus-OM-Macro-Flash-Shoe-Ring.JPG|support pour flash macro
Olympus slide copier hg.jpg|duplicateur de diapositives
Slide copier - Olympus bellows unit, modified to take a Pentax body.jpg
Slide copier - Olympus bellows unit, modified to take a Pentax body - (1).jpg
Olympus Aufbewahrungsmappe für SmartMedia Speicherkarten 06.jpg
Olympus Aufbewahrungsmappe für SmartMedia Speicherkarten 08.jpg
Olympus Kabelfernauslöser RM-UC1.jpg
Olympus Li-ion Akkuladegerät BCM-2 21.jpg
Carcasa y cámara de fotos subacuática.jpg|Caisson étanche PT-029 pour Olympus Stylus 600 (2001)
</gallery>
== Sacs et fourre-tout ==
La marque vend des étuis, sacs et fourre-tout adaptés à ses produits.
{{Ph Fabricants}}
hmampeahspc0y62emaaf56kg0y542iw
763718
763717
2026-04-15T10:55:38Z
Banffy
34456
/* Objectifs à mise au point manuelle */
763718
wikitext
text/x-wiki
{{Ph s Fabricants}}
{{EnTravaux}}
== À classer ==
<gallery>
Olympus Superzoom 110 BW 1.JPG|Superzoom 110 BW
Quick Flash AFL.jpg|Quick flash
Olympus SZIII stereo microscope.jpg
Olympus Stylus.jpg|Stylus
Olympus mju ii.jpg|[[/Olympus Mju II/]]
Olympus C-960 Zoom.jpg|C 960
Olympus Superzoom 120TC.jpg|Olympus
</gallery>
== Appareils 18 x 24 ==
<gallery>
Olympus Pen img 0048.jpg|[[/Olympus Pen|Olympus Pen EE]]
Olympus Pen img 1197.jpg|Olympus Pen
Olympus pen camera.JPG|Olympus Pen
Olympus Pen 6867.jpg
Olympus Pen.jpg
Olympus Pen 4397.jpg|[[/Olympus Pen révision 3|Olympus Pen révision 3]] (1959)
Pen s 130503 019 (8705222446).jpg|[[/Olympus Pen S/]] (vers 1960)
Image:IMG.svg|Olympus Pen EM
Olympus Pen EE (type 1).jpg|[[/Olympus Pen EE|Olympus Pen EE (type 1)]] (vers 1968) {{25}}
Olympus Pen EE-2 241-2599.jpg|[[/Olympus Pen EE-2|Olympus Pen EE-2]]
Olympus pen ee3.jpg|[[/Olympus Pen EE-3|Olympus Pen EE-3]]
Olympus Pen EE3.jpg|Olympus Pen EE-3
Olympus pen ees.jpg|[[/Olympus Pen EE S|Olympus Pen EE S]]
Olympus PEN-EE S (meio quadro).jpg|Olympus Pen EE S
Olympus Pen EES2.jpg|[[/Olympus Pen EES-2|Olympus Pen EES-2]]
Olympus Pen EED.jpg|Olympus Pen EED
Olympus pen eed.jpg|Olympus Pen EED
0607 Olympus EED with lens cap (9122191695).jpg|Olympus Pen EED
0606 Olympus EED no lens cap (9124412452).jpg|Olympus Pen EED
MelvL P4290007 (5669556412).jpg|Olympus Pen EED
MelvL P4290003 (5669548202).jpg|Olympus Pen EED
MelvL P4290009 (5669557980).jpg|Olympus Pen EED
MelvL P4290006 (5669403335).jpg|Olympus Pen EED
MelvL P4290004 (5668984407).jpg|Olympus Pen EED
MelvL P4290008 (5668985957).jpg|Olympus Pen EED
MelvL P1040065 (5703691290).jpg|Olympus Pen EED
MelvL (5703980674).jpg|Olympus Pen EED
MelvL P1040116 (5709471065).jpg|Olympus Pen EED
MelvL P1040153 (5730856146).jpg|Olympus Pen EED
MelvL P1040155 (5726118704).jpg|Olympus Pen EED
Olympus Pen EES-2 (6717094541).jpg|Olympus Pen EES-2
Pen D3.jpg|[[/Olympus Pen D3|Olympus Pen D3]] (1965-1969)
Olympus PenF.jpg|[[/Olympus Pen F|Olympus Pen F]] (vers 1963)
Olympus-Pen-FT-with-38mm1 8.jpg|[[/Olympus Pen FT|Olympus Pen FT]] (vers 1968) {{75}}
</gallery>
== Appareils 24 x 36 reflex ==
<gallery>
Olympus FTL front.jpg|[[/Olympus FTL/]] (1971-1972) {{25}}
Olympus OM-1 (13573573703).jpg|[[/Olympus OM-1/]] (1973-1974) {{25}}
OM1NB 1.jpg|[[/Olympus OM-1N/]]
Olympus OM1MD.jpg|[[/Olympus OM-1 MD/]] (avant 1979) {{50}}
OM1-n MD (4072626146).jpg|[[/Olympus OM1-n MD/]] (1979-1983)
Olympus OM-2 with Zuiko 50mm f1.8.jpg|[[/Olympus OM-2/]] (1976) {{75}}
Olympus OM-2N img 0732.jpg|[[/Olympus OM-2N/]] {{25}}
Olympus OM-2 SP.jpg|[[/Olympus OM-2 SP/]] {{25}}
Olympus OM10 35-70mm.jpg|[[/Olympus OM10/]] (1978) {{100}}
Olympusom3.jpg|[[/Olympus OM-3/]] {{25}}
Olympus OM3 ti.jpg|OM-3 Ti
OM-3Ti Black.jpg
Olympus OM3 ti OM4 ti.jpg
Olympus OM20 - Tokino 70-210.jpg|[[/Olympus OM20|Olympus OM20 = Olympus OMG (A)]] (1982)
Olympus OM-30 (bottom).jpg|[[/Olympus OM30/]] (1982)
OlympusOM4 1.JPG|[[/Olympus OM-4/]] {{50}}
Olympus OM4 ti 01.jpg|OM-4 Ti
Olympus OM-4 Ti.JPG|OM-4 Ti
Olympus OM-4Ti worn black body with Zuiko 1.8-50mm lens and neckstrap.jpg
Vintage Olympus OM-PC (aka OM-40) 35mm SLR Film Camera, Made In Japan, Circa 1985 (13517132323).jpg|[[/Olympus OM-40|Olympus OM-40 = OM-PC]] (vers 1985)
</gallery>
== Appareils 24 x 36 compacts ==
<gallery>
Olympus LT1 (3007523325).jpg|[[/Olympus LT1/]]
Olympus Superzoom 3000.jpg|Olympus Superzoom 3000
Olympus XA camera and film.jpg|[[/Olympus XA/]] {{25}}
My Olympus XA1 (4379061989).jpg|[[/Olympus XA1/]]
My Olympus XA2 (4989175842).jpg|[[/Olympus XA2/]]
Olympus Ecru.jpg|Olympus Ecru
Olympus Ecru (4766955124).jpg|[[/Olympus Ecru/]] série limitée du Mju
Olympus Ecru cap.jpg
Olympus Ecru back.jpg
Olympus Ecru front.jpg
Olympus Ecru 01.jpg
Olympus Wide.jpg|Olympus wide
Olympus Trip 35.jpeg|[[/Olympus Trip 35/]] (vers 1968) {{50}}
Olympus-35 ECR.jpg|Olympus-35 ECR
Olympus-35 SP.jpg|[[/Olympus 35 SP/]] (1968) {{25}}
Olympus35DC3.jpg|35 DC
Olympus35DC2.jpg|35 DC
Olympus35DC1.jpg|35 DC
My Olympus 35DC (4797809987).jpg|35 DC
Olympus 35 RC img 1850.jpg|[[/Olympus 35 RC/]] (avant 1977) {{50}}
Olympus 35RD.jpg|35 RD
Olympus Stylus Epic 1118.jpg|[[/Olympus mju II|Olympus mju II = Olympus Stylus Epic]] {{25}}
My Olympus XA-3 (4024574761).jpg|[[/Olympus XA3/]]
Olympus XA4 Macro (2388651901).jpg|[[/Olympus XA4 Macro/]]
Olympus mju i.jpg|mju 1
Mju (3645746098).jpg
2009-11-26-Olympus-700BF-1.jpg|700 BF
2009-11-26-Olympus-700BF-2.jpg|700 BF
2009-11-26-Olympus-700BF-3.jpg|700 BF
Olympus-stylus hg.jpg[Stylus zoom 115
Olympus Superzoom 120 1a.jpg|Superzoom 120
Olympus Superzoom 120TC.jpg|Olympus Superzoom 120TC
My Olympus AF-1 Infinity (4876749434).jpg|[[/Olympus AF-1 Infinity/]]
Olympus Infinity Jr. (4815671398).jpg|[[/Olympus Infinity Jr./]]
Olympus AZ-200 Superzoom.jpg|Olympus AZ-200 Superzoom
Olympus Trip MD3.jpg|Olympus Trip MD3
Olympus LT-105Z (6733278979).jpg|Olympus LT-105Z
</gallery>
== Appareils 24x36 bridge ==
<gallery>
My Olympus IS-1 (4662576887).jpg|[[/Olympus IS-1|Olympus IS-1]]
Olympus IS10 (3) (5789273975).jpg|[[/Olympus IS-10|Olympus IS-10]] {{25}}
Olympus ED 35-180 (6175609523).jpg|[[/Olympus IS-3000/]] (1993)
Olympus-IS-100-07.jpg|[[/Olympus IS-100|Olympus IS-100]] (1994) {{25}}
Olympus IS100S (5) (5789275039).jpg|[[/Olympus IS-100S|Olympus IS-100S]] {{25}}
Olympus Alvesgaspar.jpg|[[/Olympus IS-1000|Olympus IS-1000]] {{25}}
</gallery>
== Appareils pour le format AGFA Rapid ==
<gallery>
Image:IMG.svg|[[/Olympus Pen RAPID EES|Olympus Pen RAPID EES]]
Image:IMG.svg|[[/Olympus Pen RAPID EED|Olympus Pen RAPID EED]]
</gallery>
== Appareils pour le format 126 ==
<gallery>
Olympus Quickmatic 600 (2759484117).jpg|[[/Olympus Quickmatic 600|Olympus Quickmatic 600]]
</gallery>
== Appareils pour le format APS ==
<gallery>
Olympus i zoom 2000 (3854940049).jpg|[[/Olympus i zoom 2000/]] (2000)
</gallery>
== Appareils numériques non reflex ==
=== année 1996 ===
<gallery>
Image:IMG.svg|[[/Olympus D-200L|Olympus D-200L]] {{50}} (5 septembre 1996)
Image:IMG.svg|[[/Olympus D-300L|Olympus D-300L]] {{50}} (5 septembre 1996)
</gallery>
=== année 1997 ===
<gallery>
Image:Olympus C-820L.jpg|[[/Olympus Camedia C-820L|Olympus Camedia C-820L]] {{50}} (septembre 1997)
File:2009-11-26-Olympus-C-820L-1.jpg|C-820L
File:2009-11-26-Olympus-C-820L-2.jpg|C-820L
File:2009-11-26-Olympus-C-820L-3.jpg|C-820L
File:2009-11-26-Olympus-C-820L-5.jpg|C-820L
File:2009-11-26-Olympus-C-820L-6.jpg|C-820L
File:2009-11-26-Olympus-C-820L-4.jpg|C-820L
File:Camedia-C-820L-05.jpg|C-820L
File:Camedia-C-820L-02.jpg|C-820L
</gallery>
=== année 1998 ===
<gallery>
Image:IMG.svg|[[/Olympus D-340L|Olympus D-340L]] {{50}} (28 septembre 1998)
File:Olympus C-900 ZOOM.jpg|[[/Olympus D-400|Olympus D-400 = Stylus Digital 400 = Olympus C900Z)]] {{75}} (2 novembre 1998)
</gallery>
=== année 1999 ===
<gallery>
Image:IMG.svg|[[/Olympus D-340R|Olympus D-340R]] {{50}} (2 janvier 1999)
File:Olympus Camedia C-2000 Z.jpg|[[/Olympus C-2000 Zoom|Olympus C-2000 Zoom]] {{50}} (16 février 1999)
Image:IMG.svg|[[/Olympus C-21|Olympus C-21]] {{50}} (28 juin 1999)
File:Olympus Camedia C-21T.commu CP+ 2011.jpg|Olympus Camedia C-21T.commu
Image:IMG.svg|[[/Olympus D-450 Zoom|Olympus D-450 Zoom = Olympus C920Z]] {{50}} (31 juillet 1999)
Image:IMG.svg|[[/Olympus C-2020 Zoom|Olympus C-2020 Zoom]] (19 octobre 1999)
</gallery>
=== année 2000 ===
<gallery>
Olympos-Camedia-C3000.jpg|C3000
Image:IMG.svg|[[/Olympus C-3030 Zoom|Olympus C-3030 Zoom]] (27 janvier 2000)
Image:IMG.svg|[[/Olympus D-360L|Olympus D-360L]] (2 février 2000)
Image:IMG.svg|[[/Olympus C-460 Zoom|Olympus C-460 Zoom]] (8 février 2000)
Image:IMG.svg|[[/Olympus C-3000 Zoom|Olympus C-3000 Zoom]] (24 avril 2000)
Image:Olympus UZ-2100 03.jpg|[[/Olympus C-2100 Ultra Zoom|Olympus C-2100 Ultra Zoom]] (15 juin 2000)
Image:Olympus UZ-2100 01.jpg
Image:Olympus UZ-2100 02.jpg
Image:IMG.svg|[[/Olympus D-490 Zoom|Olympus D-490 Zoom]] (1er août 2000)
File:Olympus E100RS.jpg|[[/Olympus E-100 RS|Olympus E-100 RS]] (22 août 2000)
File:Olympus Camera E-100RS.jpg|E-100 RS
Image:IMG.svg|[[/Olympus C-3040 Zoom|Olympus 3-2040 Zoom]] (21 novembre 2000)
Image:IMG.svg|[[/Olympus C-2040 Zoom|Olympus C-2040 Zoom]] (21 novembre 2000)
</gallery>
=== année 2001 ===
<gallery>
Olympus Camedia C-1.jpg|[[/Olympus C-1|Olympus C-1]] (6 mars 2001)
Olympus C-700 Ultra Zoom.jpg|[[/Olympus C-700 Ultra Zoom|Olympus C-700 Ultra Zoom]] (19 mars 2001)
IMG.svg|[[/Olympus D-150 Zoom|Olympus D-150 Zoom]] (8 mai 2001)
IMG.svg|[[/Olympus D-510 Zoom|Olympus D-510 Zoom]] (8 mai 2001)
IMG.svg|[[/Olympus D-370|Olympus D-370]] (5 juin 2001)
IMG.svg|[[/Olympus C-4040 Zoom|Olympus C-4040 Zoom]] (20 juin 2001)
IMG.svg|[[/Olympus D-40 Zoom|Olympus D-40 Zoom]] (2 septembre 2001)
Olympus Camedia C-2.jpg|[[/Olympus C-2|Olympus C-2]] (13 septembre 2001)
Olympus Camedia C-3020.jpg|[[/Olympus C-3020 Zoom|Olympus C-3020 Zoom]] (15 octobre 2001)
</gallery>
=== année 2002 ===
<gallery>
File:My Olympus D-520Z (4794377895).jpg|[[/Olympus D-520 Zoom|Olympus D-520 Zoom]] (13 mars 2002)
File:Olympus D-380.jpg|[[/Olympus D-380|Olympus D-380 = Olympus C-120]] (13 mars 2002)
Olympus C-2020Z.jpg|Olympus Camedia C-2020Z
OlympusC220ZoomCamera.jpg|C220Z
File:Olympus Camedia C-720.jpg|[[/Olympus C-720 Ultra Zoom|Olympus C-720 Ultra Zoom]] (8 mai 2002)
Image:IMG.svg|[[/Olympus C-300 Zoom|Olympus C-300 Zoom]] (8 mai 2002)
Image:IMG.svg|[[/Olympus C-4000 Zoom|Olympus C-4000 Zoom]] (25 juillet 2002)
File:Olympus C-5050Z, -Apr. 2007 a.jpg|[[/Olympus C-5050 Zoom|Olympus C-5050 Zoom]] (19 août 2002)
File:Olympus C-5050Z, -6 Aug. 2006 a.jpg|C-5050
File:Olympus C-5050Z, -19 Nov. 2005 a.jpg|C-5050
Image:Olympus C-730UZ Front Left.jpg|[[/Olympus C-730 UZ|Olympus C-730 UZ]] (12 septembre 2002)
Image:IMG.svg|[[/Olympus C-50 Zoom|Olympus C-50 Zoom]] (24 septembre 2002)
</gallery>
=== année 2003 ===
<gallery>
Image:IMG.svg|[[/Olympus Stylus 400|Olympus Stylus 400 = Olympus µ 400 Digital]] (9 janvier 2003)
Image:IMG.svg|[[/Olympus Stylus 300|Olympus Stylus 300 = Olympus µ 300 Digital]] (9 janvier 2003)
Fichier:Olympus Camedia C-740 Ultra Zoom 10.JPG|[[/Olympus C-740 Ultra Zoom|Olympus C-740 Ultra Zoom]] {{75}} (2 mars 2003)
File:Olympus C-150.JPG|[[/Olympus Camedia C-150|Olympus Camedia C-150 = Olympus D-390]] (2 mars 2003)
Image:Olympus Camedia C-350 Zoom -3.JPG|[[/Olympus D-560 Zoom|Olympus D-560 Zoom = Camedia C-350 zoom]] (2 mars 2003)
Image:Olympus Camedia C-350 Zoom -2.JPG
Image:Olympus Camedia C-350 Zoom -1.JPG
Image:Olympus Camedia C-350 Zoom.JPG
Image:Olympus-C350Z.jpg
Image:Olympus C-750.jpg|[[/Olympus C-750 Ultra Zoom|Olympus C-750 Ultra Zoom]] (2 mars 2003)
Image:Olympus C-750 back.jpg
Image:Olympus C-750 front right-1.jpg
Image:Olympus C-750 front right.jpg
Image:Olympus C-750 front left.jpg
Image:Digital Camera.jpg|[[/Olympus C-5000 Zoom|Olympus C-5000 Zoom]] (29 août 2003)
Olympus Camedia C-5000Z 3750.jpg
Olympus Camedia C-5000Z 3751.jpg
Olympus Camedia C-5000Z 3752.jpg
Olympus Camedia C-5000Z 3753.jpg
Olympus Camedia C-5000Z 3754.jpg
Olympus Camedia C-5000Z 3755.jpg
Olympus Camedia C-5000Z 3756.jpg
Image:IMG.svg|[[/Olympus C-5060 Zoom|Olympus C-5060 Zoom]] (29 septembre 2003)
</gallery>
=== année 2004 ===
<gallery>
IMG.svg|[[/Olympus D-540 Zoom|Olympus D-540 Zoom]] (14 février 2004)
IMG.svg|[[/Olympus D-580 Zoom|Olympus D-580 Zoom]] (14 février 2004)
Stylus410specs.jpg|[[/Olympus Stylus 410|Olympus Stylus 410]] (14 février 2004)
OLYMPUS C-8080WZ 01.jpg|[[/Olympus C-8080 WideZoom|Olympus C-8080 WideZoom]] (14 février 2004)
Olympus CAMEDIA C-8080.JPG|C-8080
C-8080WZ rear.JPG|C-8080
C-8080WZ tele.JPG|C-8080
Olympus C-760 UltraZoom (2178205925).jpg|[[/Olympus Camedia C-760 UZ/]]
Olympus C-765UZ, -13 juni 2006 a.jpg|[[/Olympus C-765 Ultra Zoom|Olympus C-765 Ultra Zoom]] (14 février 2004)
Olympus C-766 UZ back.jpg
Olympus C-765 UZ front.jpg
IMG.svg|[[/Olympus C-770 Ultra Zoom|Olympus C-770 Ultra Zoom]] (14 février 2004)
Olympus D-395.JPG|[[/Olympus D-395|Olympus D-395]] (18 mars 2004)
Olympus C-60 Zoom.JPG|[[/Olympus C-60 Zoom|Olympus C-60 Zoom]] (18 mars 2004)
Olympus µ-mini.jpeg|[[/Olympus Stylus Verve|Olympus Stylus Verve = Olympus Mju-mini = Olympus mju-ii]] (3 septembre 2004)
IMG.svg|[[/Olympus C-7000 Zoom|Olympus C-7000 Zoom]] (16 septembre 2004)
IMG.svg|[[/Olympus D-535 Zoom|Olympus D-535 Zoom]] (16 septembre 2004)
IMG.svg|[[/Olympus Stylus 500|Olympus Stylus 500]] (29 novembre 2004)
</gallery>
=== année 2005 ===
<gallery>
IMG.svg|[[/Olympus D-425/]] (5 janvier 2005)
IMG.svg|[[/Olympus C-7070 Wide Zoom/]] (5 janvier 2005)
IMG.svg|[[/Olympus C-5500 Sport Zoom/]] (5 janvier 2005)
IMG.svg|[[/Olympus Stylus Verve S/]] (17 février 2005)
IMG.svg|[[/Olympus D-545 Zoom/]] (17 février 2005)
Olympus C-500Z 3.JPG|[[/Olympus D-595 Zoom|Olympus D-595 Zoom = Olympus C-500Z]] (17 février 2005)
Olympus C-500Z 2.JPG
Olympus C-500Z 1.JPG
Olympus IR-300.jpg|[[/Olympus IR-300/]] (17 février 2005)
IMG.svg|[[/Olympus D-630 Zoom/]] (17 février 2005)
IMG.svg|[[/Olympus Stylus 800/]] (12 mai 2005)
IMG.svg|[[/Olympus D-435/]] (20 mai 2005)
Olympus FE 110 (2254131662).jpg|[[/Olympus FE-110/]] (29 août 2005)
Olympus-SP-310-p1030353.jpg|[[/Olympus SP-310/]] {{00}} (29 août 2005)
Olympus FE-120 01.jpg|[[/Olympus FE-120/]] {{50}} (29 août 2005)
IMG.svg|[[/Olympus Stylus 600/]] (29 août 2005)
Oly SP-350-1.jpg|[[/Olympus SP-350/]] {{25}} (29 août 2005)
IMG.svg|[[/Olympus SP-500 UZ/]] {{25}} (29 août 2005)
Olympus FE-100 front.jpg|[[/Olympus FE-100/]] (29 août 2005)
IMG.svg|[[/Olympus SP-700/]] (4 octobre 2005)
</gallery>
=== année 2006 ===
<gallery>
File:My Olympus SP-320 (4171943306).jpg|[[/Olympus SP-320|Olympus SP-320]] (26 janvier 2006) {{50}}
Image:IMG.svg|[[/Olympus FE-115|Olympus FE-115]] (26 janvier 2006) {{25}}
File:Olympus-digitale-camera-FE-130.JPG|[[/Olympus FE-130|Olympus FE-130]] (26 janvier 2006) {{50}}
Image:IMG.svg|[[/Olympus FE-140|Olympus FE-140]] (26 janvier 2006) {{25}}
Image:IMG.svg|[[/Olympus FE-150|Olympus FE-150]] (26 janvier 2006) {{25}}
Image:Olympus µ 700.jpg|[[/Olympus Stylus 700|Olympus Stylus 700 = Mju 700 Digital]] (26 janvier 2006) {{50}}
Image:IMG.svg|[[/Olympus Stylus 720 SW|Olympus Stylus 720 SW = Olympus Mju 720 SW Digital]] (26 janvier 2006)
Image:IMG.svg|[[/Olympus Stylus 810|Olympus Stylus 810 = Olympus Mju 810 Digital]] (26 janvier 2006) {{50}}
Image:Olympus X760 01.jpg|[[/Olympus FE-170|Olympus FE-170 = Olympux X-760]] (24 août 2006)
Image:IMG.svg|[[/Olympus FE-180|Olympus FE-180]] (24 août 2006) {{25}}
Image:Olympus FE190.JPG|[[/Olympus FE-190|Olympus FE-190]] (24 août 2006) {{50}}
Image:IMG.svg|[[/Olympus FE-200|Olympus FE-200]] (24 août 2006) {{50}}
File:Olympus μ 725 SW.jpg|[[/Olympus Stylus 725 SW|Olympus Stylus 725 SW = Olympus Mju 725 SW Digital]] (24 août 2006)
Image:IMG.svg|[[/Olympus Stylus 730|Olympus Stylus 730 = Olympus Mju 730 Digital]] (24 août 2006) {{50}}
Image:IMG.svg|[[/Olympus Stylus 740|Olympus Stylus 740 = Olympus Mju 740 Digital]] (24 août 2006) {{50}}
Image:IMG.svg|[[/Olympus Stylus 750|Olympus Stylus 750 = Olympus Mju 750 Digital]] (24 août 2006) {{75}}
Image:IMG.svg|[[/Olympus Stylus 1000|Olympus Stylus 1000 = Olympus Mju 1000 Digital]] (24 août 2006) {{75}}
File:OlympusSP510UZ.jpg|[[/Olympus SP-510 UZ|Olympus SP-510 UZ]] (24 août 2006) {{50}}
</gallery>
=== année 2007 ===
<gallery>
Image:Olympus SP 550UZ.jpg|[[/Olympus SP-550 UZ|Olympus SP-550 UZ]] (25 janvier 2007) {{100}}
File:Fe-210.png|[[/Olympus FE-210|Olympus FE-210 = X-775]] (25 janvier 2007) {{75}}
Image:IMG.svg|[[/Olympus FE-230|Olympus FE-230]] (25 janvier 2007) {{75}}
Image:IMG.svg|[[/Olympus FE-240|Olympus FE-240]] (25 janvier 2007) {{75}}
Image:IMG.svg|[[/Olympus FE-250|Olympus FE-250]] (25 janvier 2007) {{75}}
Image:Olympus µ 760.jpg|[[/Olympus Stylus 760|Olympus Stylus 760 = Olympus mju 760 Digital]] (25 janvier 2007) {{75}}
Image:Stylus 770SW.jpg|[[/Olympus Stylus 770 SW|Olympus Stylus 770 SW = Olympus mju 770 SW Digital]] (25 janvier 2007) {{100}}
Image:IMG.svg|[[/Olympus Stylus 780|Olympus Stylus 780]] (5 mars 2007)
Image:IMG.svg|[[/Olympus FE-270|Olympus FE-270]] (23 août 2007) {{75}}
Image:IMG.svg|[[/Olympus FE-280|Olympus FE-280]] (23 août 2007) {{75}}
Image:IMG.svg|[[/Olympus FE-290|Olympus FE-290]] (23 août 2007) {{75}}
Image:IMG.svg|[[/Olympus FE-300|Olympus FE-300]] (23 août 2007) {{75}}
Image:IMG.svg|[[/Olympus Stylus 790 SW|Olympus Stylus 790 SW = Olympus mju 790 SW Digital]] (23 août 2007) {{75}}
Image:IMG.svg|[[/Olympus Stylus 820|Olympus Stylus 820 = Olympus mju 820 Digital]] (23 août 2007) {{75}}
File:OLYMPUS Mu 830.jpeg|[[/Olympus Stylus 830|Olympus Stylus 830 = Olympus mju 830 Digital]] (23 août 2007) {{100}}
Image:IMG.svg|[[/Olympus Stylus 1200|Olympus Stylus 1200 = Olympus mju 1200 Digital]] (23 août 2007) {{75}}
Image:IMG.svg|[[/Olympus SP-560 UZ|Olympus SP-560 UZ]] (25 janvier 2007) {{75}}
</gallery>
=== année 2008 ===
<gallery>
File:Olympus SP-570UZ, -Nov. 2008 a.jpg|[[/Olympus SP-570 UZ|Olympus SP-570 UZ]] (2008) {{50}}
Image:IMG.svg|[[/Olympus Mju 1040|Olympus Mju 1040]] (2008) {{25}}
Image:IMG.svg|[[/Olympus Mju 1050sw|Olympus Mju 1050sw]] (2008) {{25}}
Image:IMG.svg|[[/Olympus Mju 1060|Olympus Mju 1060]] (2008) {{25}}
Image:IMG.svg|[[/Olympus FE-20|Olympus FE-20]] (2008) {{25}}
Image:IMG.svg|[[/Olympus FE-360|Olympus FE-360]] (19 août 2008) {{75}}
Image:IMG.svg|[[/Olympus FE-370|Olympus FE-370]] (2008) {{25}}
</gallery>
=== année 2009 ===
<gallery>
Bfishadow Olympus E-P1.jpg|Olympus Pen E-P1
Bfishadow Olympus E-P1 bottom.jpg|Olympus Pen E-P1
Bfishadow Olympus E-P1 top.jpg|Olympus Pen E-P1
Bfishadow Olympus E-P1 back.jpg|Olympus Pen E-P1
Olympus Pen img 3486.jpg|Olympus Pen E-P1
Olympus IMG 2163.jpg|Olympus Pen E-P1
Olympus E-P1 (3634318402).jpg
Olympus E-P1 (3634895524).jpg
Olympus E-P1 (3634080929).jpg
Olympus E-P1- Sleek frame (3634087049).jpg
Olympus E-P1 (3634889300).jpg
Olympus E-P1 (3634079289).jpg
Olympus E-P1 (3633504211).jpg
Olympus E-P1 (3634318984).jpg
Olympus E-P1 (3634318918).jpg
Olympus E-P1 (3633503717).jpg
</gallery>
=== année 2010 ===
<gallery>
File:Olympus PEN E-PL1.jpg|[[/Olympus PEN E-PL1|Olympus PEN E-PL1]] (10 février 2010) {{100}}
</gallery>
=== année 2011 ===
<gallery>
File:Olympus E-PL2 with Leica Summicron 50 f2 LTM lens.jpg|[[/Olympus Pen E-PL2|Olympus Pen E-PL2]] (6 janvier 2011) {{75}}
Image:IMG.svg|[[/Olympus SP-610UZ|Olympus SP-610UZ]] (6 janvier 2011) {{75}}
File:Olympus-XZ-1.jpg|[[/Olympus XZ-1|Olympus XZ-1]] (6 janvier 2011) {{100}}
</gallery>
=== année 2012 ===
<gallery>
Image:IMG.svg|[[/Olympus Tough TG-1 iHS|Olympus Tough TG-1 iHS]] (8 mai 2012) {{75}}
</gallery>
=== année 2013 ===
<gallery>
</gallery>
=== année 2014 ===
<gallery>
</gallery>
=== année 2015 ===
<gallery>
Olympus OM-D E-M5II (18421339075).jpg|[[/Olympus OM-D E-M5 II]] (5 février 2015) {{100}}
IMG.svg|[[/Olympus Tough TG-4/]] (13 avril 2015) {{75}}
Olympus OM-D E-M10 Mark II.JPG|[[/Olympus OM-D E-M10 II/]] (25 août 2015) {{75}}
</gallery>
=== année 2016 ===
<gallery>
Olympus PEN-F.jpg|[[/Olympus Pen-F/]] (27 janvier 2016) {{00}}
</gallery>
==== à classer ====
<gallery>
2013-265-121 Tough New Toy (8700211111).jpg|Olympus Tough TG-2
OLYMPUS PEN MINI E-PM2 (8651180173).jpg|Olympus E-PM2
Olympus E-20P 01.jpg|Olympus E-20P
Olympus E-20P 02.jpg
Olympus E-20P 03.jpg
Olympus E-20P 04.jpg
Olympus E-20P 05.jpg
Olympus E-20P 06.jpg
Olympus E-20P 07.jpg
Olympus E-20P 08.jpg
Olympus E-20P 09.jpg
Olympus E-20P 10.jpg
Olympus E-20P 11.jpg
Olympus X-750.jpg|Olympus X-750
Olympus PEN F.jpg|Olympus PEN F
Olympus Pen F (digital).jpg|Olympus PEN F
Olympus Pen F-IMG 9925.jpg|Olympus PEN F
Olympus Pen F-IMG 9927.JPG|Olympus PEN F
Olympus PEN F.jpg|Olympus PEN F
Olympus PEN-F.jpg|Olympus PEN F
Olympus PEN E-PL6 black kit lens 2016-03-03.jpg|Olympus PEN E-PL6
Olympus OM D E-M1 - Johnragai Gear - 09.11.2014 (15772393942).jpg|Olympus OM-D E-M1
Olympus OM D E-M1 - Johnragai Gear - 09.11.2014 (15770833985).jpg
OM D E-M1 with 75mm f-1.8 (9869006524).jpg
OM D E-M1 with 12-60mm f-2.8-4 ED SWD (9869086256).jpg
Olympus OM-D E-M1 Mark II mock-up (rough model) 2017 CP+.jpg|Olympus OM-D E-M1 Mark II
Olympus OM-D E-M1 Mark II mock-up (design model) 2017 CP+.jpg
Olympus OM-D E-M1 Mark II mock-up (body+power battery holder model) 2017 CP+.jpg
Olympus OM-D E-M1 Mark II magnesium-alloy chassis 2017 CP+.jpg
Olympus OM-D E-M1 Mark II D81 8378-2.jpg
Olympus.OM-D.E-M1.Mark.II.back.view.jpg
Olympus C-760 UltraZoom (2178205925).jpg|[[/Olympus Camedia C-760 UZ/]]
Olympus camedia C-800L.jpg|[[/Olympus Camedia C-800L/]]
Olympus SH-1 zilver, -2015 a.jpg|Olympus SH-1
Olympus SH-1 zilver, -2015 b.jpg
Olympus SH-1 zilver, -2015 c.jpg
Olympus Pen EPL7.JPG|[[/Olympus Pen E-PL7/]]
Digitalkamera von Olympus.JPG|FE-5020
Olympus XZ-10, -februari 2013 a.jpg|Olympus XZ-10
Leong IMG 2989 (6338669929).jpg
Leong IMG 2986 (6339413622).jpg
Leong IMG 0497 (6689370435).jpg
P1000963 (6707919805).jpg
Olympus OM-D E-M10 2014 CP+.jpg|Olympus OM-D E-M10
Olympus OM-D E-M10 01.jpg
Olympus OM-D E-M10 cutaway 2014 CP+.jpg|Olympus OM-D E-M10
Olympus OM-D E-M10 cutted 1.jpg
Olympus OM-D E-M10 cutted 2.jpg
Olympus PEN E-P5 Back 1.jpg|Olympus PEN E-P5
Olympus PEN E-P5 Front 1.jpg
Olympus PEN E-P5 Front 2.jpg
EP5 with 45mm F1.8.jpg
Olympus E-P5.jpg
Olympus X-500 D-590Z C-470Z.jpg|X-500 = D-590Z = C-470Z
Olympus E-PM1 + BCL-15.jpg|Olympus E-PM1
Olympus TG-820 Camera.jpg|Olympus TG-820
Olympus TG-820 front with lens cover open.jpg|Olympus TG-820
Olympus TG-820 Top.JPG|Olympus TG-820
Olympus E-P2.jpg|Olympus E-P2
Micro Four Thirds Olympus E-P2 with Panasonic Lumix G 20mm F1.7 ASPH aspherical pancake lens.jpg
Olympus EPL5 top.jpg|E-PL5
Olympus EPL5 back 01.jpg|E-PL5
Olympus EPL5 back 02.jpg|E-PL5
Olympus EPL5 front lens.jpg|E-PL5
Olympus EPL5 front.jpg|E-PL5
Olympus epl5 vf4.jpg
Olympus epl5 vf4 45mm 01.jpg
Olympus epl5 vf4 45mm 02.jpg
Olympus epl5 vf4 45mm 03.jpg
Olympus PEN Lite and Nissin i40.jpg
Olympus VR-340.JPG|Olympus VR-340
Olympus-digitale-camera-X-720.JPG|Olympus X-720
Olympus Camedia C-310 Zoom Digital Camera.JPG|Olympus Camedia C-310 Zoom
Olympus PEN E-PL3.jpg|[[/Olympus Pen E-PL3|Olympus Pen E-PL3]]
Olypmus SP-810UZ, closed.jpg|Olympus SP-810UZ
Olympus_SP-810UZ,_no_zoom,_flash_closed.jpg|Olympus SP-810UZ
Olympus_SP-810UZ,_full_zoom,_flash_opened.jpg|Olympus SP-810UZ
Olympus E-P3 006.JPG|[[/Olympus E-P3/]]
Olympus AZ-300 Superzoom.jpg|[[/Olympus AZ-300 Superzoom/]]
Olympus SP560UZ DSCF9120.jpg|SP560 UZ
Olympus u5000.jpg|Mju 5000
Stylus Tough 8000.jpg|Stylus Tough 8000
Olympus_SP590_UZ_01_(RaBoe).jpg|SP590 UZ
Olympus_SP590_UZ_02_(RaBoe).jpg|SP590 UZ
Olympus_SP590_UZ_03_(RaBoe).jpg|SP590 UZ
Olympus_SP590_UZ_04_(RaBoe).jpg|SP590 UZ
Olympus_SP590_UZ_05_(RaBoe).jpg|SP590 UZ
Olympus_SP590_UZ_06_(RaBoe).jpg|SP590 UZ
Olympus_SP590_UZ_07_(RaBoe).jpg|SP590 UZ
Olympus SP590 UZ 2010-by-RaBoe-02.jpg
Olympus SP590 UZ 2010-by-RaBoe-01.jpg
FE-310.jpg|FE-310
Image:Olympus u850SW.jpg|mju 850SW
Olympus µ850SW, -1 mei 2010 a.jpg
Image:Olympus img 1845.jpg|C-1400
Image:Olympus_FE-340_8MP_camera_01.jpg|FE-340
Olympus-MicroFT-Model.jpg|Micro four thirds
OlyE-P2Test10112009-01.jpg|EP-2
Olympus VR-310.jpg|Olympus VR-310
</gallery>
== Appareils reflex numériques ==
=== année 1997 ===
<gallery>
Image:IMG.svg|[[/Olympus D-500L|Olympus D-500L]] {{50}} (10 septembre 1997)
Image:IMG.svg|[[/Olympus D-600L|Olympus D-600L]] {{50}} (10 septembre 1997)
Image:Olympus img 1846.jpg|C-1400
Image:Olympus img 1845.jpg|C-1400
</gallery>
=== année 1998 ===
<gallery>
Image:Olympus C-1400 01.jpg|[[/Olympus D-620L|Olympus D-620L = Olympus C1400XL]] (2 novembre 1998) {{75}}
Image:Olympus Camedia C 1400 XL 61.jpg|[[/Olympus D-620L|Olympus D-620L = Olympus C1400XL]] (2 novembre 1998) {{75}}
Image:Olympus Camedia C 1400 XL 57.jpg|[[/Olympus D-620L|Olympus D-620L = Olympus C1400XL]] (2 novembre 1998) {{75}}
</gallery>
=== année 1999 ===
<gallery>
Image:IMG.svg|[[/Olympus C-2500 L|Olympus C-2500 L]] {{50}} (18 mars 1999)
</gallery>
=== année 2000 ===
<gallery>
File:Olympus E-10.jpg|[[/Olympus E-10|Olympus E-10]] (22 août 2000)
File:Olympus E-20P.JPG|[[/Olympus E-20|Olympus E-20]]
Olympus E-20n.jpg|Olympus E-20n
</gallery>
=== année 2003 ===
<gallery>
Image:Olympus E-1 2.jpg|[[/Olympus E-1|Olympus E-1]] (24 juin 2003)
Image:E-1 hinten oben.jpg
Image:E-1 Seite hinten.jpg
Image:E-1 hinten.jpg
File:E-1 vorne.jpg
File:Olympus E-1 body.jpg
</gallery>
=== année 2004 ===
<gallery>
E-300.jpg|[[/Olympus E-300|Olympus E-300 (EVOLT E-300)]] (27 septembre 2004)
</gallery>
=== année 2005 ===
<gallery>
File:E-500 Body.jpg|[[/Olympus E-500|Olympus E-500]] {{25}} (2005)
Olympus E-500 with Minolta MD Lens (5391265164).jpg|[[/Olympus E-500/]] (26 septembre 2005)
</gallery>
=== année 2006 ===
<gallery>
File:Olympus E-330. Zuiko Digital ED.jpg|[[/Olympus E-330|Olympus E-330 = EVOLT E-330]] {{25}} (26 janvier 2006)
Image:Oly e 400 voorkant.jpg|[[/Olympus E-400|Olympus E-400]] {{75}} (14 septembre 2006)
</gallery>
=== année 2007 ===
<gallery>
Olympus E410 img 1030.jpg|[[/Olympus E-410/]] (5 mars 2007)
Olympus E510 img 1029.jpg|[[/Olympus E-510/]] (5 mars 2007)
P3069465 (3333310515).jpg|[[/Olympus E-3/]]
Olympus_E-3_Camera.jpg|[[/Olympus E3/]] {{75}} (16 octobre 2007)
</gallery>
=== année 2008 ===
<gallery>
Olympus E-420.jpg|E-420
Olympus E-420 EZ40150.jpg|E-420
Olympus E-420 Body Front.jpg|E-420
Olympus E-420 Pancake25mm Top.jpg|E-420
Olympus E-420 Body Top XL.jpg|E-420
Image:Olymous E420 img 1248.jpg|E-420
Image:Olympus E-420 (back).jpg|E-420
Image:Olympus E-420 (front).jpg|E-420
</gallery>
=== année 2009 ===
<gallery>
Olympus E-450.JPG|[[/Olympus E-450|Olympus E-450]] {{75}} (31 mars 2009)
Olympus E-30 01.jpg|Olympus E-30
Olympus E-30 02.jpg|Olympus E-30
Olympus E-30 03.jpg|Olympus E-30
Olympus E-30 04.jpg|Olympus E-30
Olympus E-30 rear01.jpg|E30
Olympus E-30 front01.jpg|E30
E-30-back.jpg|E3
Olympus E-30 with ZD 14-54mm f2.8-3.5II 01.JPG|E30
Olympus E-30-Cutmodel.jpg|E30 coupé
E-30-with-14-54.jpg|E30
Olympus E30-IMG 2445.jpg|E30
Olympus E30-IMG 2442.jpg|E30
Olympus E30-IMG 2441.jpg|E30
Olympus E-620 front.jpg|E-620
Olympus E-620.jpg|E-620
Olympus E-620 with battery grip.jpg|E-620
Olympus E-620 without lens.jpg|E620
Olympus E-620 swivel screen open.JPG|E620
Olympus E620 DSLR.jpg|E620
</gallery>
=== année 2010 ===
<gallery>
Olympus-E5.jpg|E-5
</gallery>
'''à classer'''
<gallery>
Olympus OM-D E-M1- 20131118.jpg|Olympus OM-D E-M1
OM-D EM-1.jpg|OM-D EM-1
Oly-EM1-connector.jpg
Olympus OM-D E-M1 01.jpg
Olympus OM-D E-M1 cutted 1.jpg
Olympus OM-D E-M1 cutted 2.jpg
Olympus OM-D E-M1 cutted 3.jpg
Olympus OM-D E-M1 image stabilization unit.jpg
Olympus E-M5 (front, cropped).jpg|OM-D E-M5
Oly-E-M5.jpg
OLYMPUS OM-D E-M5.jpg
Olympus OM-D E-M5.jpg
Olympus E-M5, Nokton 25mm.jpg
Olympus E-M5 01.jpg
Olympus E-M5 02.jpg
Olympus E-M5 03.jpg
Olympus E-M5 04.jpg
Olympus E-M5 05.jpg
Olympus E-M5 06.jpg
Olympus E-M5 08.jpg
Olympus E-M5 07.jpg
Olympus E-M5 09.jpg
Olympus E-M5 10.jpg
Olympus E-M5 11.jpg
Olympus E-M5 12.jpg
Olympus E-M5 13.jpg
Olympus E-M5 14.jpg
Olympus E-M5 15.jpg
Olympus E-M5 16.jpg
Olympus OM-D E-M5, Taipei, TW.jpg
Olympus E-M5 + Bigma.jpg
Micro Four Thirds Olympus OM-D E-M5 digital camera.jpg
Oly-E-M5.jpg
Olympus OM-D E-M5 Elite black kit.jpg
Olympus OM-D E-M5 Elite black.jpg
Olympus E-M5 with 45mm F1.8.jpg
Olympus OM 50mm f1.8.jpg|E-420
Olympus-e-520-front.png|[[/Olympus E-520|Olympus E-520]]
</gallery>
== Modules pour smartphone ==
<gallery>
Olympus Air A01,mounted lens and phone.jpg|Olympus Air A01
</gallery>
== Objectifs à mise au point manuelle ==
=== Série Pen ===
<gallery>
Olympus-20mm-F3 5-with-TTL-No.jpg|[[/Olympus Zuiko 20 mm f/3,5/]]
Image:PenF-Zuiko-20mm.JPG
</gallery>
=== Série FTL (M42) ===
<gallery>
M42.OffenblendOLYMPUS.jpg|Olympus M 42
Olympus FTL G.Zuiko 28 mm f 3,5.jpg|Olympus G.Zuiko 28 mm f/3,5
IMG.svg|Olympus Zuiko 35 mm f/2,8/
IMG.svg|Olympus Zuiko 50 mm f/1,4/
Olympus FTL F. Zuiko 50 mm f 1,8.jpg|Olympus Zuiko 50 mm f/1,8
IMG.svg|Olympus Zuiko 135 mm f/3,5/
IMG.svg|Olympus Zuiko 200 mm f/4,0/
</gallery>
=== Série OM-System ===
<gallery>
OMLenses.jpg
OM Zuiko f2 lenses.jpg|Zuiko f/2
</gallery>
<gallery>
IMG.svg|[[/Olympus Zuiko 8 mm f/2,8/]] (avant 1978) {{25}}
IMG.svg|[[/Olympus Zuiko 16 mm f/3,5/]] (avant 1978) {{25}}
Olympus Zuiko Auto-Macro 20mm 1-2 lens (4243439258).jpg|[[/Olympus Zuiko Auto-Macro 20 mm f/1,2/]]
ZUIKO21mmF2.jpg|[[/Olympus Zuiko 21 mm f/2/]]
Olympus Zuiko 2,0 21mm.jpg|[[/Olympus Zuiko 21 mm f/2/]]
IMG.svg|[[/Olympus Zuiko 21 mm f/3,5/]] (avant 1978) {{25}}
IMG.svg|[[/Olympus Zuiko 24 mm f/2/]] (avant 1978) {{25}}
Obiettivo fotografico ultragrandangolare, messa a fuoco elicoidale, con innesto a baionetta - Museo scienza tecnologia Milano 13087.jpg|Olympus Zuiko Auto-W 24 mm f/2,8 (1991)
Zuiko shift 24mm.jpg|Olympus Zuiko shift 24 mm f/3,5 à décentrement
Olympus Zuiko 24mm f 2.8.JPG|[[/Olympus Zuiko 24 mm f/2,8/]] (avant 1978) {{50}}
IMG.svg|[[/Olympus Zuiko 28 mm f/2/]] (avant 1978) {{25}}
Olympus G. Zuiko 3,5 28mm.jpg|[[/Olympus Zuiko 28 mm f/3,5/]] (avant 1978) {{25}}
Olympus OM 2,8 35 Shift.jpg|Olympus Zuiko shift 35 mm f/2,8 à décentrement
IMG.svg|[[/Olympus Zuiko 35 mm f/2/]] (avant 1978) {{25}}
Olympus G. Zuiko 2,8 35mm.jpg|[[/Olympus Zuiko 35 mm f/2,8/]] (avant 1978) {{25}}
IMG.svg|[[/Olympus Zuiko 35 mm f/3,5 Macro/]] (26 septembre 2005) {{75}}
Olympus Zuiko MC-Macro 1-3,5 f=38mm lens (4243438710).jpg|[[/Olympus Zuiko MB 38 mm f/3,5 Macro/]]
Olympus OM Zuiko Zoom 3570 mm f 4,0.jpg|[[/Olympus Zuiko Auto-zoom 35-70 mm f/4/]] (1982)
OM Auto Zoom 3,6 f=35-70mm-19840912-RM-123616.jpg|[[/Olympus Zuiko MC Auto-zoom 35-70 mm f/3,6/]]
Olympus OM Zuiko Zoom 35-105 f 3,5-4,5.jpg|[[/Olympus Zuiko Auto-zoom 35-105 mm f/3,5/4.5 close focus/]]
IMG.svg|[[/Olympus Zuiko 50 mm f/1,2/]] (1982) {{50}}
Olympus Zuiko 50mm f 1.4.JPG|[[/Olympus Zuiko 50 mm f/1,4/]] (avant 1978) {{25}}
Olympus OM F.Zuiko 50 mm f 1,8.jpg|[[/Olympus Zuiko 50 mm f/1,8/]]
Zuiko macro50F2.jpg|[[/Olympus Zuiko Auto-Macro 50 mm f/2/]]
Olympus OM 3,5 50mm Makroobjektiv.jpg|[[/Olympus Zuiko Auto-macro 50 mm f/3,5]]
IMG.svg|[[/Olympus Zuiko 55 mm f/1,2/]]
IMG.svg|[[/Olympus Zuiko 85 mm f/2,0/]]
IMG.svg|[[/Olympus Zuiko Zoom 65-200 mm f/4]]
Olympus OM Zoom 4.0 75-150 mm.jpg|[[/Olympus Zuiko 75-150 mm f/4]]
Zuiko macro 80mm.jpg|Olympus Zuiko macro 80 mm f/4
Olympus Zuiko 100mm f 2.8.JPG|[[/Olympus Zuiko 100 mm f/2,8/]] (avant 1978) {{25}}
IMG.svg|[[/Olympus Zuiko 100 mm f/2/]]
IMG.svg|[[/Olympus S Zuiko Zoom 100-200 mm f/5]]
IMG.svg|[[/Olympus Zuiko 135 mm f/2,8/]]
IMG.svg|[[/Olympus Zuiko 135 mm f/3,5/]]
Olympus OM Zuiko Macro 135 mm f 4,5.jpg|[[/Olympus Zuiko macro 135 mm f/4,5/]]
IMG.svg|[[/Olympus Zuiko 180 mm f/2,8/]]
IMG.svg|[[/Olympus Zuiko 200 mm f/5/]]
Olympus Zuiko 200mm f 4.JPG|[[/Olympus Zuiko 200 mm f/4/]] (avant 1978) {{25}}
Olympus F.Zuiko 4.5 300 mm 06.jpg|[[Olympus F Zuiko 300 mm f/4,5]]
Olympus F.Zuiko 4.5 300 mm 07.jpg|[[Olympus F Zuiko 300 mm f/4,5]]
IMG.svg|[[/Olympus Zuiko 600 mm f/5,6/]]
IMG.svg|[[/Olympus Zuiko Reflex 500 mm f/8/]]
IMG.svg|[[/Olympus Zuiko 1000 mm f/11/]]
</gallery>
== Objectifs autofocus ==
=== Séries Zuiko anciennes ===
<gallery>
IMG.svg|Olympus Zuiko AF 24 mm f/2,8
IMG.svg|Olympus Zuiko AF 28 mm f/2,8
IMG.svg|Olympus Zuiko AF 50 mm f/2,8 Macro
IMG.svg|Olympus Zuiko AF 50 mm f/1,8
IMG.svg|Olympus Lens AF 28-85 mm f/3.5/4.5
IMG.svg|[[/Olympus Lens AF 35-70 mm f/3.5/4.5/]] (1982)
IMG.svg|Olympus Lens AF 35-105 mm f/3.5/4.5
IMG.svg|Olympus Lens AF 70-210 mm f/3.5/4.5 (1982)
</gallery>
=== Série Zuiko Digital ===
<gallery>
</gallery>
=== Série Four-Thirds ===
<gallery>
Olympus four thirds camera.JPG
Olympus four thirds lenses.JPG
IMG.svg|[[/Olympus Zuiko Digital ED 7-14 mm f/4/]] (2005) {{75}}
IMG.svg|[[/Olympus Zuiko Digital ED 9-18 mm f/4-5,6/]] (2008) {{75}}
Olympus lens EZ1122.jpg|[[/Olympus Zuiko Digital 11-22 mm f/2,8-3,5|Olympus Zuiko Digital 11-22 mm f/2,8-3,5]] {{75}}
Olympus Zuiko Digital ED 12-60mm F2.8-4.0 SWD lens with Olympus Lens Hood LH-75B.jpg|[[/Olympus Zuiko Digital ED 12-60 mm f/2,8-4 SWD/]] (octobre 2007) {{100}}
Olympus Zuiko Digital 14-42mm 3.5-5.6 ED (3795070609).jpg|[[/Olympus Zuiko Digital ED 14-42 mm f/3,5-5,6/]] (septembre 2006) {{100}}
ZD 14 54 I DSC 5350.jpg|[[/Olympus Zuiko Digital 14-54 mm f/2,8-3,5/]]
Olympus Zuiko Digital 14-45mm 3.5-5.6 (2179004620).jpg|[[/Olympus Zuiko Digital 14-45 mm f/3,5-5,6/]]
Zuiko 14-35mm.jpg|[[/Olympus Zuiko Digital 14-35 f/2/]]
Olympus Zuiko Digital 17.5-45mm 3.5-5.6 (2178211561).jpg|[[/Olympus Zuiko Digital 17,5-45 mm f/3,5-5,6/]]
Olympus Zuiko Digital 25mm lens - front.jpg|[[/Olympus Zuiko Digital 25 mm f/2,8/ (Pancake)]] (mars 2008) {{100}}
Olympus Zuiko Digital 35mm Macro 3.5 (2178211317).jpg|[[/Olympus Zuiko Digital 35 mm f/3,5 Macro/]]
Objektiv Olympus ZUIKO DIGITAL 50mm Macro stehend.jpg|[[/Olympus Zuiko Digital ED 50 mm f/2 Macro/]] (juin 2008) {{100}}
Olympus Zuiko Digital 40-150mm f3.5-4.5 lens - front.jpg|[[/Olympus Zuiko Digital ED 40-150 mm f/3,5-4,5/]] (2006) {{100}}
Olympus 50–200 2.8–3.5.jpg|50-200 mm f/2,8-3,5 ED
Olympus E-500 + EC-20 + Zuiko 50-200mm.jpg|50-200 mm f/2,8-3,5 ED
Zuiko Digital ED 50-200mm F2.8-3.5 SWD.jpg|50-200 mm f/2,8-3,5 ED
Olympus E-330 + Zuiko 50-200mm.jpg|50-200 mm f/2,8-3,5 ED
Objektiv Olympus ZUIKO DIGITAL 70-300mm by 300mm.jpg|[[/Olympus Zuiko Digital ED 70-300 mm f/4,0-5,6/]] (2007) {{75}}
IMG.svg|[[/Olympus Zuiko Digital ED 90-250 mm f/2,8/]] (2007) {{25}}
</gallery>
=== Série Micro Four-Thirds ===
<gallery>
Objektiv Olympus M.ZUIKO DIGITAL 7-14mm stehend.jpg|Olympus M.Zuiko Digital 7-14 mm
Objektiv Olympus M.ZUIKO DIGITAL 7-14mm.jpg
MelvL P3180491 (5536855633).jpg|[[/Olympus M Zuiko Digital ED 9-18 mm f/4-5,6|Olympus M Zuiko Digital ED 9-18 mm f/4-5,6]] (avril 2010) {{50}}
Olympus 9mm F8 bodycap lens on Air A01.jpg|Olympus 9 mm f/8
Olympus 9mm F8 bodycap lens on ep5.jpg
Olympus 9mm F8 bodycap lens on GM5.jpg
Olympus 9mm F8 Fisheye bodycap lens on E-P5.jpg
IMG.svg|[[/Olympus M.Zuiko Digital ED 12 mm f/2|Olympus M.Zuiko Digital ED 12 mm f/2]] (30 juin 2011) {{75}}
Olympus M.Zuiko Digital 14-42mm.png|Olympus M.Zuiko Digital 14-42 mm f/3,5-5,6 L ED
Olympus M.Zuiko Digital 14-42mm F3.5-5.6 cutted.jpg
Olympus M.Zuiko digital 14-42mm f3.5-5.6 II R.jpg|[[/Olympus M.Zuiko Digital 14-42 mm f/3,5-5,6 II R|Olympus M.Zuiko Digital 14-42 mm f/3,5-5,6 II R]]
M.Zuiko 12-50mm 02.jpg|[[/Olympus M Zuiko Digital ED 12-50 mm f/3,5-6,3 EZ|Olympus M Zuiko Digital ED 12-50 mm f/3,5-6,3 EZ]]
IMG.svg|[[/Olympus M.Zuiko Digital ED 14-150 mm f/4-5,6 II|Olympus M.Zuiko Digital ED 14-150 mm f/4-5,6 II]] (5 février 2015) {{75}}
Olympus Body Cap lens 15mm F8 n01.jpg|[[/Olympus Body Cap lens 15 mm f/8|Olympus Body Cap lens 15 mm f/8]] (septembre 2012) {{100}}
2016 0212 Olympus mft 25mm1.8.jpg|[[/Olympus M-Zuiko Digital 25 mm f/1,8/]]
M.Zuiko 12-50mm 01.jpg|Olympus M.Zuiko Digital 12-50 mm
Olympus M.Zuiko Digital 40-150mm.png|Olympus M.Zuiko Digital 40-150 mm
Olympus E-M5 15.jpg|12-50 mm
Olympus M.Zuiko Digital 40-150mm F2.8.jpg|[[/Olympus M.Zuiko Digital ED 40-150 mm f/2,8 Pro|Olympus M.Zuiko Digital ED 40-150 mm f/2,8 Pro]] (15 septembre 2014) {{100}}
Olympus M Zuiko Digital ED 45mm F1.8.jpg|Olympus M Zuiko Digital ED 45 mm f/1,8
Olympus lens M.Zuiko 75 mm f1.8.jpg|[[/Olympus M.Zuiko Digital ED 75 mm f/1,8|Olympus M.Zuiko Digital ED 75 mm f/1,8]] (8 février 2012) {{100}}
Olympus M.Zuiko Digital 300mm F4.0.jpg|Olympus M.Zuiko Digital 300 mm f/4,0
</gallery>
=== Objectifs spéciaux ===
<gallery>
Image:24mmPCleft.jpg|PC 24 mm
</gallery>
== Compléments optiques ==
<gallery>
File:Olympus TCON-14B.JPG|[[/Olympus TCON-14B|Olympus TCON-14B]] Complément optique télé destiné aux appareils E-10 et E-20
File:Olympus E-20 with TCON-14B.JPG|Olympus E-20 avec TCON-14B
Fichier:OlympusTeleconv2x.png|Téléconvertisseur EC-20 2x
File:Olympus EC-20.jpg|Téléconvertisseur EC-20 2x
Image:IMG.svg|[[/Olympus EC14|Olympus EC14]] multiplicateur de focale 1,4x (2010)
File:Olympus TCON-300S ohne Gegenlichtblende.JPG|Olympus TCON-300S
File:Olympus E-20P mit TCON-300S.JPG|E-20P avec TCON-300S
</gallery>
== Flashes ==
<gallery>
Olympus XA1 (2404583547).jpg|[[/Olympus A9M|Olympus A9M]]
Olympus XA4 Macro (2388651901).jpg|[[/Olympus A11|Olympus A11 monté sur Olympus XA4 Macro]]
Olympus XA3 (2405412636).jpg|[[/Olympus A16|Olympus A16 monté sur Olympus XA1]]
Blixt jm2.jpg|flash T32 pour OM-2
Blixt jm3.jpg|flash T32 pour OM-2
Blixt jm4.jpg|flash T32 pour OM-2
Blixt jm5.jpg|flash T32 pour OM-2
2006-07-07 00-35-52b.jpg
Olympus FL-40.jpg|FL-40
Olympus FL-40 1.jpg|FL-40
Olympus FL-40 8.jpg
Olympus FL-40 7.jpg
Olympus FL-40 6.jpg
Olympus FL-40 5.jpg
Olympus FL-40 4.jpg
Olympus Blitzgerät Auto Quick 310 38.jpg|flash Auto Quick 310 pour OM-2
</gallery>
== Bagues-allonges ==
Elles sont utilisées pour la [[proxiphotographie]] et pour la [[macrophotographie]].
* '''Olympus EX-25''' : longueur 25 mm, monture 4/3 Olympus, 150 €
<gallery>
File:Olympus OM Zwischenringe 25 + 14mm.jpg|Bagues de 14 et 25 mm pour Olympus OM
</gallery>
== Accessoires divers ==
<gallery>
MelvL P1260102 (5388033078).jpg|Viseur électronique VF-2
MelvL P1260105 (5387430951).jpg|Viseur électronique VF-2
MelvL P3180492 (5536856995).jpg|Pare-soleil LH-55B
Adattatore per esposizioni manuali - Museo scienza tecnologia Milano 13097.jpg|Adaptateur pour l'exposition manuelle pour [[/Olympus OM10/]]
Olympus OM Winder 2.jpg|OM winder 2
Olympus OM MD1 Motor.jpg|OM motor drive
Olympus focusing screen 1-1 (5344261324).jpg|verre de visée pour Olympus OM-1
Olympus Winkelsucher OM.jpg|Viseur d'angle pour Olympus OM
Olympus-OM-Macro-Flash-Shoe-Ring.JPG|support pour flash macro
Olympus slide copier hg.jpg|duplicateur de diapositives
Slide copier - Olympus bellows unit, modified to take a Pentax body.jpg
Slide copier - Olympus bellows unit, modified to take a Pentax body - (1).jpg
Olympus Aufbewahrungsmappe für SmartMedia Speicherkarten 06.jpg
Olympus Aufbewahrungsmappe für SmartMedia Speicherkarten 08.jpg
Olympus Kabelfernauslöser RM-UC1.jpg
Olympus Li-ion Akkuladegerät BCM-2 21.jpg
Carcasa y cámara de fotos subacuática.jpg|Caisson étanche PT-029 pour Olympus Stylus 600 (2001)
</gallery>
== Sacs et fourre-tout ==
La marque vend des étuis, sacs et fourre-tout adaptés à ses produits.
{{Ph Fabricants}}
gd91fjdwe7zkyjur0me4k14kk7c7crh
763719
763718
2026-04-15T11:11:27Z
Banffy
34456
/* Série OM-System */
763719
wikitext
text/x-wiki
{{Ph s Fabricants}}
{{EnTravaux}}
== À classer ==
<gallery>
Olympus Superzoom 110 BW 1.JPG|Superzoom 110 BW
Quick Flash AFL.jpg|Quick flash
Olympus SZIII stereo microscope.jpg
Olympus Stylus.jpg|Stylus
Olympus mju ii.jpg|[[/Olympus Mju II/]]
Olympus C-960 Zoom.jpg|C 960
Olympus Superzoom 120TC.jpg|Olympus
</gallery>
== Appareils 18 x 24 ==
<gallery>
Olympus Pen img 0048.jpg|[[/Olympus Pen|Olympus Pen EE]]
Olympus Pen img 1197.jpg|Olympus Pen
Olympus pen camera.JPG|Olympus Pen
Olympus Pen 6867.jpg
Olympus Pen.jpg
Olympus Pen 4397.jpg|[[/Olympus Pen révision 3|Olympus Pen révision 3]] (1959)
Pen s 130503 019 (8705222446).jpg|[[/Olympus Pen S/]] (vers 1960)
Image:IMG.svg|Olympus Pen EM
Olympus Pen EE (type 1).jpg|[[/Olympus Pen EE|Olympus Pen EE (type 1)]] (vers 1968) {{25}}
Olympus Pen EE-2 241-2599.jpg|[[/Olympus Pen EE-2|Olympus Pen EE-2]]
Olympus pen ee3.jpg|[[/Olympus Pen EE-3|Olympus Pen EE-3]]
Olympus Pen EE3.jpg|Olympus Pen EE-3
Olympus pen ees.jpg|[[/Olympus Pen EE S|Olympus Pen EE S]]
Olympus PEN-EE S (meio quadro).jpg|Olympus Pen EE S
Olympus Pen EES2.jpg|[[/Olympus Pen EES-2|Olympus Pen EES-2]]
Olympus Pen EED.jpg|Olympus Pen EED
Olympus pen eed.jpg|Olympus Pen EED
0607 Olympus EED with lens cap (9122191695).jpg|Olympus Pen EED
0606 Olympus EED no lens cap (9124412452).jpg|Olympus Pen EED
MelvL P4290007 (5669556412).jpg|Olympus Pen EED
MelvL P4290003 (5669548202).jpg|Olympus Pen EED
MelvL P4290009 (5669557980).jpg|Olympus Pen EED
MelvL P4290006 (5669403335).jpg|Olympus Pen EED
MelvL P4290004 (5668984407).jpg|Olympus Pen EED
MelvL P4290008 (5668985957).jpg|Olympus Pen EED
MelvL P1040065 (5703691290).jpg|Olympus Pen EED
MelvL (5703980674).jpg|Olympus Pen EED
MelvL P1040116 (5709471065).jpg|Olympus Pen EED
MelvL P1040153 (5730856146).jpg|Olympus Pen EED
MelvL P1040155 (5726118704).jpg|Olympus Pen EED
Olympus Pen EES-2 (6717094541).jpg|Olympus Pen EES-2
Pen D3.jpg|[[/Olympus Pen D3|Olympus Pen D3]] (1965-1969)
Olympus PenF.jpg|[[/Olympus Pen F|Olympus Pen F]] (vers 1963)
Olympus-Pen-FT-with-38mm1 8.jpg|[[/Olympus Pen FT|Olympus Pen FT]] (vers 1968) {{75}}
</gallery>
== Appareils 24 x 36 reflex ==
<gallery>
Olympus FTL front.jpg|[[/Olympus FTL/]] (1971-1972) {{25}}
Olympus OM-1 (13573573703).jpg|[[/Olympus OM-1/]] (1973-1974) {{25}}
OM1NB 1.jpg|[[/Olympus OM-1N/]]
Olympus OM1MD.jpg|[[/Olympus OM-1 MD/]] (avant 1979) {{50}}
OM1-n MD (4072626146).jpg|[[/Olympus OM1-n MD/]] (1979-1983)
Olympus OM-2 with Zuiko 50mm f1.8.jpg|[[/Olympus OM-2/]] (1976) {{75}}
Olympus OM-2N img 0732.jpg|[[/Olympus OM-2N/]] {{25}}
Olympus OM-2 SP.jpg|[[/Olympus OM-2 SP/]] {{25}}
Olympus OM10 35-70mm.jpg|[[/Olympus OM10/]] (1978) {{100}}
Olympusom3.jpg|[[/Olympus OM-3/]] {{25}}
Olympus OM3 ti.jpg|OM-3 Ti
OM-3Ti Black.jpg
Olympus OM3 ti OM4 ti.jpg
Olympus OM20 - Tokino 70-210.jpg|[[/Olympus OM20|Olympus OM20 = Olympus OMG (A)]] (1982)
Olympus OM-30 (bottom).jpg|[[/Olympus OM30/]] (1982)
OlympusOM4 1.JPG|[[/Olympus OM-4/]] {{50}}
Olympus OM4 ti 01.jpg|OM-4 Ti
Olympus OM-4 Ti.JPG|OM-4 Ti
Olympus OM-4Ti worn black body with Zuiko 1.8-50mm lens and neckstrap.jpg
Vintage Olympus OM-PC (aka OM-40) 35mm SLR Film Camera, Made In Japan, Circa 1985 (13517132323).jpg|[[/Olympus OM-40|Olympus OM-40 = OM-PC]] (vers 1985)
</gallery>
== Appareils 24 x 36 compacts ==
<gallery>
Olympus LT1 (3007523325).jpg|[[/Olympus LT1/]]
Olympus Superzoom 3000.jpg|Olympus Superzoom 3000
Olympus XA camera and film.jpg|[[/Olympus XA/]] {{25}}
My Olympus XA1 (4379061989).jpg|[[/Olympus XA1/]]
My Olympus XA2 (4989175842).jpg|[[/Olympus XA2/]]
Olympus Ecru.jpg|Olympus Ecru
Olympus Ecru (4766955124).jpg|[[/Olympus Ecru/]] série limitée du Mju
Olympus Ecru cap.jpg
Olympus Ecru back.jpg
Olympus Ecru front.jpg
Olympus Ecru 01.jpg
Olympus Wide.jpg|Olympus wide
Olympus Trip 35.jpeg|[[/Olympus Trip 35/]] (vers 1968) {{50}}
Olympus-35 ECR.jpg|Olympus-35 ECR
Olympus-35 SP.jpg|[[/Olympus 35 SP/]] (1968) {{25}}
Olympus35DC3.jpg|35 DC
Olympus35DC2.jpg|35 DC
Olympus35DC1.jpg|35 DC
My Olympus 35DC (4797809987).jpg|35 DC
Olympus 35 RC img 1850.jpg|[[/Olympus 35 RC/]] (avant 1977) {{50}}
Olympus 35RD.jpg|35 RD
Olympus Stylus Epic 1118.jpg|[[/Olympus mju II|Olympus mju II = Olympus Stylus Epic]] {{25}}
My Olympus XA-3 (4024574761).jpg|[[/Olympus XA3/]]
Olympus XA4 Macro (2388651901).jpg|[[/Olympus XA4 Macro/]]
Olympus mju i.jpg|mju 1
Mju (3645746098).jpg
2009-11-26-Olympus-700BF-1.jpg|700 BF
2009-11-26-Olympus-700BF-2.jpg|700 BF
2009-11-26-Olympus-700BF-3.jpg|700 BF
Olympus-stylus hg.jpg[Stylus zoom 115
Olympus Superzoom 120 1a.jpg|Superzoom 120
Olympus Superzoom 120TC.jpg|Olympus Superzoom 120TC
My Olympus AF-1 Infinity (4876749434).jpg|[[/Olympus AF-1 Infinity/]]
Olympus Infinity Jr. (4815671398).jpg|[[/Olympus Infinity Jr./]]
Olympus AZ-200 Superzoom.jpg|Olympus AZ-200 Superzoom
Olympus Trip MD3.jpg|Olympus Trip MD3
Olympus LT-105Z (6733278979).jpg|Olympus LT-105Z
</gallery>
== Appareils 24x36 bridge ==
<gallery>
My Olympus IS-1 (4662576887).jpg|[[/Olympus IS-1|Olympus IS-1]]
Olympus IS10 (3) (5789273975).jpg|[[/Olympus IS-10|Olympus IS-10]] {{25}}
Olympus ED 35-180 (6175609523).jpg|[[/Olympus IS-3000/]] (1993)
Olympus-IS-100-07.jpg|[[/Olympus IS-100|Olympus IS-100]] (1994) {{25}}
Olympus IS100S (5) (5789275039).jpg|[[/Olympus IS-100S|Olympus IS-100S]] {{25}}
Olympus Alvesgaspar.jpg|[[/Olympus IS-1000|Olympus IS-1000]] {{25}}
</gallery>
== Appareils pour le format AGFA Rapid ==
<gallery>
Image:IMG.svg|[[/Olympus Pen RAPID EES|Olympus Pen RAPID EES]]
Image:IMG.svg|[[/Olympus Pen RAPID EED|Olympus Pen RAPID EED]]
</gallery>
== Appareils pour le format 126 ==
<gallery>
Olympus Quickmatic 600 (2759484117).jpg|[[/Olympus Quickmatic 600|Olympus Quickmatic 600]]
</gallery>
== Appareils pour le format APS ==
<gallery>
Olympus i zoom 2000 (3854940049).jpg|[[/Olympus i zoom 2000/]] (2000)
</gallery>
== Appareils numériques non reflex ==
=== année 1996 ===
<gallery>
Image:IMG.svg|[[/Olympus D-200L|Olympus D-200L]] {{50}} (5 septembre 1996)
Image:IMG.svg|[[/Olympus D-300L|Olympus D-300L]] {{50}} (5 septembre 1996)
</gallery>
=== année 1997 ===
<gallery>
Image:Olympus C-820L.jpg|[[/Olympus Camedia C-820L|Olympus Camedia C-820L]] {{50}} (septembre 1997)
File:2009-11-26-Olympus-C-820L-1.jpg|C-820L
File:2009-11-26-Olympus-C-820L-2.jpg|C-820L
File:2009-11-26-Olympus-C-820L-3.jpg|C-820L
File:2009-11-26-Olympus-C-820L-5.jpg|C-820L
File:2009-11-26-Olympus-C-820L-6.jpg|C-820L
File:2009-11-26-Olympus-C-820L-4.jpg|C-820L
File:Camedia-C-820L-05.jpg|C-820L
File:Camedia-C-820L-02.jpg|C-820L
</gallery>
=== année 1998 ===
<gallery>
Image:IMG.svg|[[/Olympus D-340L|Olympus D-340L]] {{50}} (28 septembre 1998)
File:Olympus C-900 ZOOM.jpg|[[/Olympus D-400|Olympus D-400 = Stylus Digital 400 = Olympus C900Z)]] {{75}} (2 novembre 1998)
</gallery>
=== année 1999 ===
<gallery>
Image:IMG.svg|[[/Olympus D-340R|Olympus D-340R]] {{50}} (2 janvier 1999)
File:Olympus Camedia C-2000 Z.jpg|[[/Olympus C-2000 Zoom|Olympus C-2000 Zoom]] {{50}} (16 février 1999)
Image:IMG.svg|[[/Olympus C-21|Olympus C-21]] {{50}} (28 juin 1999)
File:Olympus Camedia C-21T.commu CP+ 2011.jpg|Olympus Camedia C-21T.commu
Image:IMG.svg|[[/Olympus D-450 Zoom|Olympus D-450 Zoom = Olympus C920Z]] {{50}} (31 juillet 1999)
Image:IMG.svg|[[/Olympus C-2020 Zoom|Olympus C-2020 Zoom]] (19 octobre 1999)
</gallery>
=== année 2000 ===
<gallery>
Olympos-Camedia-C3000.jpg|C3000
Image:IMG.svg|[[/Olympus C-3030 Zoom|Olympus C-3030 Zoom]] (27 janvier 2000)
Image:IMG.svg|[[/Olympus D-360L|Olympus D-360L]] (2 février 2000)
Image:IMG.svg|[[/Olympus C-460 Zoom|Olympus C-460 Zoom]] (8 février 2000)
Image:IMG.svg|[[/Olympus C-3000 Zoom|Olympus C-3000 Zoom]] (24 avril 2000)
Image:Olympus UZ-2100 03.jpg|[[/Olympus C-2100 Ultra Zoom|Olympus C-2100 Ultra Zoom]] (15 juin 2000)
Image:Olympus UZ-2100 01.jpg
Image:Olympus UZ-2100 02.jpg
Image:IMG.svg|[[/Olympus D-490 Zoom|Olympus D-490 Zoom]] (1er août 2000)
File:Olympus E100RS.jpg|[[/Olympus E-100 RS|Olympus E-100 RS]] (22 août 2000)
File:Olympus Camera E-100RS.jpg|E-100 RS
Image:IMG.svg|[[/Olympus C-3040 Zoom|Olympus 3-2040 Zoom]] (21 novembre 2000)
Image:IMG.svg|[[/Olympus C-2040 Zoom|Olympus C-2040 Zoom]] (21 novembre 2000)
</gallery>
=== année 2001 ===
<gallery>
Olympus Camedia C-1.jpg|[[/Olympus C-1|Olympus C-1]] (6 mars 2001)
Olympus C-700 Ultra Zoom.jpg|[[/Olympus C-700 Ultra Zoom|Olympus C-700 Ultra Zoom]] (19 mars 2001)
IMG.svg|[[/Olympus D-150 Zoom|Olympus D-150 Zoom]] (8 mai 2001)
IMG.svg|[[/Olympus D-510 Zoom|Olympus D-510 Zoom]] (8 mai 2001)
IMG.svg|[[/Olympus D-370|Olympus D-370]] (5 juin 2001)
IMG.svg|[[/Olympus C-4040 Zoom|Olympus C-4040 Zoom]] (20 juin 2001)
IMG.svg|[[/Olympus D-40 Zoom|Olympus D-40 Zoom]] (2 septembre 2001)
Olympus Camedia C-2.jpg|[[/Olympus C-2|Olympus C-2]] (13 septembre 2001)
Olympus Camedia C-3020.jpg|[[/Olympus C-3020 Zoom|Olympus C-3020 Zoom]] (15 octobre 2001)
</gallery>
=== année 2002 ===
<gallery>
File:My Olympus D-520Z (4794377895).jpg|[[/Olympus D-520 Zoom|Olympus D-520 Zoom]] (13 mars 2002)
File:Olympus D-380.jpg|[[/Olympus D-380|Olympus D-380 = Olympus C-120]] (13 mars 2002)
Olympus C-2020Z.jpg|Olympus Camedia C-2020Z
OlympusC220ZoomCamera.jpg|C220Z
File:Olympus Camedia C-720.jpg|[[/Olympus C-720 Ultra Zoom|Olympus C-720 Ultra Zoom]] (8 mai 2002)
Image:IMG.svg|[[/Olympus C-300 Zoom|Olympus C-300 Zoom]] (8 mai 2002)
Image:IMG.svg|[[/Olympus C-4000 Zoom|Olympus C-4000 Zoom]] (25 juillet 2002)
File:Olympus C-5050Z, -Apr. 2007 a.jpg|[[/Olympus C-5050 Zoom|Olympus C-5050 Zoom]] (19 août 2002)
File:Olympus C-5050Z, -6 Aug. 2006 a.jpg|C-5050
File:Olympus C-5050Z, -19 Nov. 2005 a.jpg|C-5050
Image:Olympus C-730UZ Front Left.jpg|[[/Olympus C-730 UZ|Olympus C-730 UZ]] (12 septembre 2002)
Image:IMG.svg|[[/Olympus C-50 Zoom|Olympus C-50 Zoom]] (24 septembre 2002)
</gallery>
=== année 2003 ===
<gallery>
Image:IMG.svg|[[/Olympus Stylus 400|Olympus Stylus 400 = Olympus µ 400 Digital]] (9 janvier 2003)
Image:IMG.svg|[[/Olympus Stylus 300|Olympus Stylus 300 = Olympus µ 300 Digital]] (9 janvier 2003)
Fichier:Olympus Camedia C-740 Ultra Zoom 10.JPG|[[/Olympus C-740 Ultra Zoom|Olympus C-740 Ultra Zoom]] {{75}} (2 mars 2003)
File:Olympus C-150.JPG|[[/Olympus Camedia C-150|Olympus Camedia C-150 = Olympus D-390]] (2 mars 2003)
Image:Olympus Camedia C-350 Zoom -3.JPG|[[/Olympus D-560 Zoom|Olympus D-560 Zoom = Camedia C-350 zoom]] (2 mars 2003)
Image:Olympus Camedia C-350 Zoom -2.JPG
Image:Olympus Camedia C-350 Zoom -1.JPG
Image:Olympus Camedia C-350 Zoom.JPG
Image:Olympus-C350Z.jpg
Image:Olympus C-750.jpg|[[/Olympus C-750 Ultra Zoom|Olympus C-750 Ultra Zoom]] (2 mars 2003)
Image:Olympus C-750 back.jpg
Image:Olympus C-750 front right-1.jpg
Image:Olympus C-750 front right.jpg
Image:Olympus C-750 front left.jpg
Image:Digital Camera.jpg|[[/Olympus C-5000 Zoom|Olympus C-5000 Zoom]] (29 août 2003)
Olympus Camedia C-5000Z 3750.jpg
Olympus Camedia C-5000Z 3751.jpg
Olympus Camedia C-5000Z 3752.jpg
Olympus Camedia C-5000Z 3753.jpg
Olympus Camedia C-5000Z 3754.jpg
Olympus Camedia C-5000Z 3755.jpg
Olympus Camedia C-5000Z 3756.jpg
Image:IMG.svg|[[/Olympus C-5060 Zoom|Olympus C-5060 Zoom]] (29 septembre 2003)
</gallery>
=== année 2004 ===
<gallery>
IMG.svg|[[/Olympus D-540 Zoom|Olympus D-540 Zoom]] (14 février 2004)
IMG.svg|[[/Olympus D-580 Zoom|Olympus D-580 Zoom]] (14 février 2004)
Stylus410specs.jpg|[[/Olympus Stylus 410|Olympus Stylus 410]] (14 février 2004)
OLYMPUS C-8080WZ 01.jpg|[[/Olympus C-8080 WideZoom|Olympus C-8080 WideZoom]] (14 février 2004)
Olympus CAMEDIA C-8080.JPG|C-8080
C-8080WZ rear.JPG|C-8080
C-8080WZ tele.JPG|C-8080
Olympus C-760 UltraZoom (2178205925).jpg|[[/Olympus Camedia C-760 UZ/]]
Olympus C-765UZ, -13 juni 2006 a.jpg|[[/Olympus C-765 Ultra Zoom|Olympus C-765 Ultra Zoom]] (14 février 2004)
Olympus C-766 UZ back.jpg
Olympus C-765 UZ front.jpg
IMG.svg|[[/Olympus C-770 Ultra Zoom|Olympus C-770 Ultra Zoom]] (14 février 2004)
Olympus D-395.JPG|[[/Olympus D-395|Olympus D-395]] (18 mars 2004)
Olympus C-60 Zoom.JPG|[[/Olympus C-60 Zoom|Olympus C-60 Zoom]] (18 mars 2004)
Olympus µ-mini.jpeg|[[/Olympus Stylus Verve|Olympus Stylus Verve = Olympus Mju-mini = Olympus mju-ii]] (3 septembre 2004)
IMG.svg|[[/Olympus C-7000 Zoom|Olympus C-7000 Zoom]] (16 septembre 2004)
IMG.svg|[[/Olympus D-535 Zoom|Olympus D-535 Zoom]] (16 septembre 2004)
IMG.svg|[[/Olympus Stylus 500|Olympus Stylus 500]] (29 novembre 2004)
</gallery>
=== année 2005 ===
<gallery>
IMG.svg|[[/Olympus D-425/]] (5 janvier 2005)
IMG.svg|[[/Olympus C-7070 Wide Zoom/]] (5 janvier 2005)
IMG.svg|[[/Olympus C-5500 Sport Zoom/]] (5 janvier 2005)
IMG.svg|[[/Olympus Stylus Verve S/]] (17 février 2005)
IMG.svg|[[/Olympus D-545 Zoom/]] (17 février 2005)
Olympus C-500Z 3.JPG|[[/Olympus D-595 Zoom|Olympus D-595 Zoom = Olympus C-500Z]] (17 février 2005)
Olympus C-500Z 2.JPG
Olympus C-500Z 1.JPG
Olympus IR-300.jpg|[[/Olympus IR-300/]] (17 février 2005)
IMG.svg|[[/Olympus D-630 Zoom/]] (17 février 2005)
IMG.svg|[[/Olympus Stylus 800/]] (12 mai 2005)
IMG.svg|[[/Olympus D-435/]] (20 mai 2005)
Olympus FE 110 (2254131662).jpg|[[/Olympus FE-110/]] (29 août 2005)
Olympus-SP-310-p1030353.jpg|[[/Olympus SP-310/]] {{00}} (29 août 2005)
Olympus FE-120 01.jpg|[[/Olympus FE-120/]] {{50}} (29 août 2005)
IMG.svg|[[/Olympus Stylus 600/]] (29 août 2005)
Oly SP-350-1.jpg|[[/Olympus SP-350/]] {{25}} (29 août 2005)
IMG.svg|[[/Olympus SP-500 UZ/]] {{25}} (29 août 2005)
Olympus FE-100 front.jpg|[[/Olympus FE-100/]] (29 août 2005)
IMG.svg|[[/Olympus SP-700/]] (4 octobre 2005)
</gallery>
=== année 2006 ===
<gallery>
File:My Olympus SP-320 (4171943306).jpg|[[/Olympus SP-320|Olympus SP-320]] (26 janvier 2006) {{50}}
Image:IMG.svg|[[/Olympus FE-115|Olympus FE-115]] (26 janvier 2006) {{25}}
File:Olympus-digitale-camera-FE-130.JPG|[[/Olympus FE-130|Olympus FE-130]] (26 janvier 2006) {{50}}
Image:IMG.svg|[[/Olympus FE-140|Olympus FE-140]] (26 janvier 2006) {{25}}
Image:IMG.svg|[[/Olympus FE-150|Olympus FE-150]] (26 janvier 2006) {{25}}
Image:Olympus µ 700.jpg|[[/Olympus Stylus 700|Olympus Stylus 700 = Mju 700 Digital]] (26 janvier 2006) {{50}}
Image:IMG.svg|[[/Olympus Stylus 720 SW|Olympus Stylus 720 SW = Olympus Mju 720 SW Digital]] (26 janvier 2006)
Image:IMG.svg|[[/Olympus Stylus 810|Olympus Stylus 810 = Olympus Mju 810 Digital]] (26 janvier 2006) {{50}}
Image:Olympus X760 01.jpg|[[/Olympus FE-170|Olympus FE-170 = Olympux X-760]] (24 août 2006)
Image:IMG.svg|[[/Olympus FE-180|Olympus FE-180]] (24 août 2006) {{25}}
Image:Olympus FE190.JPG|[[/Olympus FE-190|Olympus FE-190]] (24 août 2006) {{50}}
Image:IMG.svg|[[/Olympus FE-200|Olympus FE-200]] (24 août 2006) {{50}}
File:Olympus μ 725 SW.jpg|[[/Olympus Stylus 725 SW|Olympus Stylus 725 SW = Olympus Mju 725 SW Digital]] (24 août 2006)
Image:IMG.svg|[[/Olympus Stylus 730|Olympus Stylus 730 = Olympus Mju 730 Digital]] (24 août 2006) {{50}}
Image:IMG.svg|[[/Olympus Stylus 740|Olympus Stylus 740 = Olympus Mju 740 Digital]] (24 août 2006) {{50}}
Image:IMG.svg|[[/Olympus Stylus 750|Olympus Stylus 750 = Olympus Mju 750 Digital]] (24 août 2006) {{75}}
Image:IMG.svg|[[/Olympus Stylus 1000|Olympus Stylus 1000 = Olympus Mju 1000 Digital]] (24 août 2006) {{75}}
File:OlympusSP510UZ.jpg|[[/Olympus SP-510 UZ|Olympus SP-510 UZ]] (24 août 2006) {{50}}
</gallery>
=== année 2007 ===
<gallery>
Image:Olympus SP 550UZ.jpg|[[/Olympus SP-550 UZ|Olympus SP-550 UZ]] (25 janvier 2007) {{100}}
File:Fe-210.png|[[/Olympus FE-210|Olympus FE-210 = X-775]] (25 janvier 2007) {{75}}
Image:IMG.svg|[[/Olympus FE-230|Olympus FE-230]] (25 janvier 2007) {{75}}
Image:IMG.svg|[[/Olympus FE-240|Olympus FE-240]] (25 janvier 2007) {{75}}
Image:IMG.svg|[[/Olympus FE-250|Olympus FE-250]] (25 janvier 2007) {{75}}
Image:Olympus µ 760.jpg|[[/Olympus Stylus 760|Olympus Stylus 760 = Olympus mju 760 Digital]] (25 janvier 2007) {{75}}
Image:Stylus 770SW.jpg|[[/Olympus Stylus 770 SW|Olympus Stylus 770 SW = Olympus mju 770 SW Digital]] (25 janvier 2007) {{100}}
Image:IMG.svg|[[/Olympus Stylus 780|Olympus Stylus 780]] (5 mars 2007)
Image:IMG.svg|[[/Olympus FE-270|Olympus FE-270]] (23 août 2007) {{75}}
Image:IMG.svg|[[/Olympus FE-280|Olympus FE-280]] (23 août 2007) {{75}}
Image:IMG.svg|[[/Olympus FE-290|Olympus FE-290]] (23 août 2007) {{75}}
Image:IMG.svg|[[/Olympus FE-300|Olympus FE-300]] (23 août 2007) {{75}}
Image:IMG.svg|[[/Olympus Stylus 790 SW|Olympus Stylus 790 SW = Olympus mju 790 SW Digital]] (23 août 2007) {{75}}
Image:IMG.svg|[[/Olympus Stylus 820|Olympus Stylus 820 = Olympus mju 820 Digital]] (23 août 2007) {{75}}
File:OLYMPUS Mu 830.jpeg|[[/Olympus Stylus 830|Olympus Stylus 830 = Olympus mju 830 Digital]] (23 août 2007) {{100}}
Image:IMG.svg|[[/Olympus Stylus 1200|Olympus Stylus 1200 = Olympus mju 1200 Digital]] (23 août 2007) {{75}}
Image:IMG.svg|[[/Olympus SP-560 UZ|Olympus SP-560 UZ]] (25 janvier 2007) {{75}}
</gallery>
=== année 2008 ===
<gallery>
File:Olympus SP-570UZ, -Nov. 2008 a.jpg|[[/Olympus SP-570 UZ|Olympus SP-570 UZ]] (2008) {{50}}
Image:IMG.svg|[[/Olympus Mju 1040|Olympus Mju 1040]] (2008) {{25}}
Image:IMG.svg|[[/Olympus Mju 1050sw|Olympus Mju 1050sw]] (2008) {{25}}
Image:IMG.svg|[[/Olympus Mju 1060|Olympus Mju 1060]] (2008) {{25}}
Image:IMG.svg|[[/Olympus FE-20|Olympus FE-20]] (2008) {{25}}
Image:IMG.svg|[[/Olympus FE-360|Olympus FE-360]] (19 août 2008) {{75}}
Image:IMG.svg|[[/Olympus FE-370|Olympus FE-370]] (2008) {{25}}
</gallery>
=== année 2009 ===
<gallery>
Bfishadow Olympus E-P1.jpg|Olympus Pen E-P1
Bfishadow Olympus E-P1 bottom.jpg|Olympus Pen E-P1
Bfishadow Olympus E-P1 top.jpg|Olympus Pen E-P1
Bfishadow Olympus E-P1 back.jpg|Olympus Pen E-P1
Olympus Pen img 3486.jpg|Olympus Pen E-P1
Olympus IMG 2163.jpg|Olympus Pen E-P1
Olympus E-P1 (3634318402).jpg
Olympus E-P1 (3634895524).jpg
Olympus E-P1 (3634080929).jpg
Olympus E-P1- Sleek frame (3634087049).jpg
Olympus E-P1 (3634889300).jpg
Olympus E-P1 (3634079289).jpg
Olympus E-P1 (3633504211).jpg
Olympus E-P1 (3634318984).jpg
Olympus E-P1 (3634318918).jpg
Olympus E-P1 (3633503717).jpg
</gallery>
=== année 2010 ===
<gallery>
File:Olympus PEN E-PL1.jpg|[[/Olympus PEN E-PL1|Olympus PEN E-PL1]] (10 février 2010) {{100}}
</gallery>
=== année 2011 ===
<gallery>
File:Olympus E-PL2 with Leica Summicron 50 f2 LTM lens.jpg|[[/Olympus Pen E-PL2|Olympus Pen E-PL2]] (6 janvier 2011) {{75}}
Image:IMG.svg|[[/Olympus SP-610UZ|Olympus SP-610UZ]] (6 janvier 2011) {{75}}
File:Olympus-XZ-1.jpg|[[/Olympus XZ-1|Olympus XZ-1]] (6 janvier 2011) {{100}}
</gallery>
=== année 2012 ===
<gallery>
Image:IMG.svg|[[/Olympus Tough TG-1 iHS|Olympus Tough TG-1 iHS]] (8 mai 2012) {{75}}
</gallery>
=== année 2013 ===
<gallery>
</gallery>
=== année 2014 ===
<gallery>
</gallery>
=== année 2015 ===
<gallery>
Olympus OM-D E-M5II (18421339075).jpg|[[/Olympus OM-D E-M5 II]] (5 février 2015) {{100}}
IMG.svg|[[/Olympus Tough TG-4/]] (13 avril 2015) {{75}}
Olympus OM-D E-M10 Mark II.JPG|[[/Olympus OM-D E-M10 II/]] (25 août 2015) {{75}}
</gallery>
=== année 2016 ===
<gallery>
Olympus PEN-F.jpg|[[/Olympus Pen-F/]] (27 janvier 2016) {{00}}
</gallery>
==== à classer ====
<gallery>
2013-265-121 Tough New Toy (8700211111).jpg|Olympus Tough TG-2
OLYMPUS PEN MINI E-PM2 (8651180173).jpg|Olympus E-PM2
Olympus E-20P 01.jpg|Olympus E-20P
Olympus E-20P 02.jpg
Olympus E-20P 03.jpg
Olympus E-20P 04.jpg
Olympus E-20P 05.jpg
Olympus E-20P 06.jpg
Olympus E-20P 07.jpg
Olympus E-20P 08.jpg
Olympus E-20P 09.jpg
Olympus E-20P 10.jpg
Olympus E-20P 11.jpg
Olympus X-750.jpg|Olympus X-750
Olympus PEN F.jpg|Olympus PEN F
Olympus Pen F (digital).jpg|Olympus PEN F
Olympus Pen F-IMG 9925.jpg|Olympus PEN F
Olympus Pen F-IMG 9927.JPG|Olympus PEN F
Olympus PEN F.jpg|Olympus PEN F
Olympus PEN-F.jpg|Olympus PEN F
Olympus PEN E-PL6 black kit lens 2016-03-03.jpg|Olympus PEN E-PL6
Olympus OM D E-M1 - Johnragai Gear - 09.11.2014 (15772393942).jpg|Olympus OM-D E-M1
Olympus OM D E-M1 - Johnragai Gear - 09.11.2014 (15770833985).jpg
OM D E-M1 with 75mm f-1.8 (9869006524).jpg
OM D E-M1 with 12-60mm f-2.8-4 ED SWD (9869086256).jpg
Olympus OM-D E-M1 Mark II mock-up (rough model) 2017 CP+.jpg|Olympus OM-D E-M1 Mark II
Olympus OM-D E-M1 Mark II mock-up (design model) 2017 CP+.jpg
Olympus OM-D E-M1 Mark II mock-up (body+power battery holder model) 2017 CP+.jpg
Olympus OM-D E-M1 Mark II magnesium-alloy chassis 2017 CP+.jpg
Olympus OM-D E-M1 Mark II D81 8378-2.jpg
Olympus.OM-D.E-M1.Mark.II.back.view.jpg
Olympus C-760 UltraZoom (2178205925).jpg|[[/Olympus Camedia C-760 UZ/]]
Olympus camedia C-800L.jpg|[[/Olympus Camedia C-800L/]]
Olympus SH-1 zilver, -2015 a.jpg|Olympus SH-1
Olympus SH-1 zilver, -2015 b.jpg
Olympus SH-1 zilver, -2015 c.jpg
Olympus Pen EPL7.JPG|[[/Olympus Pen E-PL7/]]
Digitalkamera von Olympus.JPG|FE-5020
Olympus XZ-10, -februari 2013 a.jpg|Olympus XZ-10
Leong IMG 2989 (6338669929).jpg
Leong IMG 2986 (6339413622).jpg
Leong IMG 0497 (6689370435).jpg
P1000963 (6707919805).jpg
Olympus OM-D E-M10 2014 CP+.jpg|Olympus OM-D E-M10
Olympus OM-D E-M10 01.jpg
Olympus OM-D E-M10 cutaway 2014 CP+.jpg|Olympus OM-D E-M10
Olympus OM-D E-M10 cutted 1.jpg
Olympus OM-D E-M10 cutted 2.jpg
Olympus PEN E-P5 Back 1.jpg|Olympus PEN E-P5
Olympus PEN E-P5 Front 1.jpg
Olympus PEN E-P5 Front 2.jpg
EP5 with 45mm F1.8.jpg
Olympus E-P5.jpg
Olympus X-500 D-590Z C-470Z.jpg|X-500 = D-590Z = C-470Z
Olympus E-PM1 + BCL-15.jpg|Olympus E-PM1
Olympus TG-820 Camera.jpg|Olympus TG-820
Olympus TG-820 front with lens cover open.jpg|Olympus TG-820
Olympus TG-820 Top.JPG|Olympus TG-820
Olympus E-P2.jpg|Olympus E-P2
Micro Four Thirds Olympus E-P2 with Panasonic Lumix G 20mm F1.7 ASPH aspherical pancake lens.jpg
Olympus EPL5 top.jpg|E-PL5
Olympus EPL5 back 01.jpg|E-PL5
Olympus EPL5 back 02.jpg|E-PL5
Olympus EPL5 front lens.jpg|E-PL5
Olympus EPL5 front.jpg|E-PL5
Olympus epl5 vf4.jpg
Olympus epl5 vf4 45mm 01.jpg
Olympus epl5 vf4 45mm 02.jpg
Olympus epl5 vf4 45mm 03.jpg
Olympus PEN Lite and Nissin i40.jpg
Olympus VR-340.JPG|Olympus VR-340
Olympus-digitale-camera-X-720.JPG|Olympus X-720
Olympus Camedia C-310 Zoom Digital Camera.JPG|Olympus Camedia C-310 Zoom
Olympus PEN E-PL3.jpg|[[/Olympus Pen E-PL3|Olympus Pen E-PL3]]
Olypmus SP-810UZ, closed.jpg|Olympus SP-810UZ
Olympus_SP-810UZ,_no_zoom,_flash_closed.jpg|Olympus SP-810UZ
Olympus_SP-810UZ,_full_zoom,_flash_opened.jpg|Olympus SP-810UZ
Olympus E-P3 006.JPG|[[/Olympus E-P3/]]
Olympus AZ-300 Superzoom.jpg|[[/Olympus AZ-300 Superzoom/]]
Olympus SP560UZ DSCF9120.jpg|SP560 UZ
Olympus u5000.jpg|Mju 5000
Stylus Tough 8000.jpg|Stylus Tough 8000
Olympus_SP590_UZ_01_(RaBoe).jpg|SP590 UZ
Olympus_SP590_UZ_02_(RaBoe).jpg|SP590 UZ
Olympus_SP590_UZ_03_(RaBoe).jpg|SP590 UZ
Olympus_SP590_UZ_04_(RaBoe).jpg|SP590 UZ
Olympus_SP590_UZ_05_(RaBoe).jpg|SP590 UZ
Olympus_SP590_UZ_06_(RaBoe).jpg|SP590 UZ
Olympus_SP590_UZ_07_(RaBoe).jpg|SP590 UZ
Olympus SP590 UZ 2010-by-RaBoe-02.jpg
Olympus SP590 UZ 2010-by-RaBoe-01.jpg
FE-310.jpg|FE-310
Image:Olympus u850SW.jpg|mju 850SW
Olympus µ850SW, -1 mei 2010 a.jpg
Image:Olympus img 1845.jpg|C-1400
Image:Olympus_FE-340_8MP_camera_01.jpg|FE-340
Olympus-MicroFT-Model.jpg|Micro four thirds
OlyE-P2Test10112009-01.jpg|EP-2
Olympus VR-310.jpg|Olympus VR-310
</gallery>
== Appareils reflex numériques ==
=== année 1997 ===
<gallery>
Image:IMG.svg|[[/Olympus D-500L|Olympus D-500L]] {{50}} (10 septembre 1997)
Image:IMG.svg|[[/Olympus D-600L|Olympus D-600L]] {{50}} (10 septembre 1997)
Image:Olympus img 1846.jpg|C-1400
Image:Olympus img 1845.jpg|C-1400
</gallery>
=== année 1998 ===
<gallery>
Image:Olympus C-1400 01.jpg|[[/Olympus D-620L|Olympus D-620L = Olympus C1400XL]] (2 novembre 1998) {{75}}
Image:Olympus Camedia C 1400 XL 61.jpg|[[/Olympus D-620L|Olympus D-620L = Olympus C1400XL]] (2 novembre 1998) {{75}}
Image:Olympus Camedia C 1400 XL 57.jpg|[[/Olympus D-620L|Olympus D-620L = Olympus C1400XL]] (2 novembre 1998) {{75}}
</gallery>
=== année 1999 ===
<gallery>
Image:IMG.svg|[[/Olympus C-2500 L|Olympus C-2500 L]] {{50}} (18 mars 1999)
</gallery>
=== année 2000 ===
<gallery>
File:Olympus E-10.jpg|[[/Olympus E-10|Olympus E-10]] (22 août 2000)
File:Olympus E-20P.JPG|[[/Olympus E-20|Olympus E-20]]
Olympus E-20n.jpg|Olympus E-20n
</gallery>
=== année 2003 ===
<gallery>
Image:Olympus E-1 2.jpg|[[/Olympus E-1|Olympus E-1]] (24 juin 2003)
Image:E-1 hinten oben.jpg
Image:E-1 Seite hinten.jpg
Image:E-1 hinten.jpg
File:E-1 vorne.jpg
File:Olympus E-1 body.jpg
</gallery>
=== année 2004 ===
<gallery>
E-300.jpg|[[/Olympus E-300|Olympus E-300 (EVOLT E-300)]] (27 septembre 2004)
</gallery>
=== année 2005 ===
<gallery>
File:E-500 Body.jpg|[[/Olympus E-500|Olympus E-500]] {{25}} (2005)
Olympus E-500 with Minolta MD Lens (5391265164).jpg|[[/Olympus E-500/]] (26 septembre 2005)
</gallery>
=== année 2006 ===
<gallery>
File:Olympus E-330. Zuiko Digital ED.jpg|[[/Olympus E-330|Olympus E-330 = EVOLT E-330]] {{25}} (26 janvier 2006)
Image:Oly e 400 voorkant.jpg|[[/Olympus E-400|Olympus E-400]] {{75}} (14 septembre 2006)
</gallery>
=== année 2007 ===
<gallery>
Olympus E410 img 1030.jpg|[[/Olympus E-410/]] (5 mars 2007)
Olympus E510 img 1029.jpg|[[/Olympus E-510/]] (5 mars 2007)
P3069465 (3333310515).jpg|[[/Olympus E-3/]]
Olympus_E-3_Camera.jpg|[[/Olympus E3/]] {{75}} (16 octobre 2007)
</gallery>
=== année 2008 ===
<gallery>
Olympus E-420.jpg|E-420
Olympus E-420 EZ40150.jpg|E-420
Olympus E-420 Body Front.jpg|E-420
Olympus E-420 Pancake25mm Top.jpg|E-420
Olympus E-420 Body Top XL.jpg|E-420
Image:Olymous E420 img 1248.jpg|E-420
Image:Olympus E-420 (back).jpg|E-420
Image:Olympus E-420 (front).jpg|E-420
</gallery>
=== année 2009 ===
<gallery>
Olympus E-450.JPG|[[/Olympus E-450|Olympus E-450]] {{75}} (31 mars 2009)
Olympus E-30 01.jpg|Olympus E-30
Olympus E-30 02.jpg|Olympus E-30
Olympus E-30 03.jpg|Olympus E-30
Olympus E-30 04.jpg|Olympus E-30
Olympus E-30 rear01.jpg|E30
Olympus E-30 front01.jpg|E30
E-30-back.jpg|E3
Olympus E-30 with ZD 14-54mm f2.8-3.5II 01.JPG|E30
Olympus E-30-Cutmodel.jpg|E30 coupé
E-30-with-14-54.jpg|E30
Olympus E30-IMG 2445.jpg|E30
Olympus E30-IMG 2442.jpg|E30
Olympus E30-IMG 2441.jpg|E30
Olympus E-620 front.jpg|E-620
Olympus E-620.jpg|E-620
Olympus E-620 with battery grip.jpg|E-620
Olympus E-620 without lens.jpg|E620
Olympus E-620 swivel screen open.JPG|E620
Olympus E620 DSLR.jpg|E620
</gallery>
=== année 2010 ===
<gallery>
Olympus-E5.jpg|E-5
</gallery>
'''à classer'''
<gallery>
Olympus OM-D E-M1- 20131118.jpg|Olympus OM-D E-M1
OM-D EM-1.jpg|OM-D EM-1
Oly-EM1-connector.jpg
Olympus OM-D E-M1 01.jpg
Olympus OM-D E-M1 cutted 1.jpg
Olympus OM-D E-M1 cutted 2.jpg
Olympus OM-D E-M1 cutted 3.jpg
Olympus OM-D E-M1 image stabilization unit.jpg
Olympus E-M5 (front, cropped).jpg|OM-D E-M5
Oly-E-M5.jpg
OLYMPUS OM-D E-M5.jpg
Olympus OM-D E-M5.jpg
Olympus E-M5, Nokton 25mm.jpg
Olympus E-M5 01.jpg
Olympus E-M5 02.jpg
Olympus E-M5 03.jpg
Olympus E-M5 04.jpg
Olympus E-M5 05.jpg
Olympus E-M5 06.jpg
Olympus E-M5 08.jpg
Olympus E-M5 07.jpg
Olympus E-M5 09.jpg
Olympus E-M5 10.jpg
Olympus E-M5 11.jpg
Olympus E-M5 12.jpg
Olympus E-M5 13.jpg
Olympus E-M5 14.jpg
Olympus E-M5 15.jpg
Olympus E-M5 16.jpg
Olympus OM-D E-M5, Taipei, TW.jpg
Olympus E-M5 + Bigma.jpg
Micro Four Thirds Olympus OM-D E-M5 digital camera.jpg
Oly-E-M5.jpg
Olympus OM-D E-M5 Elite black kit.jpg
Olympus OM-D E-M5 Elite black.jpg
Olympus E-M5 with 45mm F1.8.jpg
Olympus OM 50mm f1.8.jpg|E-420
Olympus-e-520-front.png|[[/Olympus E-520|Olympus E-520]]
</gallery>
== Modules pour smartphone ==
<gallery>
Olympus Air A01,mounted lens and phone.jpg|Olympus Air A01
</gallery>
== Objectifs à mise au point manuelle ==
=== Série Pen ===
<gallery>
Olympus-20mm-F3 5-with-TTL-No.jpg|[[/Olympus Zuiko 20 mm f/3,5/]]
Image:PenF-Zuiko-20mm.JPG
</gallery>
=== Série FTL (M42) ===
<gallery>
M42.OffenblendOLYMPUS.jpg|Olympus M 42
Olympus FTL G.Zuiko 28 mm f 3,5.jpg|Olympus G.Zuiko 28 mm f/3,5
IMG.svg|Olympus Zuiko 35 mm f/2,8/
IMG.svg|Olympus Zuiko 50 mm f/1,4/
Olympus FTL F. Zuiko 50 mm f 1,8.jpg|Olympus Zuiko 50 mm f/1,8
IMG.svg|Olympus Zuiko 135 mm f/3,5/
IMG.svg|Olympus Zuiko 200 mm f/4,0/
</gallery>
=== Série OM-System ===
<gallery>
OMLenses.jpg
OM Zuiko f2 lenses.jpg|Zuiko f/2
</gallery>
<gallery>
IMG.svg|[[/Olympus Zuiko 8 mm f/2,8/]] (avant 1978) {{25}}
IMG.svg|[[/Olympus Zuiko 16 mm f/3,5/]] (avant 1978) {{25}}
Olympus Zuiko Auto-Macro 20mm 1-2 lens (4243439258).jpg|[[/Olympus Zuiko Auto-Macro 20 mm f/1,2/]]
ZUIKO21mmF2.jpg|[[/Olympus Zuiko 21 mm f/2/]]
Olympus Zuiko 2,0 21mm.jpg|[[/Olympus Zuiko 21 mm f/2/]]
IMG.svg|[[/Olympus Zuiko 21 mm f/3,5/]] (avant 1978) {{25}}
IMG.svg|[[/Olympus Zuiko 24 mm f/2/]] (avant 1978) {{25}}
Obiettivo fotografico ultragrandangolare, messa a fuoco elicoidale, con innesto a baionetta - Museo scienza tecnologia Milano 13087.jpg|Olympus Zuiko Auto-W 24 mm f/2,8 (1991)
Zuiko shift 24mm.jpg|Olympus Zuiko shift 24 mm f/3,5 à décentrement
Olympus Zuiko 24mm f 2.8.JPG|[[/Olympus Zuiko 24 mm f/2,8/]] (avant 1978) {{50}}
IMG.svg|[[/Olympus Zuiko 28 mm f/2/]] (avant 1978) {{25}}
Olympus G. Zuiko 3,5 28mm.jpg|[[/Olympus Zuiko 28 mm f/3,5/]] (avant 1978) {{25}}
Olympus OM 2,8 35 Shift.jpg|Olympus Zuiko shift 35 mm f/2,8 à décentrement
IMG.svg|[[/Olympus Zuiko 35 mm f/2/]] (avant 1978) {{25}}
Olympus G. Zuiko 2,8 35mm.jpg|[[/Olympus Zuiko 35 mm f/2,8/]] (avant 1978) {{25}}
IMG.svg|[[/Olympus Zuiko 35 mm f/3,5 Macro/]] (26 septembre 2005) {{75}}
Olympus Zuiko MC-Macro 1-3,5 f=38mm lens (4243438710).jpg|[[/Olympus Zuiko MB 38 mm f/3,5 Macro/]]
Olympus OM Zuiko Zoom 3570 mm f 4,0.jpg|[[/Olympus Zuiko Auto-zoom 35-70 mm f/4/]] (1982)
OM Auto Zoom 3,6 f=35-70mm-19840912-RM-123616.jpg|[[/Olympus Zuiko MC Auto-zoom 35-70 mm f/3,6/]]
Olympus OM Zuiko Zoom 35-105 f 3,5-4,5.jpg|[[/Olympus Zuiko Auto-zoom 35-105 mm f/3,5/4.5 close focus/]]
IMG.svg|[[/Olympus Zuiko 50 mm f/1,2/]] (1982) {{50}}
Olympus Zuiko 50mm f 1.4.JPG|[[/Olympus Zuiko 50 mm f/1,4/]] (avant 1978) {{25}}
Olympus OM F.Zuiko 50 mm f 1,8.jpg|[[/Olympus Zuiko 50 mm f/1,8/]]
Zuiko macro50F2.jpg|[[/Olympus Zuiko Auto-Macro 50 mm f/2/]]
Olympus OM 3,5 50mm Makroobjektiv.jpg|[[/Olympus Zuiko Auto-macro 50 mm f/3,5]]
IMG.svg|[[/Olympus Zuiko 55 mm f/1,2/]]
IMG.svg|[[/Olympus Zuiko 85 mm f/2,0/]]
IMG.svg|[[/Olympus Zuiko Zoom 65-200 mm f/4]]
Olympus OM Zoom 4.0 75-150 mm.jpg|[[/Olympus Zuiko 75-150 mm f/4]]
Zuiko macro 80mm.jpg|Olympus Zuiko macro 80 mm f/4
Olympus Zuiko 100mm f 2.8.JPG|[[/Olympus Zuiko 100 mm f/2,8/]] (avant 1978) {{25}}
IMG.svg|[[/Olympus Zuiko 100 mm f/2/]]
IMG.svg|[[/Olympus S Zuiko Zoom 100-200 mm f/5]]
IMG.svg|[[/Olympus Zuiko 135 mm f/2,8/]]
IMG.svg|[[/Olympus Zuiko 135 mm f/3,5/]]
Olympus OM Zuiko Macro 135 mm f 4,5.jpg|[[/Olympus Zuiko macro 135 mm f/4,5/]]
IMG.svg|[[/Olympus Zuiko 180 mm f/2,8/]]
IMG.svg|[[/Olympus Zuiko 200 mm f/5/]]
Olympus Zuiko 200mm f 4.JPG|[[/Olympus Zuiko 200 mm f/4/]] (avant 1978) {{25}}
IMG.svg|[[/Olympus Zuiko 250 mm f/2/]]
Olympus F.Zuiko 4.5 300 mm 06.jpg|[[Olympus F Zuiko 300 mm f/4,5]]
Olympus F.Zuiko 4.5 300 mm 07.jpg|[[Olympus F Zuiko 300 mm f/4,5]]
IMG.svg|[[/Olympus Zuiko 350 mm f/2,8/]
IMG.svg|[[/Olympus Zuiko 400 mm f/6,3/]
IMG.svg|[[/Olympus Zuiko 600 mm f/5,6/]]
IMG.svg|[[/Olympus Zuiko Reflex 500 mm f/8/]]
IMG.svg|[[/Olympus Zuiko 1000 mm f/11/]]
</gallery>
== Objectifs autofocus ==
=== Séries Zuiko anciennes ===
<gallery>
IMG.svg|Olympus Zuiko AF 24 mm f/2,8
IMG.svg|Olympus Zuiko AF 28 mm f/2,8
IMG.svg|Olympus Zuiko AF 50 mm f/2,8 Macro
IMG.svg|Olympus Zuiko AF 50 mm f/1,8
IMG.svg|Olympus Lens AF 28-85 mm f/3.5/4.5
IMG.svg|[[/Olympus Lens AF 35-70 mm f/3.5/4.5/]] (1982)
IMG.svg|Olympus Lens AF 35-105 mm f/3.5/4.5
IMG.svg|Olympus Lens AF 70-210 mm f/3.5/4.5 (1982)
</gallery>
=== Série Zuiko Digital ===
<gallery>
</gallery>
=== Série Four-Thirds ===
<gallery>
Olympus four thirds camera.JPG
Olympus four thirds lenses.JPG
IMG.svg|[[/Olympus Zuiko Digital ED 7-14 mm f/4/]] (2005) {{75}}
IMG.svg|[[/Olympus Zuiko Digital ED 9-18 mm f/4-5,6/]] (2008) {{75}}
Olympus lens EZ1122.jpg|[[/Olympus Zuiko Digital 11-22 mm f/2,8-3,5|Olympus Zuiko Digital 11-22 mm f/2,8-3,5]] {{75}}
Olympus Zuiko Digital ED 12-60mm F2.8-4.0 SWD lens with Olympus Lens Hood LH-75B.jpg|[[/Olympus Zuiko Digital ED 12-60 mm f/2,8-4 SWD/]] (octobre 2007) {{100}}
Olympus Zuiko Digital 14-42mm 3.5-5.6 ED (3795070609).jpg|[[/Olympus Zuiko Digital ED 14-42 mm f/3,5-5,6/]] (septembre 2006) {{100}}
ZD 14 54 I DSC 5350.jpg|[[/Olympus Zuiko Digital 14-54 mm f/2,8-3,5/]]
Olympus Zuiko Digital 14-45mm 3.5-5.6 (2179004620).jpg|[[/Olympus Zuiko Digital 14-45 mm f/3,5-5,6/]]
Zuiko 14-35mm.jpg|[[/Olympus Zuiko Digital 14-35 f/2/]]
Olympus Zuiko Digital 17.5-45mm 3.5-5.6 (2178211561).jpg|[[/Olympus Zuiko Digital 17,5-45 mm f/3,5-5,6/]]
Olympus Zuiko Digital 25mm lens - front.jpg|[[/Olympus Zuiko Digital 25 mm f/2,8/ (Pancake)]] (mars 2008) {{100}}
Olympus Zuiko Digital 35mm Macro 3.5 (2178211317).jpg|[[/Olympus Zuiko Digital 35 mm f/3,5 Macro/]]
Objektiv Olympus ZUIKO DIGITAL 50mm Macro stehend.jpg|[[/Olympus Zuiko Digital ED 50 mm f/2 Macro/]] (juin 2008) {{100}}
Olympus Zuiko Digital 40-150mm f3.5-4.5 lens - front.jpg|[[/Olympus Zuiko Digital ED 40-150 mm f/3,5-4,5/]] (2006) {{100}}
Olympus 50–200 2.8–3.5.jpg|50-200 mm f/2,8-3,5 ED
Olympus E-500 + EC-20 + Zuiko 50-200mm.jpg|50-200 mm f/2,8-3,5 ED
Zuiko Digital ED 50-200mm F2.8-3.5 SWD.jpg|50-200 mm f/2,8-3,5 ED
Olympus E-330 + Zuiko 50-200mm.jpg|50-200 mm f/2,8-3,5 ED
Objektiv Olympus ZUIKO DIGITAL 70-300mm by 300mm.jpg|[[/Olympus Zuiko Digital ED 70-300 mm f/4,0-5,6/]] (2007) {{75}}
IMG.svg|[[/Olympus Zuiko Digital ED 90-250 mm f/2,8/]] (2007) {{25}}
</gallery>
=== Série Micro Four-Thirds ===
<gallery>
Objektiv Olympus M.ZUIKO DIGITAL 7-14mm stehend.jpg|Olympus M.Zuiko Digital 7-14 mm
Objektiv Olympus M.ZUIKO DIGITAL 7-14mm.jpg
MelvL P3180491 (5536855633).jpg|[[/Olympus M Zuiko Digital ED 9-18 mm f/4-5,6|Olympus M Zuiko Digital ED 9-18 mm f/4-5,6]] (avril 2010) {{50}}
Olympus 9mm F8 bodycap lens on Air A01.jpg|Olympus 9 mm f/8
Olympus 9mm F8 bodycap lens on ep5.jpg
Olympus 9mm F8 bodycap lens on GM5.jpg
Olympus 9mm F8 Fisheye bodycap lens on E-P5.jpg
IMG.svg|[[/Olympus M.Zuiko Digital ED 12 mm f/2|Olympus M.Zuiko Digital ED 12 mm f/2]] (30 juin 2011) {{75}}
Olympus M.Zuiko Digital 14-42mm.png|Olympus M.Zuiko Digital 14-42 mm f/3,5-5,6 L ED
Olympus M.Zuiko Digital 14-42mm F3.5-5.6 cutted.jpg
Olympus M.Zuiko digital 14-42mm f3.5-5.6 II R.jpg|[[/Olympus M.Zuiko Digital 14-42 mm f/3,5-5,6 II R|Olympus M.Zuiko Digital 14-42 mm f/3,5-5,6 II R]]
M.Zuiko 12-50mm 02.jpg|[[/Olympus M Zuiko Digital ED 12-50 mm f/3,5-6,3 EZ|Olympus M Zuiko Digital ED 12-50 mm f/3,5-6,3 EZ]]
IMG.svg|[[/Olympus M.Zuiko Digital ED 14-150 mm f/4-5,6 II|Olympus M.Zuiko Digital ED 14-150 mm f/4-5,6 II]] (5 février 2015) {{75}}
Olympus Body Cap lens 15mm F8 n01.jpg|[[/Olympus Body Cap lens 15 mm f/8|Olympus Body Cap lens 15 mm f/8]] (septembre 2012) {{100}}
2016 0212 Olympus mft 25mm1.8.jpg|[[/Olympus M-Zuiko Digital 25 mm f/1,8/]]
M.Zuiko 12-50mm 01.jpg|Olympus M.Zuiko Digital 12-50 mm
Olympus M.Zuiko Digital 40-150mm.png|Olympus M.Zuiko Digital 40-150 mm
Olympus E-M5 15.jpg|12-50 mm
Olympus M.Zuiko Digital 40-150mm F2.8.jpg|[[/Olympus M.Zuiko Digital ED 40-150 mm f/2,8 Pro|Olympus M.Zuiko Digital ED 40-150 mm f/2,8 Pro]] (15 septembre 2014) {{100}}
Olympus M Zuiko Digital ED 45mm F1.8.jpg|Olympus M Zuiko Digital ED 45 mm f/1,8
Olympus lens M.Zuiko 75 mm f1.8.jpg|[[/Olympus M.Zuiko Digital ED 75 mm f/1,8|Olympus M.Zuiko Digital ED 75 mm f/1,8]] (8 février 2012) {{100}}
Olympus M.Zuiko Digital 300mm F4.0.jpg|Olympus M.Zuiko Digital 300 mm f/4,0
</gallery>
=== Objectifs spéciaux ===
<gallery>
Image:24mmPCleft.jpg|PC 24 mm
</gallery>
== Compléments optiques ==
<gallery>
File:Olympus TCON-14B.JPG|[[/Olympus TCON-14B|Olympus TCON-14B]] Complément optique télé destiné aux appareils E-10 et E-20
File:Olympus E-20 with TCON-14B.JPG|Olympus E-20 avec TCON-14B
Fichier:OlympusTeleconv2x.png|Téléconvertisseur EC-20 2x
File:Olympus EC-20.jpg|Téléconvertisseur EC-20 2x
Image:IMG.svg|[[/Olympus EC14|Olympus EC14]] multiplicateur de focale 1,4x (2010)
File:Olympus TCON-300S ohne Gegenlichtblende.JPG|Olympus TCON-300S
File:Olympus E-20P mit TCON-300S.JPG|E-20P avec TCON-300S
</gallery>
== Flashes ==
<gallery>
Olympus XA1 (2404583547).jpg|[[/Olympus A9M|Olympus A9M]]
Olympus XA4 Macro (2388651901).jpg|[[/Olympus A11|Olympus A11 monté sur Olympus XA4 Macro]]
Olympus XA3 (2405412636).jpg|[[/Olympus A16|Olympus A16 monté sur Olympus XA1]]
Blixt jm2.jpg|flash T32 pour OM-2
Blixt jm3.jpg|flash T32 pour OM-2
Blixt jm4.jpg|flash T32 pour OM-2
Blixt jm5.jpg|flash T32 pour OM-2
2006-07-07 00-35-52b.jpg
Olympus FL-40.jpg|FL-40
Olympus FL-40 1.jpg|FL-40
Olympus FL-40 8.jpg
Olympus FL-40 7.jpg
Olympus FL-40 6.jpg
Olympus FL-40 5.jpg
Olympus FL-40 4.jpg
Olympus Blitzgerät Auto Quick 310 38.jpg|flash Auto Quick 310 pour OM-2
</gallery>
== Bagues-allonges ==
Elles sont utilisées pour la [[proxiphotographie]] et pour la [[macrophotographie]].
* '''Olympus EX-25''' : longueur 25 mm, monture 4/3 Olympus, 150 €
<gallery>
File:Olympus OM Zwischenringe 25 + 14mm.jpg|Bagues de 14 et 25 mm pour Olympus OM
</gallery>
== Accessoires divers ==
<gallery>
MelvL P1260102 (5388033078).jpg|Viseur électronique VF-2
MelvL P1260105 (5387430951).jpg|Viseur électronique VF-2
MelvL P3180492 (5536856995).jpg|Pare-soleil LH-55B
Adattatore per esposizioni manuali - Museo scienza tecnologia Milano 13097.jpg|Adaptateur pour l'exposition manuelle pour [[/Olympus OM10/]]
Olympus OM Winder 2.jpg|OM winder 2
Olympus OM MD1 Motor.jpg|OM motor drive
Olympus focusing screen 1-1 (5344261324).jpg|verre de visée pour Olympus OM-1
Olympus Winkelsucher OM.jpg|Viseur d'angle pour Olympus OM
Olympus-OM-Macro-Flash-Shoe-Ring.JPG|support pour flash macro
Olympus slide copier hg.jpg|duplicateur de diapositives
Slide copier - Olympus bellows unit, modified to take a Pentax body.jpg
Slide copier - Olympus bellows unit, modified to take a Pentax body - (1).jpg
Olympus Aufbewahrungsmappe für SmartMedia Speicherkarten 06.jpg
Olympus Aufbewahrungsmappe für SmartMedia Speicherkarten 08.jpg
Olympus Kabelfernauslöser RM-UC1.jpg
Olympus Li-ion Akkuladegerät BCM-2 21.jpg
Carcasa y cámara de fotos subacuática.jpg|Caisson étanche PT-029 pour Olympus Stylus 600 (2001)
</gallery>
== Sacs et fourre-tout ==
La marque vend des étuis, sacs et fourre-tout adaptés à ses produits.
{{Ph Fabricants}}
s2z6xmln9mtd7b0enox120029aflms1
Dictionnaire de philosophie/C
0
30593
763711
754063
2026-04-15T05:47:36Z
PandaMystique
119061
763711
wikitext
text/x-wiki
{{DicoPhilo|C}}
<!-- Grille de contenu -->
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 2em; width: 100%; margin-bottom: 2em;">
<div style="padding: 1.5em; background: linear-gradient(135deg, #ffffff40 0%, #e0e4e840 100%); border-radius: 10px; box-shadow: 0 3px 10px rgba(0,0,0,0.1);">
:Catégorie
:[[Dictionnaire de philosophie/Causalité|Causalité]]
:Cause
:Caverne (allégorie)
:[[Dictionnaire de philosophie/Certitude|Certitude]]
:{{MotPhilo|Chamfort}}
:[[Dictionnaire de philosophie/Chine|Chine]]
:Chose
:Civilisation
:''Clinamen''
:''{{MotPhilo|Cogito}}''
</div>
<div style="padding: 1.5em; background: linear-gradient(135deg, #ffffff40 0%, #e0e4e840 100%); border-radius: 10px; box-shadow: 0 3px 10px rgba(0,0,0,0.1);">
:[[Dictionnaire de philosophie/Concept|Concept]]
:[[Dictionnaire de philosophie/Connaissance|Connaissance]]
:[[Dictionnaire de philosophie/Conscience|Conscience]]
:{{Page|Consensus}}
:Contemplation
:[[Dictionnaire de philosophie/Contingence|Contingence]]
:Contradiction
:Contrat
:[[Dictionnaire de philosophie/Convention|Convention]]
:[[Dictionnaire de philosophie/Courage|Courage]]
:{{Page|Criticisme}}
</div>
<div style="padding: 1.5em; background: linear-gradient(135deg, #ffffff40 0%, #e0e4e840 100%); border-radius: 10px; box-shadow: 0 3px 10px rgba(0,0,0,0.1);">
:[[Dictionnaire de philosophie/Croyance|Croyance]]
:[[Dictionnaire de philosophie/Culture|Culture]]
:Cynisme
</div>
</div>
{{PhiloRecherche}}
{{Autocat}}
nxyfx1jllblqz9jh3fqm2ctpk6e6l74
Ldap
0
57702
763682
761426
2026-04-14T19:42:51Z
~2026-23032-54
123512
/* LDAP */ Coquille dans le texte (base strong mal formée).
763682
wikitext
text/x-wiki
{{feuille volante}}
{{ébauche}}
==LDAP==
Lightweight Directory Protocol mais le nom le plus utilisé est LDAP.
Ldap est un annuaire qui centralise les comptes, les e-mails et les adresses physique. Il remplace le minitel pour ceux qui ont connus.
<H1><div style="text-align: center;">Installation de LDAP</div></H1>
Pour commencer il faut récupérer l'archive :
à l'adresse suivante : ftp://ftp.openldap.org/pub/OpenLDAP/openldap-release
<div style="text-align: center">wget ftp://ftp.openldap.org/pub/OpenLDAP/openldap-release/openldap-2.4.38.tgz/</div>
Sous la Debian, nous avons besoin pour compiler des dépendances.
Pour cela nous avons besoins de <nowiki><strong>make, autoconf, gcc, libtool, libldtl-dev, libssl-dev, libvdb5.1-dev, libsasl2-dev, voir si d'autres dépendances manquantes>/strong>.</nowiki>
<code>#apt-get install autoconf gcc libtool libldtl-dev libssl-dev libvdb5.1-dev libsasl2-dev</code>
Ensuite décompressons openldap-2.4.38.tgz
L'archive, je l'ai mis dans :
<code>#cd /root</code>
<code>#tar -zxvf openldap-2.4.38.tgz</code>
Une fois décompressé, nous pouvons lancer la compilation.
<code>#cd openldap-2.4.38</code>
<code>#./configure --enable-crypt=yes --enable-lmpasswd=yes --enable-spasswd=yes --enable-modules=yes --enable-overlays=yes</code>
<code>#make depend</code>
<code>#make</code>
<code>#make install</code>
Si tout ce passe bien, o doit avoir slapd en binaire dans /ust/local/libexec/ et les outils répartis entre /usr/local/bin et /usr/local/sbin.
Je vous conseille de créer un utilisateurs administrateur. Ne pas utiliser le root.
Pour cela :
<code>#/useradd -s /bin/false -d /usr/local/var/openldap-data openldap</code>
L'utilisateur openldap n'aura pas de shell.
[[Catégorie:Informatique]]
3y4w7txw8x1khmnokh6pgrjmok4fzo3
763683
763682
2026-04-14T19:43:58Z
~2026-23032-54
123512
/* LDAP */ seconde balise strong mal formée
763683
wikitext
text/x-wiki
{{feuille volante}}
{{ébauche}}
==LDAP==
Lightweight Directory Protocol mais le nom le plus utilisé est LDAP.
Ldap est un annuaire qui centralise les comptes, les e-mails et les adresses physique. Il remplace le minitel pour ceux qui ont connus.
<H1><div style="text-align: center;">Installation de LDAP</div></H1>
Pour commencer il faut récupérer l'archive :
à l'adresse suivante : ftp://ftp.openldap.org/pub/OpenLDAP/openldap-release
<div style="text-align: center">wget ftp://ftp.openldap.org/pub/OpenLDAP/openldap-release/openldap-2.4.38.tgz/</div>
Sous la Debian, nous avons besoin pour compiler des dépendances.
Pour cela nous avons besoins de <nowiki><strong>make, autoconf, gcc, libtool, libldtl-dev, libssl-dev, libvdb5.1-dev, libsasl2-dev, voir si d'autres dépendances manquantes</strong></nowiki>.
<code>#apt-get install autoconf gcc libtool libldtl-dev libssl-dev libvdb5.1-dev libsasl2-dev</code>
Ensuite décompressons openldap-2.4.38.tgz
L'archive, je l'ai mis dans :
<code>#cd /root</code>
<code>#tar -zxvf openldap-2.4.38.tgz</code>
Une fois décompressé, nous pouvons lancer la compilation.
<code>#cd openldap-2.4.38</code>
<code>#./configure --enable-crypt=yes --enable-lmpasswd=yes --enable-spasswd=yes --enable-modules=yes --enable-overlays=yes</code>
<code>#make depend</code>
<code>#make</code>
<code>#make install</code>
Si tout ce passe bien, o doit avoir slapd en binaire dans /ust/local/libexec/ et les outils répartis entre /usr/local/bin et /usr/local/sbin.
Je vous conseille de créer un utilisateurs administrateur. Ne pas utiliser le root.
Pour cela :
<code>#/useradd -s /bin/false -d /usr/local/var/openldap-data openldap</code>
L'utilisateur openldap n'aura pas de shell.
[[Catégorie:Informatique]]
8fw6k3bnnm5r0tgzb8ffb1t5xahlbg7
763712
763683
2026-04-15T06:47:00Z
JackPotte
5426
Fix tags
763712
wikitext
text/x-wiki
{{feuille volante}}
{{à fusionner|Le système d'exploitation GNU-Linux/Les annuaires LDAP}}
{{ébauche}}
==LDAP==
Lightweight Directory Protocol mais le nom le plus utilisé est LDAP.
Ldap est un annuaire qui centralise les comptes, les e-mails et les adresses physique. Il remplace le minitel pour ceux qui ont connus.
<H1><div style="text-align: center;">Installation de LDAP</div></H1>
Pour commencer il faut récupérer l'archive :
à l'adresse suivante : ftp://ftp.openldap.org/pub/OpenLDAP/openldap-release
<div style="text-align: center">wget ftp://ftp.openldap.org/pub/OpenLDAP/openldap-release/openldap-2.4.38.tgz/</div>
Sous la Debian, nous avons besoin pour compiler des dépendances.
Pour cela nous avons besoins de ''make, autoconf, gcc, libtool, libldtl-dev, libssl-dev, libvdb5.1-dev, libsasl2-dev, voir si d'autres dépendances manquantes''.
<code>#apt-get install autoconf gcc libtool libldtl-dev libssl-dev libvdb5.1-dev libsasl2-dev</code>
Ensuite décompressons openldap-2.4.38.tgz
L'archive, je l'ai mis dans :
<code>#cd /root</code>
<code>#tar -zxvf openldap-2.4.38.tgz</code>
Une fois décompressé, nous pouvons lancer la compilation.
<code>#cd openldap-2.4.38</code>
<code>#./configure --enable-crypt=yes --enable-lmpasswd=yes --enable-spasswd=yes --enable-modules=yes --enable-overlays=yes</code>
<code>#make depend</code>
<code>#make</code>
<code>#make install</code>
Si tout ce passe bien, o doit avoir slapd en binaire dans /ust/local/libexec/ et les outils répartis entre /usr/local/bin et /usr/local/sbin.
Je vous conseille de créer un utilisateurs administrateur. Ne pas utiliser le root.
Pour cela :
<code>#/useradd -s /bin/false -d /usr/local/var/openldap-data openldap</code>
L'utilisateur openldap n'aura pas de shell.
[[Catégorie:Informatique]]
dkhzp9gcjfn1qx3wy4ocs1qkenwb7d3
Fonctionnement d'un ordinateur/L'abstraction mémoire et la mémoire virtuelle
0
65813
763686
760035
2026-04-14T21:38:17Z
Mewtow
31375
/* La protection mémoire : les accès hors-segments */
763686
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 envoyées à 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 sachez qu'il existe 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 les adresses de l'espace d'adressage sont 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'abstraction mémoire implémente plusieurs fonctionnalités complémentaires==
L'utilité de l'abstraction matérielle n'est pas évidente, mais sachez qu'elle est si utile que tous les processeurs modernes la prennent en charge. Elle sert notamment à implémenter la mémoire virtuelle, que nous aborderons dans ce qui suit. La plupart de ces fonctionnalités manipulent la relation entre adresses logiques et physique. Dans le cas le plus simple, une adresse logique correspond à une seule adresse physique. Mais beaucoup de fonctionnalités avancées ne respectent pas cette règle.
===L'abstraction matérielle des processus===
Les systèmes d'exploitation modernes sont dits multi-tâche, à savoir qu'ils sont capables d'exécuter plusieurs logiciels en même temps. Et ce même si un seul processeur est présent dans l'ordinateur : les logiciels sont alors exécutés à tour de rôle. Toutefois, cela amène un paquet de problèmes qu'il faut résoudre au mieux. Par exemple, les programmes exécutés doivent se partager la mémoire RAM, ce qui ne vient pas sans problèmes. Le problème principal est que les programmes ne doivent pas lire ou écrire dans les données d'un autre, sans quoi on se retrouverait rapidement avec des problèmes. Il faut donc introduire des mécanismes d{{'}}'''isolement des processus''', pour isoler les programmes les uns des autres.
Un de ces mécanismes est l{{'}}'''abstraction matérielle des processus''', une technique qui fait que chaque programme a son propre espace d'adressage. Chaque programme a l'impression d'avoir accès à tout l'espace d'adressage, de l'adresse 0 à l'adresse maximale gérée par le processeur. Évidemment, il s'agit d'une illusion maintenue justement grâce à la traduction d'adresse. Les espaces d'adressage contiennent des adresses logiques, les adresses de la RAM sont des adresses physiques, la nécessité de l'abstraction mémoire est évidente.
Implémenter l'abstraction mémoire peut se faire de plusieurs manières. Mais dans tous les cas, il faut que la correspondance adresse logique - physique change d'un programme à l'autre. Ce qui est normal, vu que les deux processus sont placés à des endroits différents en RAM physique. La conséquence est qu'avec l'abstraction mémoire, une adresse logique correspond à plusieurs adresses physiques. Une même adresse logique dans deux processus différents correspond à deux adresses phsiques différentes, une par processus. Une adresse logique dans un processus correspondra à l'adresse physique X, la même adresse dans un autre processus correspondra à l'adresse Y.
Les adresses physiques qui partagent la même adresse logique sont alors appelées des '''adresses homonymes'''. Le choix de la bonne adresse étant réalisé par un mécanisme matériel et dépend du programme en cours. Le mécanisme pour choisir la bonne adresse dépend du processeur, mais il y en a deux grands types :
* La première consiste à utiliser l'identifiant de processus CPU, vu au chapitre précédent. C'est, pour rappel, un numéro attribué à chaque processus par le processeur. L'identifiant du processus en cours d'exécution est mémorisé dans un registre du processeur. La traduction d'adresse utilise cet identifiant, en plus de l'adresse logique, pour déterminer l'adresse physique.
* La seconde solution mémorise les correspondances adresses logiques-physique dans des tables en mémoire RAM, qui sont différentes pour chaque programme. Les tables sont accédées à chaque accès mémoire, afin de déterminer l'adresse physique.
===Le partage de la mémoire===
L'isolation des processus est très importante sur les systèmes d'exploitation modernes. Cependant, il existe quelques situations où elle doit être contournée ou du moins mise en pause. Les situations sont multiples : gestion de bibliothèques partagées, communication entre processus, usage de ''threads'', etc. Elles impliquent toutes un '''partage de mémoire''', à savoir qu'une portion de mémoire RAM est partagée entre plusieurs programmes. Le partage de mémoire est une sorte de brèche de l'isolation des processus, mais qui est autorisée car elle est utile.
Un cas intéressant est celui des '''bibliothèques partagées'''. Les bibliothèques sont des collections de fonctions regroupées ensemble, dans une seule unité de code. Un programme qui utilise une bibliothèque peut appeler n’importe quelle fonction présente dans la bibliothèque. La bibliothèque peut être simplement inclue dans le programme lui-même, on parle alors de bibliothèques statiques. De telles bibliothèques fonctionnent très bien, mais avec un petit défaut pour les bibliothèques très utilisées : plusieurs programmes qui utilisent la même bibliothèque vont chacun l'inclure dans leur code, ce qui fera doublon.
Pour éviter cela, les OS modernes gèrent des bibliothèques partagées, à savoir qu'un seul exemplaire de la bibliothèque est partagé entre plusieurs programmes. Chaque programme peut exécuter une fonction de la bibliothèque quand il le souhaite, en effectuant un branchement adéquat. Mais cela implique que la bibliothèque soit présente dans l'espace d'adressage du programme en question. Une bibliothèque est donc présente dans plusieurs espaces d'adressage, alors qu'il n'y en a qu'un seul exemplaire en mémoire RAM.
[[File:Ogg vorbis libs and application dia.svg|centre|vignette|upright=2|Exemple de bibliothèques, avec Ogg vorbis.]]
D'autres situations demandent de partager de la mémoire entre deux programmes. Par exemple, les systèmes d'exploitation modernes gèrent nativement des systèmes de '''communication inter-processus''', très utilisés par les programmes modernes pour échanger des données. Et la plupart demandant de partager un bout de mémoire entre processus, même si c'est seulement temporairement. Typiquement, deux processus partagent un intervalle d'adresse où l'un écrit les données à l'autre, l'autre lisant les données envoyées.
Une dernière utilisation de la mémoire partagée est l{{'}}'''accès direct au noyau'''. Sur les systèmes d'exploitations moderne, dans l'espace d'adressage de chaque programme, les adresses hautes sont remplies avec une partie du noyau ! Évidemment, ces adresses sont accessibles uniquement en lecture, pas en écriture. Pas question de modifier le noyau de l'OS ! De plus, il s'agit d'une portion du noyau dont on sait que la consultation ne pose pas de problèmes de sécurité.
Le programme peut lire des données dans cette portion du noyau, mais aussi exécuter les fonctions du noyau qui sont dedans. L'idée est d'éviter des appels systèmes trop fréquents. Au lieu d'effectuer un véritable appel système, avec une interruption logicielle, le programme peut exécuter des appels systèmes simplifiés, de simples appels de fonctions couplés avec un changement de niveau de privilège (passage en espace noyau nécessaire).
[[File:AMD64-canonical--48-bit.png|vignette|Répartition des adresses entre noyau (jaune/orange) et programme (verte), sur les systèmes x86-64 bits, avec des adresses physiques de 48 bits.]]
L'espace d'adressage est donc séparé en deux portions : l'OS d'un côté, le programme de l'autre. La répartition des adresses entre noyau et programme varie suivant l'OS ou le processeur utilisé. Sur les PC x86 32 bits, Linux attribuait 3 gigas pour les programmes et 1 giga pour le noyau, Windows attribuait 2 gigas à chacun. Sur les systèmes x86 64 bits, l'espace d'adressage d'un programme est coupé en trois, comme illustré ci-contre : une partie basse de 2^48 octets, une partie haute de même taille, et un bloc d'adresses invalides entre les deux. Les adresses basses sont utilisées pour le programme, les adresses hautes pour le noyau, il n'y a rien entre les deux.
Avec le partage de mémoire, plusieurs adresses logiques correspondent à la même adresse physique. Tel processus verra la zone de mémoire partagée à l'adresse X, l'autre la verra à l'adresse Y. Mais il s'agira de la même portion de mémoire physique, avec une seule adresse physique. En clair, lorsque deux processus partagent une même zone de mémoire, la zone sera mappées à des adresses logiques différentes. Les adresses logiques sont alors appelées des '''adresses synonymes''', terme qui trahit le fait qu'elles correspondent à la même adresse physique.
===La mémoire virtuelle===
Toutes les adresses ne sont pas forcément occupées par de la mémoire RAM, s'il n'y a pas assez de RAM installée. Par exemple, un processeur 32 bits peut adresser 4 gibioctets de RAM, même si seulement 3 gibioctets sont installés dans l'ordinateur. L'espace d'adressage contient donc 1 gigas d'adresses inutilisées, et il faut éviter ce surplus d'adresses pose problème.
Sans mémoire virtuelle, seule la mémoire réellement installée est utilisable. Si un programme utilise trop de mémoire, il est censé se rendre compte qu'il n'a pas accès à tout l'espace d'adressage. Quand il demandera au système d'exploitation de lui réserver de la mémoire, le système d'exploitation le préviendra qu'il n'y a plus de mémoire libre. Par exemple, si un programme tente d'utiliser 4 gibioctets sur un ordinateur avec 3 gibioctets de mémoire, il ne pourra pas. Pareil s'il veut utiliser 2 gibioctets de mémoire sur un ordinateur avec 4 gibioctets, mais dont 3 gibioctets sont déjà utilisés par d'autres programmes. Dans les deux cas, l'illusion tombe à plat.
Les techniques de '''mémoire virtuelle''' font que l'espace d'adressage est utilisable au complet, même s'il n'y a pas assez de mémoire installée dans l'ordinateur ou que d'autres programmes utilisent de la RAM. Par exemple, sur un processeur 32 bits, le programme aura accès à 4 gibioctets de RAM, même si d'autres programmes utilisent la RAM, même s'il n'y a que 2 gibioctets de RAM d'installés dans l'ordinateur.
Pour cela, on utilise une partie des mémoires de masse (disques durs) d'un ordinateur en remplacement de la mémoire physique manquante. Le système d'exploitation crée sur le disque dur un fichier, appelé le ''swapfile'' ou '''fichier de ''swap''''', qui est utilisé comme mémoire RAM supplémentaire. Il mémorise le surplus de données et de programmes qui ne peut pas être mis en mémoire RAM.
[[File:Vm1.png|centre|vignette|upright=2.0|Mémoire virtuelle et fichier de Swap.]]
Une technique naïve de mémoire virtuelle serait la suivante. Avant de l'aborder, précisons qu'il s'agit d'une technique abordée à but pédagogique, mais qui n'est implémentée nulle part tellement elle est lente et inefficace. Un espace d'adressage de 4 gigas ne contient que 3 gigas de RAM, ce qui fait 1 giga d'adresses inutilisées. Les accès mémoire aux 3 gigas de RAM se font normalement, mais l'accès aux adresses inutilisées lève une exception matérielle "Memory Unavailable". La routine d'interruption de cette exception accède alors au ''swapfile'' et récupère les données associées à cette adresse. La mémoire virtuelle est alors émulée par le système d'exploitation.
Le défaut de cette méthode est que l'accès au giga manquant est toujours très lent, parce qu'il se fait depuis le disque dur. D'autres techniques de mémoire virtuelle logicielle font beaucoup mieux, mais nous allons les passer sous silence, vu qu'on peut faire mieux, avec l'aide du matériel.
L'idée est de charger les données dont le programme a besoin dans la RAM, et de déplacer les autres sur le disque dur. Par exemple, imaginons la situation suivante : un programme a besoin de 4 gigas de mémoire, mais ne dispose que de 2 gigas de mémoire installée. On peut imaginer découper l'espace d'adressage en 2 blocs de 2 gigas, qui sont chargés à la demande. Si le programme accède aux adresses basses, on charge les 2 gigas d'adresse basse en RAM. S'il accède aux adresses hautes, on charge les 2 gigas d'adresse haute dans la RAM après avoir copié les adresses basses sur le ''swapfile''.
On perd du temps dans les copies de données entre RAM et ''swapfile'', mais on gagne en performance vu que tous les accès mémoire se font en RAM. Du fait de la localité temporelle, le programme utilise les données chargées depuis le swapfile durant un bon moment avant de passer au bloc suivant. La RAM est alors utilisée comme une sorte de cache alors que les données sont placées dans une mémoire fictive représentée par l'espace d'adressage et qui correspond au disque dur.
Mais avec cette technique, la correspondance entre adresses du programme et adresses de la RAM change au cours du temps. Les adresses de la RAM correspondent d'abord aux adresses basses, puis aux adresses hautes, et ainsi de suite. On a donc besoin d'abstraction mémoire. Les correspondances entre adresse logique et physique peuvent varier avec le temps, ce qui permet de déplacer des données de la RAM vers le disque dur ou inversement. Une adresse logique peut correspondre à une adresse physique, ou bien à une donnée swappée sur le disque dur. C'est l'unité de traduction d'adresse qui se charge de faire la différence. Si une correspondance entre adresse logique et physique est trouvée, elle l'utilise pour traduire les adresses. Si aucune correspondance n'est trouvée, alors elle laisse la main au système d'exploitation pour charger la donnée en RAM. Une fois la donnée chargée en RAM, les correspondances entre adresse logique et physiques sont modifiées de manière à ce que l'adresse logique pointe vers la donnée chargée.
===L'extension d'adressage===
Une autre fonctionnalité rendue possible par l'abstraction mémoire est l{{'}}'''extension d'adressage'''. Elle permet d'utiliser plus de mémoire que l'espace d'adressage ne le permet. Par exemple, utiliser 7 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'extension d'adresse est l'exact inverse de la mémoire virtuelle. La mémoire virtuelle sert quand on a moins de mémoire que d'adresses, l'extension d'adresse sert quand on a plus de mémoire que d'adresses.
Il y a quelques chapitres, nous avions vu que c'est possible via la commutation de banques. Mais l'abstraction mémoire est une méthode alternative. Que ce soit avec la commutation de banques ou avec l'abstraction mémoire, les adresses envoyées à la mémoire doivent être plus longues que les adresses gérées par le processeur. La différence est que l'abstraction mémoire étend les adresses d'une manière différente.
Une implémentation possible de l'extension d'adressage fait usage de l'abstraction matérielle des processus. Chaque processus a son propre espace d'adressage, mais ceux-ci sont placés à des endroits différents dans la mémoire physique. Par exemple, sur un ordinateur avec 16 gigas de RAM, mais un espace d'adressage de 2 gigas, on peut remplir la RAM en lançant 8 processus différents et chaque processus aura accès à un bloc de 2 gigas de RAM, pas plus, il ne peut pas dépasser cette limite. Ainsi, chaque processus est limité par son espace d'adressage, mais on remplit la mémoire avec plusieurs processus, ce qui compense. Il s'agit là de l'implémentation la plus simple, qui a en plus l'avantage d'avoir la meilleure compatibilité logicielle. De simples changements dans le système d'exploitation suffisent à l'implémenter.
[[File:Extension de l'espace d'adressage.png|centre|vignette|upright=1.5|Extension de l'espace d'adressage]]
Un autre implémentation donne plusieurs espaces d'adressage différents à chaque processus, et a donc accès à autant de mémoire que permis par la somme de ces espaces d'adressage. Par exemple, sur un ordinateur avec 16 gigas de RAM et un espace d'adressage de 4 gigas, un programme peut utiliser toute la RAM en utilisant 4 espaces d'adressage distincts. On passe d'un espace d'adressage à l'autre en changeant la correspondance adresse logique-physique. L'inconvénient est que la compatibilité logicielle est assez mauvaise. Modifier l'OS ne suffit pas, les programmeurs doivent impérativement concevoir leurs programmes pour qu'ils utilisent explicitement plusieurs espaces d'adressage.
Les deux implémentations font usage des adresses logiques homonymes, mais à l'intérieur d'un même processus. Pour rappel, cela veut dire qu'une adresse logique correspond à des adresses physiques différentes. Rien d'étonnant vu qu'on utilise plusieurs espaces d'adressage, comme pour l'abstraction des processus, sauf que cette fois-ci, on a plusieurs espaces d'adressage par processus. Prenons l'exemple où on a 8 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'idée est qu'une adresse correspondra à une adresse dans les premiers 4 gigas, ou dans les seconds 4 gigas. L'adresse logique X correspondra d'abord à une adresse physique dans les premiers 4 gigas, puis à une adresse physique dans les seconds 4 gigas.
===La protection mémoire===
La '''protection mémoire''' regroupe des techniques très différentes les unes des autres, qui visent à améliorer la sécurité des programmes et des systèmes d'exploitation. Elles visent à empêcher de lire, d'écrire ou d'exécuter certaines portions de mémoire. Sans elle, les programmes peuvent techniquement lire ou écrire les données des autres, ce qui causent des situations non-prévues par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses.
La première technique de protection mémoire est l{{'}}'''isolation des processus''', qu'on a vue plus haut. Elle garantit que chaque programme n'a accès qu'à certaines portions dédiées de la mémoire et rend le reste de la mémoire inaccessible en lecture et en écriture. Le système d'exploitation attribue à chaque programme une ou plusieurs portions de mémoire rien que pour lui, auquel aucun autre programme ne peut accéder. Un tel programme, isolé des autres, s'appelle un '''processus''', d'où le nom de cet objectif. Toute tentative d'accès à une partie de la mémoire non autorisée déclenche une exception matérielle (rappelez-vous le chapitre sur les interruptions) qui est traitée par une routine du système d'exploitation. Généralement, le programme fautif est sauvagement arrêté et un message d'erreur est affiché à l'écran.
La '''protection de l'espace exécutable''' empêche d’exécuter quoique ce soit provenant de certaines zones de la mémoire. En effet, certaines portions de la mémoire sont censées contenir uniquement des données, sans aucun programme ou code exécutable. Cependant, des virus informatiques peuvent se cacher dedans et d’exécuter depuis celles-ci. Ou encore, des failles de sécurités peuvent permettre à un attaquant d'injecter du code exécutable malicieux dans des données, ce qui peut lui permettre de lire les données manipulées par un programme, prendre le contrôle de la machine, injecter des virus, ou autre. Pour éviter cela, le système d'exploitation peut marquer certaines zones mémoire comme n'étant pas exécutable. Toute tentative d’exécuter du code localisé dans ces zones entraîne la levée d'une exception ou d'une erreur et le système d'exploitation réagit en conséquence. Là encore, le processeur doit détecter les exécutions non autorisées.
D'autres méthodes de protection mémoire visent à limiter des actions dangereuses. Pour cela, le processeur et l'OS gèrent des '''droits d'accès''', qui interdisent certaines actions pour des programmes non-autorisés. Lorsqu'on exécute une opération interdite, le système d’exploitation et/ou le processeur réagissent en conséquence. La première technique de ce genre n'est autre que la séparation entre espace noyau et utilisateur, vue dans le chapitre sur les interruptions. Mais il y en a d'autres, comme nous le verrons dans ce chapitre.
==La MMU==
La traduction des adresses logiques en adresses physiques se fait par un circuit spécialisé appelé la '''''Memory Management Unit''''' (MMU), qui est souvent intégré directement dans l'interface mémoire. La MMU est souvent associée à une ou plusieurs mémoires caches, qui visent à accélérer la traduction d'adresses logiques en adresses physiques. En effet, nous verrons plus bas que la traduction d'adresse demande d'accéder à des tableaux, gérés par le système d'exploitation, qui sont en mémoire RAM. Aussi, les processeurs modernes incorporent des mémoires caches appelées des '''''Translation Lookaside Buffers''''', ou encore TLB. Nous nous pouvons pas parler des TLB pour le moment, car nous n'avons pas encore abordé le chapitre sur les mémoires caches, mais un chapitre entier sera dédié aux TLB d'ici peu.
[[File:MMU principle updated.png|centre|vignette|upright=2|MMU.]]
===Les MMU intégrées au processeur===
D'ordinaire, la MMU est intégrée au processeur. Et elle peut l'être de deux manières. La première en fait un circuit séparé, relié au bus d'adresse. La seconde fusionne la MMU avec l'unité de calcul d'adresse. La première solution est surtout utilisée avec une technique d'abstraction mémoire appelée la pagination, alors que l'autre l'est avec une autre méthode appelée la segmentation. La raison est que la traduction d'adresse avec la segmentation est assez simple : elle demande d'additionner le contenu d'un registre avec l'adresse logique, ce qui est le genre de calcul qu'une unité de calcul d'adresse sait déjà faire. La fusion est donc assez évidente.
Pour donner un exemple, l'Intel 8086 fusionnait l'unité de calcul d'adresse et la MMU. Précisément, il utilisait un même additionneur pour incrémenter le ''program counter'' et effectuer des calculs d'adresse liés à la segmentation. Il aurait été logique d'ajouter les pointeurs de pile avec, mais ce n'était pas possible. La raison est que le pointeur de pile ne peut pas être envoyé directement sur le bus d'adresse, vu qu'il doit passer par une phase de traduction en adresse physique liée à la segmentation.
[[File:80186 arch.png|centre|vignette|upright=2|Intel 8086, microarchitecture.]]
===Les MMU séparées du processeur, sur la carte mère===
Il a existé des processeurs avec une MMU externe, soudée sur la carte mère.
Par exemple, les processeurs Motorola 68000 et 68010 pouvaient être combinés avec une MMU de type Motorola 68451. Elle supportait des versions simplifiées de la segmentation et de la pagination. Au minimum, elle ajoutait un support de la protection mémoire contre certains accès non-autorisés. La gestion de la mémoire virtuelle proprement dit n'était possible que si le processeur utilisé était un Motorola 68010, en raison de la manière dont le 68000 gérait ses accès mémoire. La MMU 68451 gérait un espace d'adressage de 16 mébioctets, découpé en maximum 32 pages/segments. On pouvait dépasser cette limite de 32 segments/pages en combinant plusieurs 68451.
Le Motorola 68851 était une MMU qui était prévue pour fonctionner de paire avec le Motorola 68020. Elle gérait la pagination pour un espace d'adressage de 32 bits.
Les processeurs suivants, les 68030, 68040, et 68060, avaient une MMU interne au processeur.
==La relocation matérielle==
Pour rappel, les systèmes d'exploitation moderne permettent de lancer plusieurs programmes en même temps et les laissent se partager la mémoire. Dans le cas le plus simple, qui n'est pas celui des OS modernes, le système d'exploitation découpe la mémoire en blocs d'adresses contiguës qui sont appelés des '''segments''', ou encore des ''partitions mémoire''. Les segments correspondent à un bloc de mémoire RAM. C'est-à-dire qu'un segment de 259 mébioctets sera un segment continu de 259 mébioctets dans la mémoire physique comme dans la mémoire logique. Dans ce qui suit, un segment contient un programme en cours d'exécution, comme illustré ci-dessous.
[[File:CPT Memory Addressable.svg|centre|vignette|upright=2|Espace d'adressage segmenté.]]
Le système d'exploitation mémorise la position de chaque segment en mémoire, ainsi que d'autres informations annexes. Le tout est regroupé dans la '''table de segment''', un tableau dont chaque case est attribuée à un programme/segment. La table des segments est un tableau numéroté, chaque segment ayant un numéro qui précise sa position dans le tableau. Chaque case, chaque entrée, contient un '''descripteur de segment''' qui regroupe plusieurs informations sur le segment : son adresse de base, sa taille, diverses informations.
===La relocation avec la relocation matérielle : le registre de base===
Un segment peut être placé n'importe où en RAM physique et sa position en RAM change à chaque exécution. Le programme est chargé à une adresse, celle du début du segment, qui change à chaque chargement du programme. Et toutes les adresses utilisées par le programme doivent être corrigées lors du chargement du programme, généralement par l'OS. Cette correction s'appelle la '''relocation''', et elle consiste à ajouter l'adresse de début du segment à chaque adresse manipulée par le programme.
[[File:Relocation assistée par matériel.png|centre|vignette|upright=2.5|Relocation.]]
La relocation matérielle fait que la relocation est faite par le processeur, pas par l'OS. La relocation est intégrée dans le processeur par l'intégration d'un registre : le '''registre de base''', aussi appelé '''registre de relocation'''. Il mémorise l'adresse à laquelle commence le segment, la première adresse du programme. Pour effectuer la relocation, le processeur ajoute automatiquement l'adresse de base à chaque accès mémoire, en allant la chercher dans le registre de relocation.
[[File:Registre de base de segment.png|centre|vignette|upright=2|Registre de base de segment.]]
Le processeur s'occupe de la relocation des segments et le programme compilé n'en voit rien. Pour le dire autrement, les programmes manipulent des adresses logiques, qui sont traduites par le processeur en adresses physiques. La traduction se fait en ajoutant le contenu du registre de relocation à l'adresse logique. De plus, cette méthode fait que chaque programme a son propre espace d'adressage.
[[File:CPU created logical address presentation.png|centre|vignette|upright=2|Traduction d'adresse avec la relocation matérielle.]]
Le système d'exploitation mémorise les adresses de base pour chaque programme, dans la table des segments. Le registre de base est mis à jour automatiquement lors de chaque changement de segment. Pour cela, le registre de base est accessible via certaines instructions, accessibles en espace noyau, plus rarement en espace utilisateur. Le registre de segment est censé être adressé implicitement, vu qu'il est unique. Si ce n'est pas le cas, il est possible d'écrire dans ce registre de segment, qui est alors adressable.
===La protection mémoire avec la relocation matérielle : le registre limite===
Sans restrictions supplémentaires, la taille maximale d'un segment est égale à la taille complète de l'espace d'adressage. Sur les processeurs 32 bits, un segment a une taille maximale de 2^32 octets, soit 4 gibioctets. Mais il est possible de limiter la taille du segment à 2 gibioctets, 1 gibioctet, 64 Kibioctets, ou toute autre taille. La limite est définie lors de la création du segment, mais elle peut cependant évoluer au cours de l'exécution du programme, grâce à l'allocation mémoire. Le processeur vérifie à chaque accès mémoire que celui-ci se fait bien dans le segment, en comparant l'adresse accédée à l'adresse de base et l'adresse maximale, l'adresse limite.
Limiter la taille d'un segment demande soit de mémoriser sa taille, soit de mémoriser l'adresse limite (l'adresse de fin de segment, l'adresse limite à ne pas dépasser). Les deux sont possibles et marchent parfaitement, le choix entre les deux solutions est une pure question de préférence. A la rigueur, la vérification des débordements est légèrement plus rapide si on utilise l'adresse de fin du segment. Précisons que l'adresse limite est une adresse logique, le segment commence toujours à l'adresse logique zéro.
Pour cela, la table des segments doit être modifiée. Au lieu de ne contenir que l'adresse de base, elle contient soit l'adresse maximale du segment, soit la taille du segment. En clair, le descripteur de segment est enrichi avec l'adresse limite. 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.
Techniquement, il y a une petite différence de vitesse entre utiliser la taille et l'adresse maximale. Vérifier les débordements avec la taille demande juste de comparer la taille avec l'adresse logique, avant relocation, ce qui peut être fait en parallèle de la relocation. Par contre, l'adresse limite est comparée à une adresse physique, ce qui demande de faire la relocation avant la vérification, ce qui prend un peu plus de temps. Mais l'impact sur les performances est des plus mineurs.
[[File:Registre limite.png|centre|vignette|upright=2|Registre limite]]
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> </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>+ </code><code style="background:#DDF">0001 0010 0011 0100</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </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. Au lieu d'utiliser un additionneur séparé pour le ''program counter'' et un autre pour le calcul de l'adresse physique, un seul additionneur est utilisé pour les deux. L'idée était de partager l'additionneur, qui servait à la fois à incrémenter le ''program counter'' et pour gérer la segmentation. 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|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> </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>+ </code><code style="background:#DDF">1111 1111 1111 1111</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </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 de nos PCs, à partir du 286 d'Intel. 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. A 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. A la place, les segments sont adressés de manière indirecte. A 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.
===La protection mémoire : les accès hors-segments===
Comme avec la relocation matérielle, le processeur utilise l'adresse ou la taille limite pour vérifier si l'accès mémoire ne déborde pas en-dehors du segment en cours. Pour cela, le processeur compare l'adresse logique accédée avec l'adresse limite, ou compare la taille limite avec le décalage. L'information est lue depuis la table des segments à chaque accès.
[[File:Vm7.svg|centre|vignette|upright=2|Traduction d'adresse avec vérification des accès hors-segment.]]
Par contre, une nouveauté fait son apparition avec la segmentation : la '''gestion des droits d'accès'''. 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 à trois bits, qui indiquent si le segment est accesible en lecture/écriture ou exécutable. 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.
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 pour tester si telle ou telle erreur survient. Il suffit que le microcode intègre des micro-branchements pour cela. Sur l'Intel 386, elle était réalisée par un circuit combinatoire dédié, la '''''Protection Test Unit'''''. 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. Le tout était totalement séparé du microcode.
Le PLA recevait un descripteur de segment, lu depuis la mémoire RAM, ainsi qu'un code opération provenant du microcode. Le code opération disait quelles conditions il fallait tester, sur les 33 possibles (33 sur ce processeur, le nombre varie d'un CPU à l'autre). Le microcode vérifiait s'ils 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.
{|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 || C/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
|}
Le PLA testait plus d'une centaine de conditions, en parallèle, en comparant les bits d'entrées avec l'instruction demandée. Par exemple, il vérifiait si le bit R/W était cohérent avec le fait que l'instruction en cours d'exécution est une écriture. Il fournissait un résultat qui : soit autorisait l'exécution de la lecture/écriture, soit levait une exception.
===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, les bits A et 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.
====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. A 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]]
A 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. A 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. A la place, l'entrée contient un '''bit ''global''''', qui précise que cette correspondance est valide pour tous les processus. Le bit global accélère rapidement la traduction d'adresse pour l'accès au noyau.
Un défaut de cette méthode est que le partage d'une page entre plusieurs processus est presque impossible. Impossible de partager une page avec seulement certains processus et pas d'autres : soit on partage une page avec tous les processus, soit on l'alloue avec un seul processus.
====L'usage de plusieurs tables des pages====
Une solution alternative, plus simple, utilise une table des pages par processus lancé sur l'ordinateur, une table des pages unique par espace d'adressage. À chaque changement de processus, le registre qui mémorise la position de la table des pages est modifié pour pointer sur la bonne. C'est le système d'exploitation qui se charge de cette mise à jour.
Avec cette méthode, il est possible de partager une ou plusieurs pages entre plusieurs processus, en configurant les tables des pages convenablement. Les pages partagées sont mappées dans l'espace d'adressage de plusieurs processus, mais pas forcément au même endroit, pas forcément dans les mêmes adresses logiques. On peut placer la page partagée à l'adresse logique 0x0FFF pour un processus, à l'adresse logique 0xFF00 pour un autre processus, etc. Par contre, les entrées de la table des pages pour ces adresses pointent vers la même adresse physique.
[[File:Vm5.png|centre|vignette|upright=2|Tables des pages de plusieurs processus.]]
===La taille des pages===
La taille des pages varie suivant le processeur et le système d'exploitation et tourne souvent autour de 4 kibioctets. Les processeurs actuels gèrent plusieurs tailles différentes pour les pages : 4 kibioctets par défaut, 2 mébioctets, voire 1 à 4 gibioctets pour les pages les plus larges. Les pages de 4 kibioctets sont les pages par défaut, les autres tailles de page sont appelées des ''pages larges''. La taille optimale pour les pages dépend de nombreux paramètres et il n'y a pas de taille qui convienne à tout le monde. Certaines applications gagnent à utiliser des pages larges, d'autres vont au contraire perdre drastiquement en performance en les utilisant.
Le désavantage principal des pages larges est qu'elles favorisent la fragmentation mémoire. Si un programme veut réserver une portion de mémoire, pour une structure de donnée quelconque, il doit réserver une portion dont la taille est multiple de la taille d'une page. Par exemple, un programme ayant besoin de 110 kibioctets allouera 28 pages de 4 kibioctets, soit 120 kibioctets : 2 kibioctets seront perdus. Par contre, avec des pages larges de 2 mébioctets, on aura une perte de 2048 - 110 = 1938 kibioctets. En somme, des morceaux de mémoire seront perdus, car les pages sont trop grandes pour les données qu'on veut y mettre. Le résultat est que le programme qui utilise les pages larges utilisent plus de mémoire et ce d'autant plus qu'il utilise des données de petite taille. Un autre désavantage est qu'elles se marient mal avec certaines techniques d'optimisations de type ''copy-on-write''.
Mais l'avantage est que la traduction des adresses est plus performante. Une taille des pages plus élevée signifie moins de pages, donc des tables des pages plus petites. Et des pages des tables plus petites n'ont pas besoin de beaucoup de niveaux de hiérarchie, voire peuvent se limiter à des tables des pages simples, ce qui rend la traduction d'adresse plus simple et plus rapide. De plus, les programmes ont une certaine localité spatiale, qui font qu'ils accèdent souvent à des données proches. La traduction d'adresse peut alors profiter de systèmes de mise en cache dont nous parlerons dans le prochain chapitre, et ces systèmes de cache marchent nettement mieux avec des pages larges.
Il faut noter que la taille des pages est presque toujours une puissance de deux. Cela a de nombreux avantages, mais n'est pas une nécessité. Par exemple, le tout premier processeur avec de la pagination, le super-ordinateur Atlas, avait des pages de 3 kibioctets. L'avantage principal est que la traduction de l'adresse physique en adresse logique est trivial avec une puissance de deux. Cela garantit que l'on peut diviser l'adresse en un numéro de page et un ''offset'' : la traduction demande juste de remplacer les bits de poids forts par le numéro de page voulu. Sans cela, la traduction d'adresse implique des divisions et des multiplications, qui sont des opérations assez couteuses.
===Les entrées de la table des pages===
Avant de poursuivre, faisons un rapide rappel sur les entrées de la table des pages. Nous venons de voir que la table des pages contient de nombreuses informations : un bit ''valid'' pour la mémoire virtuelle, des bits ''dirty'' et ''accessed'' utilisés par l'OS, des bits de protection mémoire, un bit ''global'' et un potentiellement un identifiant de processus, etc. Étudions rapidement le format de la table des pages sur un processeur x86 32 bits.
* Elle contient d'abord le numéro de page physique.
* Les bits AVL sont inutilisés et peuvent être configurés à loisir par l'OS.
* Le bit G est le bit ''global''.
* Le bit PS vaut 0 pour une page de 4 kibioctets, mais est mis à 1 pour une page de 4 mébioctets dans le cas où le processus utilise des pages larges.
* Le bit D est le bit ''dirty''.
* Le bit A est le bit ''accessed''.
* Le bit PCD indique que la page ne peut pas être cachée, dans le sens où le processeur ne peut copier son contenu dans le cache et doit toujours lire ou écrire cette page directement dans la RAM.
* Le bit PWT indique que les écritures doivent mettre à jour le cache et la page en RAM (dans le chapitre sur le cache, on verra qu'il force le cache à se comporter comme un cache ''write-through'' pour cette page).
* Le bit U/S précise si la page est accessible en mode noyau ou utilisateur.
* Le bit R/W indique si la page est accessible en écriture, toutes les pages sont par défaut accessibles en lecture.
* Le bit P est le bit ''valid''.
[[File:PDE.png|centre|vignette|upright=2.5|Table des pages des processeurs Intel 32 bits.]]
==Comparaison des différentes techniques d'abstraction mémoire==
Pour résumer, l'abstraction mémoire permet de gérer : la relocation, la protection mémoire, l'isolation des processus, la mémoire virtuelle, l'extension de l'espace d'adressage, le partage de mémoire, etc. Elles sont souvent implémentées en même temps. Ce qui fait qu'elles sont souvent confondues, alors que ce sont des concepts sont différents. Ces liens sont résumés dans le tableau ci-dessous.
{|class="wikitable"
|-
!
! colspan="5" | Avec abstraction mémoire
! rowspan="2" | Sans abstraction mémoire
|-
!
! Relocation matérielle
! Segmentation en mode réel (x86)
! Segmentation, général
! Architectures à capacités
! Pagination
|-
! Abstraction matérielle des processus
| colspan="4" | Oui, relocation matérielle
| Oui, liée à la traduction d'adresse
| Impossible
|-
! Mémoire virtuelle
| colspan="2" | Non, sauf émulation logicielle
| colspan="3" | Oui, gérée par le processeur et l'OS
| Non, sauf émulation logicielle
|-
! Extension de l'espace d'adressage
| colspan="2" | Oui : registre de base élargi
| colspan="2" | Oui : adresse de base élargie dans la table des segments
| ''Physical Adress Extension'' des processeurs 32 bits
| Commutation de banques
|-
! Protection mémoire
| Registre limite
| Aucune
| colspan="2" | Registre limite, droits d'accès aux segments
| Gestion des droits d'accès aux pages
| Possible, méthodes variées
|-
! Partage de mémoire
| colspan="2" | Non
| colspan="2" | Segment partagés
| Pages partagées
| Possible, méthodes variées
|}
===Les différents types de segmentation===
La segmentation regroupe plusieurs techniques franchement différentes, qui auraient gagné à être nommées différemment. La principale différence est l'usage de registres de relocation versus des registres de sélecteurs de segments. L'usage de registres de relocation est le fait de la relocation matérielle, mais aussi de la segmentation en mode réel des CPU x86. Par contre, l'usage de sélecteurs de segments est le fait des autres formes de segmentation, architectures à capacité inclues.
La différence entre les deux est le nombre de segments. L'usage de registres de relocation fait que le CPU ne gère qu'un petit nombre de segments de grande taille. La mémoire virtuelle est donc rarement implémentée vu que swapper des segments de grande taille est trop long, l'impact sur les performances est trop important. Sans compter que l'usage de registres de base se marie très mal avec la mémoire virtuelle. Vu qu'un segment peut être swappé ou déplacée n'importe quand, il faut invalider les registres de base au moment du swap/déplacement, ce qui n'est pas chose aisée. Aucun processeur ne gère cela, les méthodes pour n'existent tout simplement pas. L'usage de registres de base implique que la mémoire virtuelle est absente.
La protection mémoire est aussi plus limitée avec l'usage de registres de relocation. Elle se limite à des registres limite, mais la gestion des droits d'accès est limitée. En théorie, la segmentation en mode réel pourrait implémenter une version limitée de protection mémoire, avec une protection de l'espace exécutable. Mais ca n'a jamais été fait en pratique sur les processeurs x86.
Le partage de la mémoire est aussi difficile sur les architectures avec des registres de base. L'absence de table des segments fait que le partage d'un segment est basiquement impossible sans utiliser des méthodes complétement tordues, qui ne sont jamais implémentées en pratique.
===Segmentation versus pagination===
Par rapport à la pagination, la segmentation a des avantages et des inconvénients. Tous sont liés aux propriétés des segments et pages : les segments sont de grande taille et de taille variable, les pages sont petites et de taille fixe.
L'avantage principal de la segmentation est sa rapidité. Le fait que les segments sont de grande taille fait qu'on a pas besoin d'équivalent aux tables des pages inversée ou multiple, juste d'une table des segments toute simple. De plus, les échanges entre table des pages/segments et registres sont plus rares avec la segmentation. Par exemple, si un programme utilise un segment de 2 gigas, tous les accès dans le segment se feront avec une seule consultation de la table des segments. Alors qu'avec la pagination, il faudra une consultation de la table des pages chaque bloc de 4 kibioctet, au minimum.
Mais les désavantages sont nombreux. Le système d'exploitation doit agencer les segments en RAM, et c'est une tâche complexe. Le fait que les segments puisse changer de taille rend le tout encore plus complexe. Par exemple, si on colle les segments les uns à la suite des autres, changer la taille d'un segment demande de réorganiser tous les segments en RAM, ce qui demande énormément de copies RAM-RAM. Une autre possibilité est de laisser assez d'espace entre les segments, mais cet espace est alors gâché, dans le sens où on ne peut pas y placer un nouveau segment.
Swapper un segment est aussi très long, vu que les segments sont de grande taille, alors que swapper une page est très rapide.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'espace d'adressage du processeur
| prevText=L'espace d'adressage du processeur
| next=Les méthodes de synchronisation entre processeur et périphériques
| nextText=Les méthodes de synchronisation entre processeur et périphériques
}}
</noinclude>
9yfb8i75qqqnaxo085kkzuvi7lze864
763687
763686
2026-04-14T21:51:22Z
Mewtow
31375
/* La protection mémoire : les accès hors-segments */
763687
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 envoyées à 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 sachez qu'il existe 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 les adresses de l'espace d'adressage sont 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'abstraction mémoire implémente plusieurs fonctionnalités complémentaires==
L'utilité de l'abstraction matérielle n'est pas évidente, mais sachez qu'elle est si utile que tous les processeurs modernes la prennent en charge. Elle sert notamment à implémenter la mémoire virtuelle, que nous aborderons dans ce qui suit. La plupart de ces fonctionnalités manipulent la relation entre adresses logiques et physique. Dans le cas le plus simple, une adresse logique correspond à une seule adresse physique. Mais beaucoup de fonctionnalités avancées ne respectent pas cette règle.
===L'abstraction matérielle des processus===
Les systèmes d'exploitation modernes sont dits multi-tâche, à savoir qu'ils sont capables d'exécuter plusieurs logiciels en même temps. Et ce même si un seul processeur est présent dans l'ordinateur : les logiciels sont alors exécutés à tour de rôle. Toutefois, cela amène un paquet de problèmes qu'il faut résoudre au mieux. Par exemple, les programmes exécutés doivent se partager la mémoire RAM, ce qui ne vient pas sans problèmes. Le problème principal est que les programmes ne doivent pas lire ou écrire dans les données d'un autre, sans quoi on se retrouverait rapidement avec des problèmes. Il faut donc introduire des mécanismes d{{'}}'''isolement des processus''', pour isoler les programmes les uns des autres.
Un de ces mécanismes est l{{'}}'''abstraction matérielle des processus''', une technique qui fait que chaque programme a son propre espace d'adressage. Chaque programme a l'impression d'avoir accès à tout l'espace d'adressage, de l'adresse 0 à l'adresse maximale gérée par le processeur. Évidemment, il s'agit d'une illusion maintenue justement grâce à la traduction d'adresse. Les espaces d'adressage contiennent des adresses logiques, les adresses de la RAM sont des adresses physiques, la nécessité de l'abstraction mémoire est évidente.
Implémenter l'abstraction mémoire peut se faire de plusieurs manières. Mais dans tous les cas, il faut que la correspondance adresse logique - physique change d'un programme à l'autre. Ce qui est normal, vu que les deux processus sont placés à des endroits différents en RAM physique. La conséquence est qu'avec l'abstraction mémoire, une adresse logique correspond à plusieurs adresses physiques. Une même adresse logique dans deux processus différents correspond à deux adresses phsiques différentes, une par processus. Une adresse logique dans un processus correspondra à l'adresse physique X, la même adresse dans un autre processus correspondra à l'adresse Y.
Les adresses physiques qui partagent la même adresse logique sont alors appelées des '''adresses homonymes'''. Le choix de la bonne adresse étant réalisé par un mécanisme matériel et dépend du programme en cours. Le mécanisme pour choisir la bonne adresse dépend du processeur, mais il y en a deux grands types :
* La première consiste à utiliser l'identifiant de processus CPU, vu au chapitre précédent. C'est, pour rappel, un numéro attribué à chaque processus par le processeur. L'identifiant du processus en cours d'exécution est mémorisé dans un registre du processeur. La traduction d'adresse utilise cet identifiant, en plus de l'adresse logique, pour déterminer l'adresse physique.
* La seconde solution mémorise les correspondances adresses logiques-physique dans des tables en mémoire RAM, qui sont différentes pour chaque programme. Les tables sont accédées à chaque accès mémoire, afin de déterminer l'adresse physique.
===Le partage de la mémoire===
L'isolation des processus est très importante sur les systèmes d'exploitation modernes. Cependant, il existe quelques situations où elle doit être contournée ou du moins mise en pause. Les situations sont multiples : gestion de bibliothèques partagées, communication entre processus, usage de ''threads'', etc. Elles impliquent toutes un '''partage de mémoire''', à savoir qu'une portion de mémoire RAM est partagée entre plusieurs programmes. Le partage de mémoire est une sorte de brèche de l'isolation des processus, mais qui est autorisée car elle est utile.
Un cas intéressant est celui des '''bibliothèques partagées'''. Les bibliothèques sont des collections de fonctions regroupées ensemble, dans une seule unité de code. Un programme qui utilise une bibliothèque peut appeler n’importe quelle fonction présente dans la bibliothèque. La bibliothèque peut être simplement inclue dans le programme lui-même, on parle alors de bibliothèques statiques. De telles bibliothèques fonctionnent très bien, mais avec un petit défaut pour les bibliothèques très utilisées : plusieurs programmes qui utilisent la même bibliothèque vont chacun l'inclure dans leur code, ce qui fera doublon.
Pour éviter cela, les OS modernes gèrent des bibliothèques partagées, à savoir qu'un seul exemplaire de la bibliothèque est partagé entre plusieurs programmes. Chaque programme peut exécuter une fonction de la bibliothèque quand il le souhaite, en effectuant un branchement adéquat. Mais cela implique que la bibliothèque soit présente dans l'espace d'adressage du programme en question. Une bibliothèque est donc présente dans plusieurs espaces d'adressage, alors qu'il n'y en a qu'un seul exemplaire en mémoire RAM.
[[File:Ogg vorbis libs and application dia.svg|centre|vignette|upright=2|Exemple de bibliothèques, avec Ogg vorbis.]]
D'autres situations demandent de partager de la mémoire entre deux programmes. Par exemple, les systèmes d'exploitation modernes gèrent nativement des systèmes de '''communication inter-processus''', très utilisés par les programmes modernes pour échanger des données. Et la plupart demandant de partager un bout de mémoire entre processus, même si c'est seulement temporairement. Typiquement, deux processus partagent un intervalle d'adresse où l'un écrit les données à l'autre, l'autre lisant les données envoyées.
Une dernière utilisation de la mémoire partagée est l{{'}}'''accès direct au noyau'''. Sur les systèmes d'exploitations moderne, dans l'espace d'adressage de chaque programme, les adresses hautes sont remplies avec une partie du noyau ! Évidemment, ces adresses sont accessibles uniquement en lecture, pas en écriture. Pas question de modifier le noyau de l'OS ! De plus, il s'agit d'une portion du noyau dont on sait que la consultation ne pose pas de problèmes de sécurité.
Le programme peut lire des données dans cette portion du noyau, mais aussi exécuter les fonctions du noyau qui sont dedans. L'idée est d'éviter des appels systèmes trop fréquents. Au lieu d'effectuer un véritable appel système, avec une interruption logicielle, le programme peut exécuter des appels systèmes simplifiés, de simples appels de fonctions couplés avec un changement de niveau de privilège (passage en espace noyau nécessaire).
[[File:AMD64-canonical--48-bit.png|vignette|Répartition des adresses entre noyau (jaune/orange) et programme (verte), sur les systèmes x86-64 bits, avec des adresses physiques de 48 bits.]]
L'espace d'adressage est donc séparé en deux portions : l'OS d'un côté, le programme de l'autre. La répartition des adresses entre noyau et programme varie suivant l'OS ou le processeur utilisé. Sur les PC x86 32 bits, Linux attribuait 3 gigas pour les programmes et 1 giga pour le noyau, Windows attribuait 2 gigas à chacun. Sur les systèmes x86 64 bits, l'espace d'adressage d'un programme est coupé en trois, comme illustré ci-contre : une partie basse de 2^48 octets, une partie haute de même taille, et un bloc d'adresses invalides entre les deux. Les adresses basses sont utilisées pour le programme, les adresses hautes pour le noyau, il n'y a rien entre les deux.
Avec le partage de mémoire, plusieurs adresses logiques correspondent à la même adresse physique. Tel processus verra la zone de mémoire partagée à l'adresse X, l'autre la verra à l'adresse Y. Mais il s'agira de la même portion de mémoire physique, avec une seule adresse physique. En clair, lorsque deux processus partagent une même zone de mémoire, la zone sera mappées à des adresses logiques différentes. Les adresses logiques sont alors appelées des '''adresses synonymes''', terme qui trahit le fait qu'elles correspondent à la même adresse physique.
===La mémoire virtuelle===
Toutes les adresses ne sont pas forcément occupées par de la mémoire RAM, s'il n'y a pas assez de RAM installée. Par exemple, un processeur 32 bits peut adresser 4 gibioctets de RAM, même si seulement 3 gibioctets sont installés dans l'ordinateur. L'espace d'adressage contient donc 1 gigas d'adresses inutilisées, et il faut éviter ce surplus d'adresses pose problème.
Sans mémoire virtuelle, seule la mémoire réellement installée est utilisable. Si un programme utilise trop de mémoire, il est censé se rendre compte qu'il n'a pas accès à tout l'espace d'adressage. Quand il demandera au système d'exploitation de lui réserver de la mémoire, le système d'exploitation le préviendra qu'il n'y a plus de mémoire libre. Par exemple, si un programme tente d'utiliser 4 gibioctets sur un ordinateur avec 3 gibioctets de mémoire, il ne pourra pas. Pareil s'il veut utiliser 2 gibioctets de mémoire sur un ordinateur avec 4 gibioctets, mais dont 3 gibioctets sont déjà utilisés par d'autres programmes. Dans les deux cas, l'illusion tombe à plat.
Les techniques de '''mémoire virtuelle''' font que l'espace d'adressage est utilisable au complet, même s'il n'y a pas assez de mémoire installée dans l'ordinateur ou que d'autres programmes utilisent de la RAM. Par exemple, sur un processeur 32 bits, le programme aura accès à 4 gibioctets de RAM, même si d'autres programmes utilisent la RAM, même s'il n'y a que 2 gibioctets de RAM d'installés dans l'ordinateur.
Pour cela, on utilise une partie des mémoires de masse (disques durs) d'un ordinateur en remplacement de la mémoire physique manquante. Le système d'exploitation crée sur le disque dur un fichier, appelé le ''swapfile'' ou '''fichier de ''swap''''', qui est utilisé comme mémoire RAM supplémentaire. Il mémorise le surplus de données et de programmes qui ne peut pas être mis en mémoire RAM.
[[File:Vm1.png|centre|vignette|upright=2.0|Mémoire virtuelle et fichier de Swap.]]
Une technique naïve de mémoire virtuelle serait la suivante. Avant de l'aborder, précisons qu'il s'agit d'une technique abordée à but pédagogique, mais qui n'est implémentée nulle part tellement elle est lente et inefficace. Un espace d'adressage de 4 gigas ne contient que 3 gigas de RAM, ce qui fait 1 giga d'adresses inutilisées. Les accès mémoire aux 3 gigas de RAM se font normalement, mais l'accès aux adresses inutilisées lève une exception matérielle "Memory Unavailable". La routine d'interruption de cette exception accède alors au ''swapfile'' et récupère les données associées à cette adresse. La mémoire virtuelle est alors émulée par le système d'exploitation.
Le défaut de cette méthode est que l'accès au giga manquant est toujours très lent, parce qu'il se fait depuis le disque dur. D'autres techniques de mémoire virtuelle logicielle font beaucoup mieux, mais nous allons les passer sous silence, vu qu'on peut faire mieux, avec l'aide du matériel.
L'idée est de charger les données dont le programme a besoin dans la RAM, et de déplacer les autres sur le disque dur. Par exemple, imaginons la situation suivante : un programme a besoin de 4 gigas de mémoire, mais ne dispose que de 2 gigas de mémoire installée. On peut imaginer découper l'espace d'adressage en 2 blocs de 2 gigas, qui sont chargés à la demande. Si le programme accède aux adresses basses, on charge les 2 gigas d'adresse basse en RAM. S'il accède aux adresses hautes, on charge les 2 gigas d'adresse haute dans la RAM après avoir copié les adresses basses sur le ''swapfile''.
On perd du temps dans les copies de données entre RAM et ''swapfile'', mais on gagne en performance vu que tous les accès mémoire se font en RAM. Du fait de la localité temporelle, le programme utilise les données chargées depuis le swapfile durant un bon moment avant de passer au bloc suivant. La RAM est alors utilisée comme une sorte de cache alors que les données sont placées dans une mémoire fictive représentée par l'espace d'adressage et qui correspond au disque dur.
Mais avec cette technique, la correspondance entre adresses du programme et adresses de la RAM change au cours du temps. Les adresses de la RAM correspondent d'abord aux adresses basses, puis aux adresses hautes, et ainsi de suite. On a donc besoin d'abstraction mémoire. Les correspondances entre adresse logique et physique peuvent varier avec le temps, ce qui permet de déplacer des données de la RAM vers le disque dur ou inversement. Une adresse logique peut correspondre à une adresse physique, ou bien à une donnée swappée sur le disque dur. C'est l'unité de traduction d'adresse qui se charge de faire la différence. Si une correspondance entre adresse logique et physique est trouvée, elle l'utilise pour traduire les adresses. Si aucune correspondance n'est trouvée, alors elle laisse la main au système d'exploitation pour charger la donnée en RAM. Une fois la donnée chargée en RAM, les correspondances entre adresse logique et physiques sont modifiées de manière à ce que l'adresse logique pointe vers la donnée chargée.
===L'extension d'adressage===
Une autre fonctionnalité rendue possible par l'abstraction mémoire est l{{'}}'''extension d'adressage'''. Elle permet d'utiliser plus de mémoire que l'espace d'adressage ne le permet. Par exemple, utiliser 7 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'extension d'adresse est l'exact inverse de la mémoire virtuelle. La mémoire virtuelle sert quand on a moins de mémoire que d'adresses, l'extension d'adresse sert quand on a plus de mémoire que d'adresses.
Il y a quelques chapitres, nous avions vu que c'est possible via la commutation de banques. Mais l'abstraction mémoire est une méthode alternative. Que ce soit avec la commutation de banques ou avec l'abstraction mémoire, les adresses envoyées à la mémoire doivent être plus longues que les adresses gérées par le processeur. La différence est que l'abstraction mémoire étend les adresses d'une manière différente.
Une implémentation possible de l'extension d'adressage fait usage de l'abstraction matérielle des processus. Chaque processus a son propre espace d'adressage, mais ceux-ci sont placés à des endroits différents dans la mémoire physique. Par exemple, sur un ordinateur avec 16 gigas de RAM, mais un espace d'adressage de 2 gigas, on peut remplir la RAM en lançant 8 processus différents et chaque processus aura accès à un bloc de 2 gigas de RAM, pas plus, il ne peut pas dépasser cette limite. Ainsi, chaque processus est limité par son espace d'adressage, mais on remplit la mémoire avec plusieurs processus, ce qui compense. Il s'agit là de l'implémentation la plus simple, qui a en plus l'avantage d'avoir la meilleure compatibilité logicielle. De simples changements dans le système d'exploitation suffisent à l'implémenter.
[[File:Extension de l'espace d'adressage.png|centre|vignette|upright=1.5|Extension de l'espace d'adressage]]
Un autre implémentation donne plusieurs espaces d'adressage différents à chaque processus, et a donc accès à autant de mémoire que permis par la somme de ces espaces d'adressage. Par exemple, sur un ordinateur avec 16 gigas de RAM et un espace d'adressage de 4 gigas, un programme peut utiliser toute la RAM en utilisant 4 espaces d'adressage distincts. On passe d'un espace d'adressage à l'autre en changeant la correspondance adresse logique-physique. L'inconvénient est que la compatibilité logicielle est assez mauvaise. Modifier l'OS ne suffit pas, les programmeurs doivent impérativement concevoir leurs programmes pour qu'ils utilisent explicitement plusieurs espaces d'adressage.
Les deux implémentations font usage des adresses logiques homonymes, mais à l'intérieur d'un même processus. Pour rappel, cela veut dire qu'une adresse logique correspond à des adresses physiques différentes. Rien d'étonnant vu qu'on utilise plusieurs espaces d'adressage, comme pour l'abstraction des processus, sauf que cette fois-ci, on a plusieurs espaces d'adressage par processus. Prenons l'exemple où on a 8 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'idée est qu'une adresse correspondra à une adresse dans les premiers 4 gigas, ou dans les seconds 4 gigas. L'adresse logique X correspondra d'abord à une adresse physique dans les premiers 4 gigas, puis à une adresse physique dans les seconds 4 gigas.
===La protection mémoire===
La '''protection mémoire''' regroupe des techniques très différentes les unes des autres, qui visent à améliorer la sécurité des programmes et des systèmes d'exploitation. Elles visent à empêcher de lire, d'écrire ou d'exécuter certaines portions de mémoire. Sans elle, les programmes peuvent techniquement lire ou écrire les données des autres, ce qui causent des situations non-prévues par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses.
La première technique de protection mémoire est l{{'}}'''isolation des processus''', qu'on a vue plus haut. Elle garantit que chaque programme n'a accès qu'à certaines portions dédiées de la mémoire et rend le reste de la mémoire inaccessible en lecture et en écriture. Le système d'exploitation attribue à chaque programme une ou plusieurs portions de mémoire rien que pour lui, auquel aucun autre programme ne peut accéder. Un tel programme, isolé des autres, s'appelle un '''processus''', d'où le nom de cet objectif. Toute tentative d'accès à une partie de la mémoire non autorisée déclenche une exception matérielle (rappelez-vous le chapitre sur les interruptions) qui est traitée par une routine du système d'exploitation. Généralement, le programme fautif est sauvagement arrêté et un message d'erreur est affiché à l'écran.
La '''protection de l'espace exécutable''' empêche d’exécuter quoique ce soit provenant de certaines zones de la mémoire. En effet, certaines portions de la mémoire sont censées contenir uniquement des données, sans aucun programme ou code exécutable. Cependant, des virus informatiques peuvent se cacher dedans et d’exécuter depuis celles-ci. Ou encore, des failles de sécurités peuvent permettre à un attaquant d'injecter du code exécutable malicieux dans des données, ce qui peut lui permettre de lire les données manipulées par un programme, prendre le contrôle de la machine, injecter des virus, ou autre. Pour éviter cela, le système d'exploitation peut marquer certaines zones mémoire comme n'étant pas exécutable. Toute tentative d’exécuter du code localisé dans ces zones entraîne la levée d'une exception ou d'une erreur et le système d'exploitation réagit en conséquence. Là encore, le processeur doit détecter les exécutions non autorisées.
D'autres méthodes de protection mémoire visent à limiter des actions dangereuses. Pour cela, le processeur et l'OS gèrent des '''droits d'accès''', qui interdisent certaines actions pour des programmes non-autorisés. Lorsqu'on exécute une opération interdite, le système d’exploitation et/ou le processeur réagissent en conséquence. La première technique de ce genre n'est autre que la séparation entre espace noyau et utilisateur, vue dans le chapitre sur les interruptions. Mais il y en a d'autres, comme nous le verrons dans ce chapitre.
==La MMU==
La traduction des adresses logiques en adresses physiques se fait par un circuit spécialisé appelé la '''''Memory Management Unit''''' (MMU), qui est souvent intégré directement dans l'interface mémoire. La MMU est souvent associée à une ou plusieurs mémoires caches, qui visent à accélérer la traduction d'adresses logiques en adresses physiques. En effet, nous verrons plus bas que la traduction d'adresse demande d'accéder à des tableaux, gérés par le système d'exploitation, qui sont en mémoire RAM. Aussi, les processeurs modernes incorporent des mémoires caches appelées des '''''Translation Lookaside Buffers''''', ou encore TLB. Nous nous pouvons pas parler des TLB pour le moment, car nous n'avons pas encore abordé le chapitre sur les mémoires caches, mais un chapitre entier sera dédié aux TLB d'ici peu.
[[File:MMU principle updated.png|centre|vignette|upright=2|MMU.]]
===Les MMU intégrées au processeur===
D'ordinaire, la MMU est intégrée au processeur. Et elle peut l'être de deux manières. La première en fait un circuit séparé, relié au bus d'adresse. La seconde fusionne la MMU avec l'unité de calcul d'adresse. La première solution est surtout utilisée avec une technique d'abstraction mémoire appelée la pagination, alors que l'autre l'est avec une autre méthode appelée la segmentation. La raison est que la traduction d'adresse avec la segmentation est assez simple : elle demande d'additionner le contenu d'un registre avec l'adresse logique, ce qui est le genre de calcul qu'une unité de calcul d'adresse sait déjà faire. La fusion est donc assez évidente.
Pour donner un exemple, l'Intel 8086 fusionnait l'unité de calcul d'adresse et la MMU. Précisément, il utilisait un même additionneur pour incrémenter le ''program counter'' et effectuer des calculs d'adresse liés à la segmentation. Il aurait été logique d'ajouter les pointeurs de pile avec, mais ce n'était pas possible. La raison est que le pointeur de pile ne peut pas être envoyé directement sur le bus d'adresse, vu qu'il doit passer par une phase de traduction en adresse physique liée à la segmentation.
[[File:80186 arch.png|centre|vignette|upright=2|Intel 8086, microarchitecture.]]
===Les MMU séparées du processeur, sur la carte mère===
Il a existé des processeurs avec une MMU externe, soudée sur la carte mère.
Par exemple, les processeurs Motorola 68000 et 68010 pouvaient être combinés avec une MMU de type Motorola 68451. Elle supportait des versions simplifiées de la segmentation et de la pagination. Au minimum, elle ajoutait un support de la protection mémoire contre certains accès non-autorisés. La gestion de la mémoire virtuelle proprement dit n'était possible que si le processeur utilisé était un Motorola 68010, en raison de la manière dont le 68000 gérait ses accès mémoire. La MMU 68451 gérait un espace d'adressage de 16 mébioctets, découpé en maximum 32 pages/segments. On pouvait dépasser cette limite de 32 segments/pages en combinant plusieurs 68451.
Le Motorola 68851 était une MMU qui était prévue pour fonctionner de paire avec le Motorola 68020. Elle gérait la pagination pour un espace d'adressage de 32 bits.
Les processeurs suivants, les 68030, 68040, et 68060, avaient une MMU interne au processeur.
==La relocation matérielle==
Pour rappel, les systèmes d'exploitation moderne permettent de lancer plusieurs programmes en même temps et les laissent se partager la mémoire. Dans le cas le plus simple, qui n'est pas celui des OS modernes, le système d'exploitation découpe la mémoire en blocs d'adresses contiguës qui sont appelés des '''segments''', ou encore des ''partitions mémoire''. Les segments correspondent à un bloc de mémoire RAM. C'est-à-dire qu'un segment de 259 mébioctets sera un segment continu de 259 mébioctets dans la mémoire physique comme dans la mémoire logique. Dans ce qui suit, un segment contient un programme en cours d'exécution, comme illustré ci-dessous.
[[File:CPT Memory Addressable.svg|centre|vignette|upright=2|Espace d'adressage segmenté.]]
Le système d'exploitation mémorise la position de chaque segment en mémoire, ainsi que d'autres informations annexes. Le tout est regroupé dans la '''table de segment''', un tableau dont chaque case est attribuée à un programme/segment. La table des segments est un tableau numéroté, chaque segment ayant un numéro qui précise sa position dans le tableau. Chaque case, chaque entrée, contient un '''descripteur de segment''' qui regroupe plusieurs informations sur le segment : son adresse de base, sa taille, diverses informations.
===La relocation avec la relocation matérielle : le registre de base===
Un segment peut être placé n'importe où en RAM physique et sa position en RAM change à chaque exécution. Le programme est chargé à une adresse, celle du début du segment, qui change à chaque chargement du programme. Et toutes les adresses utilisées par le programme doivent être corrigées lors du chargement du programme, généralement par l'OS. Cette correction s'appelle la '''relocation''', et elle consiste à ajouter l'adresse de début du segment à chaque adresse manipulée par le programme.
[[File:Relocation assistée par matériel.png|centre|vignette|upright=2.5|Relocation.]]
La relocation matérielle fait que la relocation est faite par le processeur, pas par l'OS. La relocation est intégrée dans le processeur par l'intégration d'un registre : le '''registre de base''', aussi appelé '''registre de relocation'''. Il mémorise l'adresse à laquelle commence le segment, la première adresse du programme. Pour effectuer la relocation, le processeur ajoute automatiquement l'adresse de base à chaque accès mémoire, en allant la chercher dans le registre de relocation.
[[File:Registre de base de segment.png|centre|vignette|upright=2|Registre de base de segment.]]
Le processeur s'occupe de la relocation des segments et le programme compilé n'en voit rien. Pour le dire autrement, les programmes manipulent des adresses logiques, qui sont traduites par le processeur en adresses physiques. La traduction se fait en ajoutant le contenu du registre de relocation à l'adresse logique. De plus, cette méthode fait que chaque programme a son propre espace d'adressage.
[[File:CPU created logical address presentation.png|centre|vignette|upright=2|Traduction d'adresse avec la relocation matérielle.]]
Le système d'exploitation mémorise les adresses de base pour chaque programme, dans la table des segments. Le registre de base est mis à jour automatiquement lors de chaque changement de segment. Pour cela, le registre de base est accessible via certaines instructions, accessibles en espace noyau, plus rarement en espace utilisateur. Le registre de segment est censé être adressé implicitement, vu qu'il est unique. Si ce n'est pas le cas, il est possible d'écrire dans ce registre de segment, qui est alors adressable.
===La protection mémoire avec la relocation matérielle : le registre limite===
Sans restrictions supplémentaires, la taille maximale d'un segment est égale à la taille complète de l'espace d'adressage. Sur les processeurs 32 bits, un segment a une taille maximale de 2^32 octets, soit 4 gibioctets. Mais il est possible de limiter la taille du segment à 2 gibioctets, 1 gibioctet, 64 Kibioctets, ou toute autre taille. La limite est définie lors de la création du segment, mais elle peut cependant évoluer au cours de l'exécution du programme, grâce à l'allocation mémoire. Le processeur vérifie à chaque accès mémoire que celui-ci se fait bien dans le segment, en comparant l'adresse accédée à l'adresse de base et l'adresse maximale, l'adresse limite.
Limiter la taille d'un segment demande soit de mémoriser sa taille, soit de mémoriser l'adresse limite (l'adresse de fin de segment, l'adresse limite à ne pas dépasser). Les deux sont possibles et marchent parfaitement, le choix entre les deux solutions est une pure question de préférence. A la rigueur, la vérification des débordements est légèrement plus rapide si on utilise l'adresse de fin du segment. Précisons que l'adresse limite est une adresse logique, le segment commence toujours à l'adresse logique zéro.
Pour cela, la table des segments doit être modifiée. Au lieu de ne contenir que l'adresse de base, elle contient soit l'adresse maximale du segment, soit la taille du segment. En clair, le descripteur de segment est enrichi avec l'adresse limite. 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.
Techniquement, il y a une petite différence de vitesse entre utiliser la taille et l'adresse maximale. Vérifier les débordements avec la taille demande juste de comparer la taille avec l'adresse logique, avant relocation, ce qui peut être fait en parallèle de la relocation. Par contre, l'adresse limite est comparée à une adresse physique, ce qui demande de faire la relocation avant la vérification, ce qui prend un peu plus de temps. Mais l'impact sur les performances est des plus mineurs.
[[File:Registre limite.png|centre|vignette|upright=2|Registre limite]]
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> </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>+ </code><code style="background:#DDF">0001 0010 0011 0100</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </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. Au lieu d'utiliser un additionneur séparé pour le ''program counter'' et un autre pour le calcul de l'adresse physique, un seul additionneur est utilisé pour les deux. L'idée était de partager l'additionneur, qui servait à la fois à incrémenter le ''program counter'' et pour gérer la segmentation. 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|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> </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>+ </code><code style="background:#DDF">1111 1111 1111 1111</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </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 de nos PCs, à partir du 286 d'Intel. 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. A 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. A la place, les segments sont adressés de manière indirecte. A 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.
===La protection mémoire : les accès hors-segments===
Comme avec la relocation matérielle, le processeur utilise l'adresse ou la taille limite pour vérifier si l'accès mémoire ne déborde pas en-dehors du segment en cours. Pour cela, le processeur compare l'adresse logique accédée avec l'adresse limite, ou compare la taille limite avec le décalage. L'information est lue depuis la table des segments à chaque accès.
[[File:Vm7.svg|centre|vignette|upright=2|Traduction d'adresse avec vérification des accès hors-segment.]]
Par contre, une nouveauté fait son apparition avec la segmentation : la '''gestion des droits d'accès'''. 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 à trois bits, qui indiquent si le segment est accesible en lecture/écriture ou exécutable. 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.
L'implémentation de la protection mémoire dépend du CPU considéré. Mais en général, elle se repose sur 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. Les trois étapes sont réalisées en au moins une micro-opération chacune, souvent plus. Notamment, les tests de protection mémoire demandent de tester beaucoup de conditions différentes. Par exemple, le CPU Intel 386 avait 148 conditions distinctes à tester, mais heureusement pas 148 par instructions. En réalité, le processeur pouvait se trouver dans 33 situations différentes, chacune demandant de tester moins d'une dizaine de conditions différentes.
Les CPU microcodés peuvent en théorie utiliser le microcode pour tester si telle ou telle erreur survient. Il suffit que le microcode intègre des micro-branchements pour cela. Mais les performances sont alors rarement au rendez-vous. Une solution alternative, faisait les tests avec un circuit combinatoire dédié. L'Intel 386 a utilisé cette solution, il intégrait une '''''Protection Test Unit'''''. 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. Le tout était totalement séparé du microcode.
Le PLA recevait un descripteur de segment, lu depuis la mémoire RAM, ainsi qu'un code opération provenant du microcode. Le code opération disait quelles conditions il fallait tester, sur les 33 possibles (33 sur ce processeur, le nombre varie d'un CPU à l'autre). Le microcode vérifiait s'ils 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.
{|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 || C/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
|}
Le PLA testait plus d'une centaine de conditions, en parallèle, en comparant les bits d'entrées avec l'instruction demandée. Par exemple, il vérifiait si le bit R/W était cohérent avec le fait que l'instruction en cours d'exécution est une écriture. Il fournissait en sortie :
* Un bit qui : soit autorisait l'exécution de la lecture/écriture, soit levait une exception.
* Une adresse de 12 bits, pointant dans le microcode, sur un code levant une exception en cas d'erreur.
* 4 bits pouvant être testés par un branchement dans le microcode, qui demandaient :
** soit de modifier le bit A du descripteur de segment (pas d'erreur, on accède au segment) ;
** soit de tester s'il y a un accès hors-limite ;
** de signaler une instruction sur la pile.
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.
===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, les bits A et 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.
====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. A 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]]
A 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. A 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. A la place, l'entrée contient un '''bit ''global''''', qui précise que cette correspondance est valide pour tous les processus. Le bit global accélère rapidement la traduction d'adresse pour l'accès au noyau.
Un défaut de cette méthode est que le partage d'une page entre plusieurs processus est presque impossible. Impossible de partager une page avec seulement certains processus et pas d'autres : soit on partage une page avec tous les processus, soit on l'alloue avec un seul processus.
====L'usage de plusieurs tables des pages====
Une solution alternative, plus simple, utilise une table des pages par processus lancé sur l'ordinateur, une table des pages unique par espace d'adressage. À chaque changement de processus, le registre qui mémorise la position de la table des pages est modifié pour pointer sur la bonne. C'est le système d'exploitation qui se charge de cette mise à jour.
Avec cette méthode, il est possible de partager une ou plusieurs pages entre plusieurs processus, en configurant les tables des pages convenablement. Les pages partagées sont mappées dans l'espace d'adressage de plusieurs processus, mais pas forcément au même endroit, pas forcément dans les mêmes adresses logiques. On peut placer la page partagée à l'adresse logique 0x0FFF pour un processus, à l'adresse logique 0xFF00 pour un autre processus, etc. Par contre, les entrées de la table des pages pour ces adresses pointent vers la même adresse physique.
[[File:Vm5.png|centre|vignette|upright=2|Tables des pages de plusieurs processus.]]
===La taille des pages===
La taille des pages varie suivant le processeur et le système d'exploitation et tourne souvent autour de 4 kibioctets. Les processeurs actuels gèrent plusieurs tailles différentes pour les pages : 4 kibioctets par défaut, 2 mébioctets, voire 1 à 4 gibioctets pour les pages les plus larges. Les pages de 4 kibioctets sont les pages par défaut, les autres tailles de page sont appelées des ''pages larges''. La taille optimale pour les pages dépend de nombreux paramètres et il n'y a pas de taille qui convienne à tout le monde. Certaines applications gagnent à utiliser des pages larges, d'autres vont au contraire perdre drastiquement en performance en les utilisant.
Le désavantage principal des pages larges est qu'elles favorisent la fragmentation mémoire. Si un programme veut réserver une portion de mémoire, pour une structure de donnée quelconque, il doit réserver une portion dont la taille est multiple de la taille d'une page. Par exemple, un programme ayant besoin de 110 kibioctets allouera 28 pages de 4 kibioctets, soit 120 kibioctets : 2 kibioctets seront perdus. Par contre, avec des pages larges de 2 mébioctets, on aura une perte de 2048 - 110 = 1938 kibioctets. En somme, des morceaux de mémoire seront perdus, car les pages sont trop grandes pour les données qu'on veut y mettre. Le résultat est que le programme qui utilise les pages larges utilisent plus de mémoire et ce d'autant plus qu'il utilise des données de petite taille. Un autre désavantage est qu'elles se marient mal avec certaines techniques d'optimisations de type ''copy-on-write''.
Mais l'avantage est que la traduction des adresses est plus performante. Une taille des pages plus élevée signifie moins de pages, donc des tables des pages plus petites. Et des pages des tables plus petites n'ont pas besoin de beaucoup de niveaux de hiérarchie, voire peuvent se limiter à des tables des pages simples, ce qui rend la traduction d'adresse plus simple et plus rapide. De plus, les programmes ont une certaine localité spatiale, qui font qu'ils accèdent souvent à des données proches. La traduction d'adresse peut alors profiter de systèmes de mise en cache dont nous parlerons dans le prochain chapitre, et ces systèmes de cache marchent nettement mieux avec des pages larges.
Il faut noter que la taille des pages est presque toujours une puissance de deux. Cela a de nombreux avantages, mais n'est pas une nécessité. Par exemple, le tout premier processeur avec de la pagination, le super-ordinateur Atlas, avait des pages de 3 kibioctets. L'avantage principal est que la traduction de l'adresse physique en adresse logique est trivial avec une puissance de deux. Cela garantit que l'on peut diviser l'adresse en un numéro de page et un ''offset'' : la traduction demande juste de remplacer les bits de poids forts par le numéro de page voulu. Sans cela, la traduction d'adresse implique des divisions et des multiplications, qui sont des opérations assez couteuses.
===Les entrées de la table des pages===
Avant de poursuivre, faisons un rapide rappel sur les entrées de la table des pages. Nous venons de voir que la table des pages contient de nombreuses informations : un bit ''valid'' pour la mémoire virtuelle, des bits ''dirty'' et ''accessed'' utilisés par l'OS, des bits de protection mémoire, un bit ''global'' et un potentiellement un identifiant de processus, etc. Étudions rapidement le format de la table des pages sur un processeur x86 32 bits.
* Elle contient d'abord le numéro de page physique.
* Les bits AVL sont inutilisés et peuvent être configurés à loisir par l'OS.
* Le bit G est le bit ''global''.
* Le bit PS vaut 0 pour une page de 4 kibioctets, mais est mis à 1 pour une page de 4 mébioctets dans le cas où le processus utilise des pages larges.
* Le bit D est le bit ''dirty''.
* Le bit A est le bit ''accessed''.
* Le bit PCD indique que la page ne peut pas être cachée, dans le sens où le processeur ne peut copier son contenu dans le cache et doit toujours lire ou écrire cette page directement dans la RAM.
* Le bit PWT indique que les écritures doivent mettre à jour le cache et la page en RAM (dans le chapitre sur le cache, on verra qu'il force le cache à se comporter comme un cache ''write-through'' pour cette page).
* Le bit U/S précise si la page est accessible en mode noyau ou utilisateur.
* Le bit R/W indique si la page est accessible en écriture, toutes les pages sont par défaut accessibles en lecture.
* Le bit P est le bit ''valid''.
[[File:PDE.png|centre|vignette|upright=2.5|Table des pages des processeurs Intel 32 bits.]]
==Comparaison des différentes techniques d'abstraction mémoire==
Pour résumer, l'abstraction mémoire permet de gérer : la relocation, la protection mémoire, l'isolation des processus, la mémoire virtuelle, l'extension de l'espace d'adressage, le partage de mémoire, etc. Elles sont souvent implémentées en même temps. Ce qui fait qu'elles sont souvent confondues, alors que ce sont des concepts sont différents. Ces liens sont résumés dans le tableau ci-dessous.
{|class="wikitable"
|-
!
! colspan="5" | Avec abstraction mémoire
! rowspan="2" | Sans abstraction mémoire
|-
!
! Relocation matérielle
! Segmentation en mode réel (x86)
! Segmentation, général
! Architectures à capacités
! Pagination
|-
! Abstraction matérielle des processus
| colspan="4" | Oui, relocation matérielle
| Oui, liée à la traduction d'adresse
| Impossible
|-
! Mémoire virtuelle
| colspan="2" | Non, sauf émulation logicielle
| colspan="3" | Oui, gérée par le processeur et l'OS
| Non, sauf émulation logicielle
|-
! Extension de l'espace d'adressage
| colspan="2" | Oui : registre de base élargi
| colspan="2" | Oui : adresse de base élargie dans la table des segments
| ''Physical Adress Extension'' des processeurs 32 bits
| Commutation de banques
|-
! Protection mémoire
| Registre limite
| Aucune
| colspan="2" | Registre limite, droits d'accès aux segments
| Gestion des droits d'accès aux pages
| Possible, méthodes variées
|-
! Partage de mémoire
| colspan="2" | Non
| colspan="2" | Segment partagés
| Pages partagées
| Possible, méthodes variées
|}
===Les différents types de segmentation===
La segmentation regroupe plusieurs techniques franchement différentes, qui auraient gagné à être nommées différemment. La principale différence est l'usage de registres de relocation versus des registres de sélecteurs de segments. L'usage de registres de relocation est le fait de la relocation matérielle, mais aussi de la segmentation en mode réel des CPU x86. Par contre, l'usage de sélecteurs de segments est le fait des autres formes de segmentation, architectures à capacité inclues.
La différence entre les deux est le nombre de segments. L'usage de registres de relocation fait que le CPU ne gère qu'un petit nombre de segments de grande taille. La mémoire virtuelle est donc rarement implémentée vu que swapper des segments de grande taille est trop long, l'impact sur les performances est trop important. Sans compter que l'usage de registres de base se marie très mal avec la mémoire virtuelle. Vu qu'un segment peut être swappé ou déplacée n'importe quand, il faut invalider les registres de base au moment du swap/déplacement, ce qui n'est pas chose aisée. Aucun processeur ne gère cela, les méthodes pour n'existent tout simplement pas. L'usage de registres de base implique que la mémoire virtuelle est absente.
La protection mémoire est aussi plus limitée avec l'usage de registres de relocation. Elle se limite à des registres limite, mais la gestion des droits d'accès est limitée. En théorie, la segmentation en mode réel pourrait implémenter une version limitée de protection mémoire, avec une protection de l'espace exécutable. Mais ca n'a jamais été fait en pratique sur les processeurs x86.
Le partage de la mémoire est aussi difficile sur les architectures avec des registres de base. L'absence de table des segments fait que le partage d'un segment est basiquement impossible sans utiliser des méthodes complétement tordues, qui ne sont jamais implémentées en pratique.
===Segmentation versus pagination===
Par rapport à la pagination, la segmentation a des avantages et des inconvénients. Tous sont liés aux propriétés des segments et pages : les segments sont de grande taille et de taille variable, les pages sont petites et de taille fixe.
L'avantage principal de la segmentation est sa rapidité. Le fait que les segments sont de grande taille fait qu'on a pas besoin d'équivalent aux tables des pages inversée ou multiple, juste d'une table des segments toute simple. De plus, les échanges entre table des pages/segments et registres sont plus rares avec la segmentation. Par exemple, si un programme utilise un segment de 2 gigas, tous les accès dans le segment se feront avec une seule consultation de la table des segments. Alors qu'avec la pagination, il faudra une consultation de la table des pages chaque bloc de 4 kibioctet, au minimum.
Mais les désavantages sont nombreux. Le système d'exploitation doit agencer les segments en RAM, et c'est une tâche complexe. Le fait que les segments puisse changer de taille rend le tout encore plus complexe. Par exemple, si on colle les segments les uns à la suite des autres, changer la taille d'un segment demande de réorganiser tous les segments en RAM, ce qui demande énormément de copies RAM-RAM. Une autre possibilité est de laisser assez d'espace entre les segments, mais cet espace est alors gâché, dans le sens où on ne peut pas y placer un nouveau segment.
Swapper un segment est aussi très long, vu que les segments sont de grande taille, alors que swapper une page est très rapide.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'espace d'adressage du processeur
| prevText=L'espace d'adressage du processeur
| next=Les méthodes de synchronisation entre processeur et périphériques
| nextText=Les méthodes de synchronisation entre processeur et périphériques
}}
</noinclude>
jiixgnphlpew6yswk4h19vrdcjt9zy6
763688
763687
2026-04-14T21:56:12Z
Mewtow
31375
/* La protection mémoire : les accès hors-segments */
763688
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 envoyées à 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 sachez qu'il existe 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 les adresses de l'espace d'adressage sont 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'abstraction mémoire implémente plusieurs fonctionnalités complémentaires==
L'utilité de l'abstraction matérielle n'est pas évidente, mais sachez qu'elle est si utile que tous les processeurs modernes la prennent en charge. Elle sert notamment à implémenter la mémoire virtuelle, que nous aborderons dans ce qui suit. La plupart de ces fonctionnalités manipulent la relation entre adresses logiques et physique. Dans le cas le plus simple, une adresse logique correspond à une seule adresse physique. Mais beaucoup de fonctionnalités avancées ne respectent pas cette règle.
===L'abstraction matérielle des processus===
Les systèmes d'exploitation modernes sont dits multi-tâche, à savoir qu'ils sont capables d'exécuter plusieurs logiciels en même temps. Et ce même si un seul processeur est présent dans l'ordinateur : les logiciels sont alors exécutés à tour de rôle. Toutefois, cela amène un paquet de problèmes qu'il faut résoudre au mieux. Par exemple, les programmes exécutés doivent se partager la mémoire RAM, ce qui ne vient pas sans problèmes. Le problème principal est que les programmes ne doivent pas lire ou écrire dans les données d'un autre, sans quoi on se retrouverait rapidement avec des problèmes. Il faut donc introduire des mécanismes d{{'}}'''isolement des processus''', pour isoler les programmes les uns des autres.
Un de ces mécanismes est l{{'}}'''abstraction matérielle des processus''', une technique qui fait que chaque programme a son propre espace d'adressage. Chaque programme a l'impression d'avoir accès à tout l'espace d'adressage, de l'adresse 0 à l'adresse maximale gérée par le processeur. Évidemment, il s'agit d'une illusion maintenue justement grâce à la traduction d'adresse. Les espaces d'adressage contiennent des adresses logiques, les adresses de la RAM sont des adresses physiques, la nécessité de l'abstraction mémoire est évidente.
Implémenter l'abstraction mémoire peut se faire de plusieurs manières. Mais dans tous les cas, il faut que la correspondance adresse logique - physique change d'un programme à l'autre. Ce qui est normal, vu que les deux processus sont placés à des endroits différents en RAM physique. La conséquence est qu'avec l'abstraction mémoire, une adresse logique correspond à plusieurs adresses physiques. Une même adresse logique dans deux processus différents correspond à deux adresses phsiques différentes, une par processus. Une adresse logique dans un processus correspondra à l'adresse physique X, la même adresse dans un autre processus correspondra à l'adresse Y.
Les adresses physiques qui partagent la même adresse logique sont alors appelées des '''adresses homonymes'''. Le choix de la bonne adresse étant réalisé par un mécanisme matériel et dépend du programme en cours. Le mécanisme pour choisir la bonne adresse dépend du processeur, mais il y en a deux grands types :
* La première consiste à utiliser l'identifiant de processus CPU, vu au chapitre précédent. C'est, pour rappel, un numéro attribué à chaque processus par le processeur. L'identifiant du processus en cours d'exécution est mémorisé dans un registre du processeur. La traduction d'adresse utilise cet identifiant, en plus de l'adresse logique, pour déterminer l'adresse physique.
* La seconde solution mémorise les correspondances adresses logiques-physique dans des tables en mémoire RAM, qui sont différentes pour chaque programme. Les tables sont accédées à chaque accès mémoire, afin de déterminer l'adresse physique.
===Le partage de la mémoire===
L'isolation des processus est très importante sur les systèmes d'exploitation modernes. Cependant, il existe quelques situations où elle doit être contournée ou du moins mise en pause. Les situations sont multiples : gestion de bibliothèques partagées, communication entre processus, usage de ''threads'', etc. Elles impliquent toutes un '''partage de mémoire''', à savoir qu'une portion de mémoire RAM est partagée entre plusieurs programmes. Le partage de mémoire est une sorte de brèche de l'isolation des processus, mais qui est autorisée car elle est utile.
Un cas intéressant est celui des '''bibliothèques partagées'''. Les bibliothèques sont des collections de fonctions regroupées ensemble, dans une seule unité de code. Un programme qui utilise une bibliothèque peut appeler n’importe quelle fonction présente dans la bibliothèque. La bibliothèque peut être simplement inclue dans le programme lui-même, on parle alors de bibliothèques statiques. De telles bibliothèques fonctionnent très bien, mais avec un petit défaut pour les bibliothèques très utilisées : plusieurs programmes qui utilisent la même bibliothèque vont chacun l'inclure dans leur code, ce qui fera doublon.
Pour éviter cela, les OS modernes gèrent des bibliothèques partagées, à savoir qu'un seul exemplaire de la bibliothèque est partagé entre plusieurs programmes. Chaque programme peut exécuter une fonction de la bibliothèque quand il le souhaite, en effectuant un branchement adéquat. Mais cela implique que la bibliothèque soit présente dans l'espace d'adressage du programme en question. Une bibliothèque est donc présente dans plusieurs espaces d'adressage, alors qu'il n'y en a qu'un seul exemplaire en mémoire RAM.
[[File:Ogg vorbis libs and application dia.svg|centre|vignette|upright=2|Exemple de bibliothèques, avec Ogg vorbis.]]
D'autres situations demandent de partager de la mémoire entre deux programmes. Par exemple, les systèmes d'exploitation modernes gèrent nativement des systèmes de '''communication inter-processus''', très utilisés par les programmes modernes pour échanger des données. Et la plupart demandant de partager un bout de mémoire entre processus, même si c'est seulement temporairement. Typiquement, deux processus partagent un intervalle d'adresse où l'un écrit les données à l'autre, l'autre lisant les données envoyées.
Une dernière utilisation de la mémoire partagée est l{{'}}'''accès direct au noyau'''. Sur les systèmes d'exploitations moderne, dans l'espace d'adressage de chaque programme, les adresses hautes sont remplies avec une partie du noyau ! Évidemment, ces adresses sont accessibles uniquement en lecture, pas en écriture. Pas question de modifier le noyau de l'OS ! De plus, il s'agit d'une portion du noyau dont on sait que la consultation ne pose pas de problèmes de sécurité.
Le programme peut lire des données dans cette portion du noyau, mais aussi exécuter les fonctions du noyau qui sont dedans. L'idée est d'éviter des appels systèmes trop fréquents. Au lieu d'effectuer un véritable appel système, avec une interruption logicielle, le programme peut exécuter des appels systèmes simplifiés, de simples appels de fonctions couplés avec un changement de niveau de privilège (passage en espace noyau nécessaire).
[[File:AMD64-canonical--48-bit.png|vignette|Répartition des adresses entre noyau (jaune/orange) et programme (verte), sur les systèmes x86-64 bits, avec des adresses physiques de 48 bits.]]
L'espace d'adressage est donc séparé en deux portions : l'OS d'un côté, le programme de l'autre. La répartition des adresses entre noyau et programme varie suivant l'OS ou le processeur utilisé. Sur les PC x86 32 bits, Linux attribuait 3 gigas pour les programmes et 1 giga pour le noyau, Windows attribuait 2 gigas à chacun. Sur les systèmes x86 64 bits, l'espace d'adressage d'un programme est coupé en trois, comme illustré ci-contre : une partie basse de 2^48 octets, une partie haute de même taille, et un bloc d'adresses invalides entre les deux. Les adresses basses sont utilisées pour le programme, les adresses hautes pour le noyau, il n'y a rien entre les deux.
Avec le partage de mémoire, plusieurs adresses logiques correspondent à la même adresse physique. Tel processus verra la zone de mémoire partagée à l'adresse X, l'autre la verra à l'adresse Y. Mais il s'agira de la même portion de mémoire physique, avec une seule adresse physique. En clair, lorsque deux processus partagent une même zone de mémoire, la zone sera mappées à des adresses logiques différentes. Les adresses logiques sont alors appelées des '''adresses synonymes''', terme qui trahit le fait qu'elles correspondent à la même adresse physique.
===La mémoire virtuelle===
Toutes les adresses ne sont pas forcément occupées par de la mémoire RAM, s'il n'y a pas assez de RAM installée. Par exemple, un processeur 32 bits peut adresser 4 gibioctets de RAM, même si seulement 3 gibioctets sont installés dans l'ordinateur. L'espace d'adressage contient donc 1 gigas d'adresses inutilisées, et il faut éviter ce surplus d'adresses pose problème.
Sans mémoire virtuelle, seule la mémoire réellement installée est utilisable. Si un programme utilise trop de mémoire, il est censé se rendre compte qu'il n'a pas accès à tout l'espace d'adressage. Quand il demandera au système d'exploitation de lui réserver de la mémoire, le système d'exploitation le préviendra qu'il n'y a plus de mémoire libre. Par exemple, si un programme tente d'utiliser 4 gibioctets sur un ordinateur avec 3 gibioctets de mémoire, il ne pourra pas. Pareil s'il veut utiliser 2 gibioctets de mémoire sur un ordinateur avec 4 gibioctets, mais dont 3 gibioctets sont déjà utilisés par d'autres programmes. Dans les deux cas, l'illusion tombe à plat.
Les techniques de '''mémoire virtuelle''' font que l'espace d'adressage est utilisable au complet, même s'il n'y a pas assez de mémoire installée dans l'ordinateur ou que d'autres programmes utilisent de la RAM. Par exemple, sur un processeur 32 bits, le programme aura accès à 4 gibioctets de RAM, même si d'autres programmes utilisent la RAM, même s'il n'y a que 2 gibioctets de RAM d'installés dans l'ordinateur.
Pour cela, on utilise une partie des mémoires de masse (disques durs) d'un ordinateur en remplacement de la mémoire physique manquante. Le système d'exploitation crée sur le disque dur un fichier, appelé le ''swapfile'' ou '''fichier de ''swap''''', qui est utilisé comme mémoire RAM supplémentaire. Il mémorise le surplus de données et de programmes qui ne peut pas être mis en mémoire RAM.
[[File:Vm1.png|centre|vignette|upright=2.0|Mémoire virtuelle et fichier de Swap.]]
Une technique naïve de mémoire virtuelle serait la suivante. Avant de l'aborder, précisons qu'il s'agit d'une technique abordée à but pédagogique, mais qui n'est implémentée nulle part tellement elle est lente et inefficace. Un espace d'adressage de 4 gigas ne contient que 3 gigas de RAM, ce qui fait 1 giga d'adresses inutilisées. Les accès mémoire aux 3 gigas de RAM se font normalement, mais l'accès aux adresses inutilisées lève une exception matérielle "Memory Unavailable". La routine d'interruption de cette exception accède alors au ''swapfile'' et récupère les données associées à cette adresse. La mémoire virtuelle est alors émulée par le système d'exploitation.
Le défaut de cette méthode est que l'accès au giga manquant est toujours très lent, parce qu'il se fait depuis le disque dur. D'autres techniques de mémoire virtuelle logicielle font beaucoup mieux, mais nous allons les passer sous silence, vu qu'on peut faire mieux, avec l'aide du matériel.
L'idée est de charger les données dont le programme a besoin dans la RAM, et de déplacer les autres sur le disque dur. Par exemple, imaginons la situation suivante : un programme a besoin de 4 gigas de mémoire, mais ne dispose que de 2 gigas de mémoire installée. On peut imaginer découper l'espace d'adressage en 2 blocs de 2 gigas, qui sont chargés à la demande. Si le programme accède aux adresses basses, on charge les 2 gigas d'adresse basse en RAM. S'il accède aux adresses hautes, on charge les 2 gigas d'adresse haute dans la RAM après avoir copié les adresses basses sur le ''swapfile''.
On perd du temps dans les copies de données entre RAM et ''swapfile'', mais on gagne en performance vu que tous les accès mémoire se font en RAM. Du fait de la localité temporelle, le programme utilise les données chargées depuis le swapfile durant un bon moment avant de passer au bloc suivant. La RAM est alors utilisée comme une sorte de cache alors que les données sont placées dans une mémoire fictive représentée par l'espace d'adressage et qui correspond au disque dur.
Mais avec cette technique, la correspondance entre adresses du programme et adresses de la RAM change au cours du temps. Les adresses de la RAM correspondent d'abord aux adresses basses, puis aux adresses hautes, et ainsi de suite. On a donc besoin d'abstraction mémoire. Les correspondances entre adresse logique et physique peuvent varier avec le temps, ce qui permet de déplacer des données de la RAM vers le disque dur ou inversement. Une adresse logique peut correspondre à une adresse physique, ou bien à une donnée swappée sur le disque dur. C'est l'unité de traduction d'adresse qui se charge de faire la différence. Si une correspondance entre adresse logique et physique est trouvée, elle l'utilise pour traduire les adresses. Si aucune correspondance n'est trouvée, alors elle laisse la main au système d'exploitation pour charger la donnée en RAM. Une fois la donnée chargée en RAM, les correspondances entre adresse logique et physiques sont modifiées de manière à ce que l'adresse logique pointe vers la donnée chargée.
===L'extension d'adressage===
Une autre fonctionnalité rendue possible par l'abstraction mémoire est l{{'}}'''extension d'adressage'''. Elle permet d'utiliser plus de mémoire que l'espace d'adressage ne le permet. Par exemple, utiliser 7 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'extension d'adresse est l'exact inverse de la mémoire virtuelle. La mémoire virtuelle sert quand on a moins de mémoire que d'adresses, l'extension d'adresse sert quand on a plus de mémoire que d'adresses.
Il y a quelques chapitres, nous avions vu que c'est possible via la commutation de banques. Mais l'abstraction mémoire est une méthode alternative. Que ce soit avec la commutation de banques ou avec l'abstraction mémoire, les adresses envoyées à la mémoire doivent être plus longues que les adresses gérées par le processeur. La différence est que l'abstraction mémoire étend les adresses d'une manière différente.
Une implémentation possible de l'extension d'adressage fait usage de l'abstraction matérielle des processus. Chaque processus a son propre espace d'adressage, mais ceux-ci sont placés à des endroits différents dans la mémoire physique. Par exemple, sur un ordinateur avec 16 gigas de RAM, mais un espace d'adressage de 2 gigas, on peut remplir la RAM en lançant 8 processus différents et chaque processus aura accès à un bloc de 2 gigas de RAM, pas plus, il ne peut pas dépasser cette limite. Ainsi, chaque processus est limité par son espace d'adressage, mais on remplit la mémoire avec plusieurs processus, ce qui compense. Il s'agit là de l'implémentation la plus simple, qui a en plus l'avantage d'avoir la meilleure compatibilité logicielle. De simples changements dans le système d'exploitation suffisent à l'implémenter.
[[File:Extension de l'espace d'adressage.png|centre|vignette|upright=1.5|Extension de l'espace d'adressage]]
Un autre implémentation donne plusieurs espaces d'adressage différents à chaque processus, et a donc accès à autant de mémoire que permis par la somme de ces espaces d'adressage. Par exemple, sur un ordinateur avec 16 gigas de RAM et un espace d'adressage de 4 gigas, un programme peut utiliser toute la RAM en utilisant 4 espaces d'adressage distincts. On passe d'un espace d'adressage à l'autre en changeant la correspondance adresse logique-physique. L'inconvénient est que la compatibilité logicielle est assez mauvaise. Modifier l'OS ne suffit pas, les programmeurs doivent impérativement concevoir leurs programmes pour qu'ils utilisent explicitement plusieurs espaces d'adressage.
Les deux implémentations font usage des adresses logiques homonymes, mais à l'intérieur d'un même processus. Pour rappel, cela veut dire qu'une adresse logique correspond à des adresses physiques différentes. Rien d'étonnant vu qu'on utilise plusieurs espaces d'adressage, comme pour l'abstraction des processus, sauf que cette fois-ci, on a plusieurs espaces d'adressage par processus. Prenons l'exemple où on a 8 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'idée est qu'une adresse correspondra à une adresse dans les premiers 4 gigas, ou dans les seconds 4 gigas. L'adresse logique X correspondra d'abord à une adresse physique dans les premiers 4 gigas, puis à une adresse physique dans les seconds 4 gigas.
===La protection mémoire===
La '''protection mémoire''' regroupe des techniques très différentes les unes des autres, qui visent à améliorer la sécurité des programmes et des systèmes d'exploitation. Elles visent à empêcher de lire, d'écrire ou d'exécuter certaines portions de mémoire. Sans elle, les programmes peuvent techniquement lire ou écrire les données des autres, ce qui causent des situations non-prévues par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses.
La première technique de protection mémoire est l{{'}}'''isolation des processus''', qu'on a vue plus haut. Elle garantit que chaque programme n'a accès qu'à certaines portions dédiées de la mémoire et rend le reste de la mémoire inaccessible en lecture et en écriture. Le système d'exploitation attribue à chaque programme une ou plusieurs portions de mémoire rien que pour lui, auquel aucun autre programme ne peut accéder. Un tel programme, isolé des autres, s'appelle un '''processus''', d'où le nom de cet objectif. Toute tentative d'accès à une partie de la mémoire non autorisée déclenche une exception matérielle (rappelez-vous le chapitre sur les interruptions) qui est traitée par une routine du système d'exploitation. Généralement, le programme fautif est sauvagement arrêté et un message d'erreur est affiché à l'écran.
La '''protection de l'espace exécutable''' empêche d’exécuter quoique ce soit provenant de certaines zones de la mémoire. En effet, certaines portions de la mémoire sont censées contenir uniquement des données, sans aucun programme ou code exécutable. Cependant, des virus informatiques peuvent se cacher dedans et d’exécuter depuis celles-ci. Ou encore, des failles de sécurités peuvent permettre à un attaquant d'injecter du code exécutable malicieux dans des données, ce qui peut lui permettre de lire les données manipulées par un programme, prendre le contrôle de la machine, injecter des virus, ou autre. Pour éviter cela, le système d'exploitation peut marquer certaines zones mémoire comme n'étant pas exécutable. Toute tentative d’exécuter du code localisé dans ces zones entraîne la levée d'une exception ou d'une erreur et le système d'exploitation réagit en conséquence. Là encore, le processeur doit détecter les exécutions non autorisées.
D'autres méthodes de protection mémoire visent à limiter des actions dangereuses. Pour cela, le processeur et l'OS gèrent des '''droits d'accès''', qui interdisent certaines actions pour des programmes non-autorisés. Lorsqu'on exécute une opération interdite, le système d’exploitation et/ou le processeur réagissent en conséquence. La première technique de ce genre n'est autre que la séparation entre espace noyau et utilisateur, vue dans le chapitre sur les interruptions. Mais il y en a d'autres, comme nous le verrons dans ce chapitre.
==La MMU==
La traduction des adresses logiques en adresses physiques se fait par un circuit spécialisé appelé la '''''Memory Management Unit''''' (MMU), qui est souvent intégré directement dans l'interface mémoire. La MMU est souvent associée à une ou plusieurs mémoires caches, qui visent à accélérer la traduction d'adresses logiques en adresses physiques. En effet, nous verrons plus bas que la traduction d'adresse demande d'accéder à des tableaux, gérés par le système d'exploitation, qui sont en mémoire RAM. Aussi, les processeurs modernes incorporent des mémoires caches appelées des '''''Translation Lookaside Buffers''''', ou encore TLB. Nous nous pouvons pas parler des TLB pour le moment, car nous n'avons pas encore abordé le chapitre sur les mémoires caches, mais un chapitre entier sera dédié aux TLB d'ici peu.
[[File:MMU principle updated.png|centre|vignette|upright=2|MMU.]]
===Les MMU intégrées au processeur===
D'ordinaire, la MMU est intégrée au processeur. Et elle peut l'être de deux manières. La première en fait un circuit séparé, relié au bus d'adresse. La seconde fusionne la MMU avec l'unité de calcul d'adresse. La première solution est surtout utilisée avec une technique d'abstraction mémoire appelée la pagination, alors que l'autre l'est avec une autre méthode appelée la segmentation. La raison est que la traduction d'adresse avec la segmentation est assez simple : elle demande d'additionner le contenu d'un registre avec l'adresse logique, ce qui est le genre de calcul qu'une unité de calcul d'adresse sait déjà faire. La fusion est donc assez évidente.
Pour donner un exemple, l'Intel 8086 fusionnait l'unité de calcul d'adresse et la MMU. Précisément, il utilisait un même additionneur pour incrémenter le ''program counter'' et effectuer des calculs d'adresse liés à la segmentation. Il aurait été logique d'ajouter les pointeurs de pile avec, mais ce n'était pas possible. La raison est que le pointeur de pile ne peut pas être envoyé directement sur le bus d'adresse, vu qu'il doit passer par une phase de traduction en adresse physique liée à la segmentation.
[[File:80186 arch.png|centre|vignette|upright=2|Intel 8086, microarchitecture.]]
===Les MMU séparées du processeur, sur la carte mère===
Il a existé des processeurs avec une MMU externe, soudée sur la carte mère.
Par exemple, les processeurs Motorola 68000 et 68010 pouvaient être combinés avec une MMU de type Motorola 68451. Elle supportait des versions simplifiées de la segmentation et de la pagination. Au minimum, elle ajoutait un support de la protection mémoire contre certains accès non-autorisés. La gestion de la mémoire virtuelle proprement dit n'était possible que si le processeur utilisé était un Motorola 68010, en raison de la manière dont le 68000 gérait ses accès mémoire. La MMU 68451 gérait un espace d'adressage de 16 mébioctets, découpé en maximum 32 pages/segments. On pouvait dépasser cette limite de 32 segments/pages en combinant plusieurs 68451.
Le Motorola 68851 était une MMU qui était prévue pour fonctionner de paire avec le Motorola 68020. Elle gérait la pagination pour un espace d'adressage de 32 bits.
Les processeurs suivants, les 68030, 68040, et 68060, avaient une MMU interne au processeur.
==La relocation matérielle==
Pour rappel, les systèmes d'exploitation moderne permettent de lancer plusieurs programmes en même temps et les laissent se partager la mémoire. Dans le cas le plus simple, qui n'est pas celui des OS modernes, le système d'exploitation découpe la mémoire en blocs d'adresses contiguës qui sont appelés des '''segments''', ou encore des ''partitions mémoire''. Les segments correspondent à un bloc de mémoire RAM. C'est-à-dire qu'un segment de 259 mébioctets sera un segment continu de 259 mébioctets dans la mémoire physique comme dans la mémoire logique. Dans ce qui suit, un segment contient un programme en cours d'exécution, comme illustré ci-dessous.
[[File:CPT Memory Addressable.svg|centre|vignette|upright=2|Espace d'adressage segmenté.]]
Le système d'exploitation mémorise la position de chaque segment en mémoire, ainsi que d'autres informations annexes. Le tout est regroupé dans la '''table de segment''', un tableau dont chaque case est attribuée à un programme/segment. La table des segments est un tableau numéroté, chaque segment ayant un numéro qui précise sa position dans le tableau. Chaque case, chaque entrée, contient un '''descripteur de segment''' qui regroupe plusieurs informations sur le segment : son adresse de base, sa taille, diverses informations.
===La relocation avec la relocation matérielle : le registre de base===
Un segment peut être placé n'importe où en RAM physique et sa position en RAM change à chaque exécution. Le programme est chargé à une adresse, celle du début du segment, qui change à chaque chargement du programme. Et toutes les adresses utilisées par le programme doivent être corrigées lors du chargement du programme, généralement par l'OS. Cette correction s'appelle la '''relocation''', et elle consiste à ajouter l'adresse de début du segment à chaque adresse manipulée par le programme.
[[File:Relocation assistée par matériel.png|centre|vignette|upright=2.5|Relocation.]]
La relocation matérielle fait que la relocation est faite par le processeur, pas par l'OS. La relocation est intégrée dans le processeur par l'intégration d'un registre : le '''registre de base''', aussi appelé '''registre de relocation'''. Il mémorise l'adresse à laquelle commence le segment, la première adresse du programme. Pour effectuer la relocation, le processeur ajoute automatiquement l'adresse de base à chaque accès mémoire, en allant la chercher dans le registre de relocation.
[[File:Registre de base de segment.png|centre|vignette|upright=2|Registre de base de segment.]]
Le processeur s'occupe de la relocation des segments et le programme compilé n'en voit rien. Pour le dire autrement, les programmes manipulent des adresses logiques, qui sont traduites par le processeur en adresses physiques. La traduction se fait en ajoutant le contenu du registre de relocation à l'adresse logique. De plus, cette méthode fait que chaque programme a son propre espace d'adressage.
[[File:CPU created logical address presentation.png|centre|vignette|upright=2|Traduction d'adresse avec la relocation matérielle.]]
Le système d'exploitation mémorise les adresses de base pour chaque programme, dans la table des segments. Le registre de base est mis à jour automatiquement lors de chaque changement de segment. Pour cela, le registre de base est accessible via certaines instructions, accessibles en espace noyau, plus rarement en espace utilisateur. Le registre de segment est censé être adressé implicitement, vu qu'il est unique. Si ce n'est pas le cas, il est possible d'écrire dans ce registre de segment, qui est alors adressable.
===La protection mémoire avec la relocation matérielle : le registre limite===
Sans restrictions supplémentaires, la taille maximale d'un segment est égale à la taille complète de l'espace d'adressage. Sur les processeurs 32 bits, un segment a une taille maximale de 2^32 octets, soit 4 gibioctets. Mais il est possible de limiter la taille du segment à 2 gibioctets, 1 gibioctet, 64 Kibioctets, ou toute autre taille. La limite est définie lors de la création du segment, mais elle peut cependant évoluer au cours de l'exécution du programme, grâce à l'allocation mémoire. Le processeur vérifie à chaque accès mémoire que celui-ci se fait bien dans le segment, en comparant l'adresse accédée à l'adresse de base et l'adresse maximale, l'adresse limite.
Limiter la taille d'un segment demande soit de mémoriser sa taille, soit de mémoriser l'adresse limite (l'adresse de fin de segment, l'adresse limite à ne pas dépasser). Les deux sont possibles et marchent parfaitement, le choix entre les deux solutions est une pure question de préférence. A la rigueur, la vérification des débordements est légèrement plus rapide si on utilise l'adresse de fin du segment. Précisons que l'adresse limite est une adresse logique, le segment commence toujours à l'adresse logique zéro.
Pour cela, la table des segments doit être modifiée. Au lieu de ne contenir que l'adresse de base, elle contient soit l'adresse maximale du segment, soit la taille du segment. En clair, le descripteur de segment est enrichi avec l'adresse limite. 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.
Techniquement, il y a une petite différence de vitesse entre utiliser la taille et l'adresse maximale. Vérifier les débordements avec la taille demande juste de comparer la taille avec l'adresse logique, avant relocation, ce qui peut être fait en parallèle de la relocation. Par contre, l'adresse limite est comparée à une adresse physique, ce qui demande de faire la relocation avant la vérification, ce qui prend un peu plus de temps. Mais l'impact sur les performances est des plus mineurs.
[[File:Registre limite.png|centre|vignette|upright=2|Registre limite]]
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> </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>+ </code><code style="background:#DDF">0001 0010 0011 0100</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </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. Au lieu d'utiliser un additionneur séparé pour le ''program counter'' et un autre pour le calcul de l'adresse physique, un seul additionneur est utilisé pour les deux. L'idée était de partager l'additionneur, qui servait à la fois à incrémenter le ''program counter'' et pour gérer la segmentation. 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|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> </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>+ </code><code style="background:#DDF">1111 1111 1111 1111</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </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 de nos PCs, à partir du 286 d'Intel. 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. A 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. A la place, les segments sont adressés de manière indirecte. A 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.
===La protection mémoire : les accès hors-segments===
Comme avec la relocation matérielle, le processeur utilise l'adresse ou la taille limite pour vérifier si l'accès mémoire ne déborde pas en-dehors du segment en cours. Pour cela, le processeur compare l'adresse logique accédée avec l'adresse limite, ou compare la taille limite avec le décalage. L'information est lue depuis la table des segments à chaque accès.
[[File:Vm7.svg|centre|vignette|upright=2|Traduction d'adresse avec vérification des accès hors-segment.]]
Par contre, une nouveauté fait son apparition avec la segmentation : la '''gestion des droits d'accès'''. 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. 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.
L'implémentation de la protection mémoire dépend du CPU considéré. Mais en général, elle se repose sur 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. Les trois étapes sont réalisées en au moins une micro-opération chacune, souvent plus.
Les CPU microcodés peuvent en théorie utiliser le microcode pour tester si telle ou telle erreur survient. Il suffit que le microcode intègre des micro-branchements pour cela. Par exemple, une écriture va tester le bit R/W du descripteur, qui indique si on peut écrire dans le segment. Le micro-branchement enverra vers une routine du microcode en cas d'erreur. Mais les performances sont alors rarement au rendez-vous. La raison est que les tests de protection mémoire demandent de tester beaucoup de conditions différentes. Par exemple, le CPU Intel 386 avait 148 conditions distinctes à tester, mais heureusement pas 148 par instructions. En réalité, le processeur pouvait se trouver dans 33 situations différentes, chacune demandant de tester moins d'une dizaine de conditions différentes.
L'Intel 386 a donc utilisé une solution alternative : utiliser un circuit combinatoire pour faire les tests adéquats. Pour cela, il intégrait une '''''Protection Test Unit'''''. 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. Le tout était totalement séparé du microcode.
Le PLA recevait un descripteur de segment, lu depuis la mémoire RAM, ainsi qu'un code opération provenant du microcode. Le code opération disait quelles conditions il fallait tester, sur les 33 possibles (33 sur ce processeur, le nombre varie d'un CPU à l'autre). Le microcode vérifiait s'ils 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.
{|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 || C/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
|}
Le PLA testait plus d'une centaine de conditions, en parallèle, en comparant les bits d'entrées avec l'instruction demandée. Par exemple, il vérifiait si le bit R/W était cohérent avec le fait que l'instruction en cours d'exécution est une écriture. Il fournissait en sortie :
* Un bit qui : soit autorisait l'exécution de la lecture/écriture, soit levait une exception.
* Une adresse de 12 bits, pointant dans le microcode, sur un code levant une exception en cas d'erreur.
* 4 bits pouvant être testés par un branchement dans le microcode, qui demandaient :
** soit de modifier le bit A du descripteur de segment (pas d'erreur, on accède au segment) ;
** soit de tester s'il y a un accès hors-limite ;
** de signaler une instruction sur la pile.
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.
===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, les bits A et 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.
====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. A 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]]
A 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. A 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. A la place, l'entrée contient un '''bit ''global''''', qui précise que cette correspondance est valide pour tous les processus. Le bit global accélère rapidement la traduction d'adresse pour l'accès au noyau.
Un défaut de cette méthode est que le partage d'une page entre plusieurs processus est presque impossible. Impossible de partager une page avec seulement certains processus et pas d'autres : soit on partage une page avec tous les processus, soit on l'alloue avec un seul processus.
====L'usage de plusieurs tables des pages====
Une solution alternative, plus simple, utilise une table des pages par processus lancé sur l'ordinateur, une table des pages unique par espace d'adressage. À chaque changement de processus, le registre qui mémorise la position de la table des pages est modifié pour pointer sur la bonne. C'est le système d'exploitation qui se charge de cette mise à jour.
Avec cette méthode, il est possible de partager une ou plusieurs pages entre plusieurs processus, en configurant les tables des pages convenablement. Les pages partagées sont mappées dans l'espace d'adressage de plusieurs processus, mais pas forcément au même endroit, pas forcément dans les mêmes adresses logiques. On peut placer la page partagée à l'adresse logique 0x0FFF pour un processus, à l'adresse logique 0xFF00 pour un autre processus, etc. Par contre, les entrées de la table des pages pour ces adresses pointent vers la même adresse physique.
[[File:Vm5.png|centre|vignette|upright=2|Tables des pages de plusieurs processus.]]
===La taille des pages===
La taille des pages varie suivant le processeur et le système d'exploitation et tourne souvent autour de 4 kibioctets. Les processeurs actuels gèrent plusieurs tailles différentes pour les pages : 4 kibioctets par défaut, 2 mébioctets, voire 1 à 4 gibioctets pour les pages les plus larges. Les pages de 4 kibioctets sont les pages par défaut, les autres tailles de page sont appelées des ''pages larges''. La taille optimale pour les pages dépend de nombreux paramètres et il n'y a pas de taille qui convienne à tout le monde. Certaines applications gagnent à utiliser des pages larges, d'autres vont au contraire perdre drastiquement en performance en les utilisant.
Le désavantage principal des pages larges est qu'elles favorisent la fragmentation mémoire. Si un programme veut réserver une portion de mémoire, pour une structure de donnée quelconque, il doit réserver une portion dont la taille est multiple de la taille d'une page. Par exemple, un programme ayant besoin de 110 kibioctets allouera 28 pages de 4 kibioctets, soit 120 kibioctets : 2 kibioctets seront perdus. Par contre, avec des pages larges de 2 mébioctets, on aura une perte de 2048 - 110 = 1938 kibioctets. En somme, des morceaux de mémoire seront perdus, car les pages sont trop grandes pour les données qu'on veut y mettre. Le résultat est que le programme qui utilise les pages larges utilisent plus de mémoire et ce d'autant plus qu'il utilise des données de petite taille. Un autre désavantage est qu'elles se marient mal avec certaines techniques d'optimisations de type ''copy-on-write''.
Mais l'avantage est que la traduction des adresses est plus performante. Une taille des pages plus élevée signifie moins de pages, donc des tables des pages plus petites. Et des pages des tables plus petites n'ont pas besoin de beaucoup de niveaux de hiérarchie, voire peuvent se limiter à des tables des pages simples, ce qui rend la traduction d'adresse plus simple et plus rapide. De plus, les programmes ont une certaine localité spatiale, qui font qu'ils accèdent souvent à des données proches. La traduction d'adresse peut alors profiter de systèmes de mise en cache dont nous parlerons dans le prochain chapitre, et ces systèmes de cache marchent nettement mieux avec des pages larges.
Il faut noter que la taille des pages est presque toujours une puissance de deux. Cela a de nombreux avantages, mais n'est pas une nécessité. Par exemple, le tout premier processeur avec de la pagination, le super-ordinateur Atlas, avait des pages de 3 kibioctets. L'avantage principal est que la traduction de l'adresse physique en adresse logique est trivial avec une puissance de deux. Cela garantit que l'on peut diviser l'adresse en un numéro de page et un ''offset'' : la traduction demande juste de remplacer les bits de poids forts par le numéro de page voulu. Sans cela, la traduction d'adresse implique des divisions et des multiplications, qui sont des opérations assez couteuses.
===Les entrées de la table des pages===
Avant de poursuivre, faisons un rapide rappel sur les entrées de la table des pages. Nous venons de voir que la table des pages contient de nombreuses informations : un bit ''valid'' pour la mémoire virtuelle, des bits ''dirty'' et ''accessed'' utilisés par l'OS, des bits de protection mémoire, un bit ''global'' et un potentiellement un identifiant de processus, etc. Étudions rapidement le format de la table des pages sur un processeur x86 32 bits.
* Elle contient d'abord le numéro de page physique.
* Les bits AVL sont inutilisés et peuvent être configurés à loisir par l'OS.
* Le bit G est le bit ''global''.
* Le bit PS vaut 0 pour une page de 4 kibioctets, mais est mis à 1 pour une page de 4 mébioctets dans le cas où le processus utilise des pages larges.
* Le bit D est le bit ''dirty''.
* Le bit A est le bit ''accessed''.
* Le bit PCD indique que la page ne peut pas être cachée, dans le sens où le processeur ne peut copier son contenu dans le cache et doit toujours lire ou écrire cette page directement dans la RAM.
* Le bit PWT indique que les écritures doivent mettre à jour le cache et la page en RAM (dans le chapitre sur le cache, on verra qu'il force le cache à se comporter comme un cache ''write-through'' pour cette page).
* Le bit U/S précise si la page est accessible en mode noyau ou utilisateur.
* Le bit R/W indique si la page est accessible en écriture, toutes les pages sont par défaut accessibles en lecture.
* Le bit P est le bit ''valid''.
[[File:PDE.png|centre|vignette|upright=2.5|Table des pages des processeurs Intel 32 bits.]]
==Comparaison des différentes techniques d'abstraction mémoire==
Pour résumer, l'abstraction mémoire permet de gérer : la relocation, la protection mémoire, l'isolation des processus, la mémoire virtuelle, l'extension de l'espace d'adressage, le partage de mémoire, etc. Elles sont souvent implémentées en même temps. Ce qui fait qu'elles sont souvent confondues, alors que ce sont des concepts sont différents. Ces liens sont résumés dans le tableau ci-dessous.
{|class="wikitable"
|-
!
! colspan="5" | Avec abstraction mémoire
! rowspan="2" | Sans abstraction mémoire
|-
!
! Relocation matérielle
! Segmentation en mode réel (x86)
! Segmentation, général
! Architectures à capacités
! Pagination
|-
! Abstraction matérielle des processus
| colspan="4" | Oui, relocation matérielle
| Oui, liée à la traduction d'adresse
| Impossible
|-
! Mémoire virtuelle
| colspan="2" | Non, sauf émulation logicielle
| colspan="3" | Oui, gérée par le processeur et l'OS
| Non, sauf émulation logicielle
|-
! Extension de l'espace d'adressage
| colspan="2" | Oui : registre de base élargi
| colspan="2" | Oui : adresse de base élargie dans la table des segments
| ''Physical Adress Extension'' des processeurs 32 bits
| Commutation de banques
|-
! Protection mémoire
| Registre limite
| Aucune
| colspan="2" | Registre limite, droits d'accès aux segments
| Gestion des droits d'accès aux pages
| Possible, méthodes variées
|-
! Partage de mémoire
| colspan="2" | Non
| colspan="2" | Segment partagés
| Pages partagées
| Possible, méthodes variées
|}
===Les différents types de segmentation===
La segmentation regroupe plusieurs techniques franchement différentes, qui auraient gagné à être nommées différemment. La principale différence est l'usage de registres de relocation versus des registres de sélecteurs de segments. L'usage de registres de relocation est le fait de la relocation matérielle, mais aussi de la segmentation en mode réel des CPU x86. Par contre, l'usage de sélecteurs de segments est le fait des autres formes de segmentation, architectures à capacité inclues.
La différence entre les deux est le nombre de segments. L'usage de registres de relocation fait que le CPU ne gère qu'un petit nombre de segments de grande taille. La mémoire virtuelle est donc rarement implémentée vu que swapper des segments de grande taille est trop long, l'impact sur les performances est trop important. Sans compter que l'usage de registres de base se marie très mal avec la mémoire virtuelle. Vu qu'un segment peut être swappé ou déplacée n'importe quand, il faut invalider les registres de base au moment du swap/déplacement, ce qui n'est pas chose aisée. Aucun processeur ne gère cela, les méthodes pour n'existent tout simplement pas. L'usage de registres de base implique que la mémoire virtuelle est absente.
La protection mémoire est aussi plus limitée avec l'usage de registres de relocation. Elle se limite à des registres limite, mais la gestion des droits d'accès est limitée. En théorie, la segmentation en mode réel pourrait implémenter une version limitée de protection mémoire, avec une protection de l'espace exécutable. Mais ca n'a jamais été fait en pratique sur les processeurs x86.
Le partage de la mémoire est aussi difficile sur les architectures avec des registres de base. L'absence de table des segments fait que le partage d'un segment est basiquement impossible sans utiliser des méthodes complétement tordues, qui ne sont jamais implémentées en pratique.
===Segmentation versus pagination===
Par rapport à la pagination, la segmentation a des avantages et des inconvénients. Tous sont liés aux propriétés des segments et pages : les segments sont de grande taille et de taille variable, les pages sont petites et de taille fixe.
L'avantage principal de la segmentation est sa rapidité. Le fait que les segments sont de grande taille fait qu'on a pas besoin d'équivalent aux tables des pages inversée ou multiple, juste d'une table des segments toute simple. De plus, les échanges entre table des pages/segments et registres sont plus rares avec la segmentation. Par exemple, si un programme utilise un segment de 2 gigas, tous les accès dans le segment se feront avec une seule consultation de la table des segments. Alors qu'avec la pagination, il faudra une consultation de la table des pages chaque bloc de 4 kibioctet, au minimum.
Mais les désavantages sont nombreux. Le système d'exploitation doit agencer les segments en RAM, et c'est une tâche complexe. Le fait que les segments puisse changer de taille rend le tout encore plus complexe. Par exemple, si on colle les segments les uns à la suite des autres, changer la taille d'un segment demande de réorganiser tous les segments en RAM, ce qui demande énormément de copies RAM-RAM. Une autre possibilité est de laisser assez d'espace entre les segments, mais cet espace est alors gâché, dans le sens où on ne peut pas y placer un nouveau segment.
Swapper un segment est aussi très long, vu que les segments sont de grande taille, alors que swapper une page est très rapide.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'espace d'adressage du processeur
| prevText=L'espace d'adressage du processeur
| next=Les méthodes de synchronisation entre processeur et périphériques
| nextText=Les méthodes de synchronisation entre processeur et périphériques
}}
</noinclude>
fybdg47kfom8n4uppzvl9gdyiczl6e6
763689
763688
2026-04-14T21:59:37Z
Mewtow
31375
/* Le mode protégé des processeurs x86 */
763689
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 envoyées à 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 sachez qu'il existe 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 les adresses de l'espace d'adressage sont 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'abstraction mémoire implémente plusieurs fonctionnalités complémentaires==
L'utilité de l'abstraction matérielle n'est pas évidente, mais sachez qu'elle est si utile que tous les processeurs modernes la prennent en charge. Elle sert notamment à implémenter la mémoire virtuelle, que nous aborderons dans ce qui suit. La plupart de ces fonctionnalités manipulent la relation entre adresses logiques et physique. Dans le cas le plus simple, une adresse logique correspond à une seule adresse physique. Mais beaucoup de fonctionnalités avancées ne respectent pas cette règle.
===L'abstraction matérielle des processus===
Les systèmes d'exploitation modernes sont dits multi-tâche, à savoir qu'ils sont capables d'exécuter plusieurs logiciels en même temps. Et ce même si un seul processeur est présent dans l'ordinateur : les logiciels sont alors exécutés à tour de rôle. Toutefois, cela amène un paquet de problèmes qu'il faut résoudre au mieux. Par exemple, les programmes exécutés doivent se partager la mémoire RAM, ce qui ne vient pas sans problèmes. Le problème principal est que les programmes ne doivent pas lire ou écrire dans les données d'un autre, sans quoi on se retrouverait rapidement avec des problèmes. Il faut donc introduire des mécanismes d{{'}}'''isolement des processus''', pour isoler les programmes les uns des autres.
Un de ces mécanismes est l{{'}}'''abstraction matérielle des processus''', une technique qui fait que chaque programme a son propre espace d'adressage. Chaque programme a l'impression d'avoir accès à tout l'espace d'adressage, de l'adresse 0 à l'adresse maximale gérée par le processeur. Évidemment, il s'agit d'une illusion maintenue justement grâce à la traduction d'adresse. Les espaces d'adressage contiennent des adresses logiques, les adresses de la RAM sont des adresses physiques, la nécessité de l'abstraction mémoire est évidente.
Implémenter l'abstraction mémoire peut se faire de plusieurs manières. Mais dans tous les cas, il faut que la correspondance adresse logique - physique change d'un programme à l'autre. Ce qui est normal, vu que les deux processus sont placés à des endroits différents en RAM physique. La conséquence est qu'avec l'abstraction mémoire, une adresse logique correspond à plusieurs adresses physiques. Une même adresse logique dans deux processus différents correspond à deux adresses phsiques différentes, une par processus. Une adresse logique dans un processus correspondra à l'adresse physique X, la même adresse dans un autre processus correspondra à l'adresse Y.
Les adresses physiques qui partagent la même adresse logique sont alors appelées des '''adresses homonymes'''. Le choix de la bonne adresse étant réalisé par un mécanisme matériel et dépend du programme en cours. Le mécanisme pour choisir la bonne adresse dépend du processeur, mais il y en a deux grands types :
* La première consiste à utiliser l'identifiant de processus CPU, vu au chapitre précédent. C'est, pour rappel, un numéro attribué à chaque processus par le processeur. L'identifiant du processus en cours d'exécution est mémorisé dans un registre du processeur. La traduction d'adresse utilise cet identifiant, en plus de l'adresse logique, pour déterminer l'adresse physique.
* La seconde solution mémorise les correspondances adresses logiques-physique dans des tables en mémoire RAM, qui sont différentes pour chaque programme. Les tables sont accédées à chaque accès mémoire, afin de déterminer l'adresse physique.
===Le partage de la mémoire===
L'isolation des processus est très importante sur les systèmes d'exploitation modernes. Cependant, il existe quelques situations où elle doit être contournée ou du moins mise en pause. Les situations sont multiples : gestion de bibliothèques partagées, communication entre processus, usage de ''threads'', etc. Elles impliquent toutes un '''partage de mémoire''', à savoir qu'une portion de mémoire RAM est partagée entre plusieurs programmes. Le partage de mémoire est une sorte de brèche de l'isolation des processus, mais qui est autorisée car elle est utile.
Un cas intéressant est celui des '''bibliothèques partagées'''. Les bibliothèques sont des collections de fonctions regroupées ensemble, dans une seule unité de code. Un programme qui utilise une bibliothèque peut appeler n’importe quelle fonction présente dans la bibliothèque. La bibliothèque peut être simplement inclue dans le programme lui-même, on parle alors de bibliothèques statiques. De telles bibliothèques fonctionnent très bien, mais avec un petit défaut pour les bibliothèques très utilisées : plusieurs programmes qui utilisent la même bibliothèque vont chacun l'inclure dans leur code, ce qui fera doublon.
Pour éviter cela, les OS modernes gèrent des bibliothèques partagées, à savoir qu'un seul exemplaire de la bibliothèque est partagé entre plusieurs programmes. Chaque programme peut exécuter une fonction de la bibliothèque quand il le souhaite, en effectuant un branchement adéquat. Mais cela implique que la bibliothèque soit présente dans l'espace d'adressage du programme en question. Une bibliothèque est donc présente dans plusieurs espaces d'adressage, alors qu'il n'y en a qu'un seul exemplaire en mémoire RAM.
[[File:Ogg vorbis libs and application dia.svg|centre|vignette|upright=2|Exemple de bibliothèques, avec Ogg vorbis.]]
D'autres situations demandent de partager de la mémoire entre deux programmes. Par exemple, les systèmes d'exploitation modernes gèrent nativement des systèmes de '''communication inter-processus''', très utilisés par les programmes modernes pour échanger des données. Et la plupart demandant de partager un bout de mémoire entre processus, même si c'est seulement temporairement. Typiquement, deux processus partagent un intervalle d'adresse où l'un écrit les données à l'autre, l'autre lisant les données envoyées.
Une dernière utilisation de la mémoire partagée est l{{'}}'''accès direct au noyau'''. Sur les systèmes d'exploitations moderne, dans l'espace d'adressage de chaque programme, les adresses hautes sont remplies avec une partie du noyau ! Évidemment, ces adresses sont accessibles uniquement en lecture, pas en écriture. Pas question de modifier le noyau de l'OS ! De plus, il s'agit d'une portion du noyau dont on sait que la consultation ne pose pas de problèmes de sécurité.
Le programme peut lire des données dans cette portion du noyau, mais aussi exécuter les fonctions du noyau qui sont dedans. L'idée est d'éviter des appels systèmes trop fréquents. Au lieu d'effectuer un véritable appel système, avec une interruption logicielle, le programme peut exécuter des appels systèmes simplifiés, de simples appels de fonctions couplés avec un changement de niveau de privilège (passage en espace noyau nécessaire).
[[File:AMD64-canonical--48-bit.png|vignette|Répartition des adresses entre noyau (jaune/orange) et programme (verte), sur les systèmes x86-64 bits, avec des adresses physiques de 48 bits.]]
L'espace d'adressage est donc séparé en deux portions : l'OS d'un côté, le programme de l'autre. La répartition des adresses entre noyau et programme varie suivant l'OS ou le processeur utilisé. Sur les PC x86 32 bits, Linux attribuait 3 gigas pour les programmes et 1 giga pour le noyau, Windows attribuait 2 gigas à chacun. Sur les systèmes x86 64 bits, l'espace d'adressage d'un programme est coupé en trois, comme illustré ci-contre : une partie basse de 2^48 octets, une partie haute de même taille, et un bloc d'adresses invalides entre les deux. Les adresses basses sont utilisées pour le programme, les adresses hautes pour le noyau, il n'y a rien entre les deux.
Avec le partage de mémoire, plusieurs adresses logiques correspondent à la même adresse physique. Tel processus verra la zone de mémoire partagée à l'adresse X, l'autre la verra à l'adresse Y. Mais il s'agira de la même portion de mémoire physique, avec une seule adresse physique. En clair, lorsque deux processus partagent une même zone de mémoire, la zone sera mappées à des adresses logiques différentes. Les adresses logiques sont alors appelées des '''adresses synonymes''', terme qui trahit le fait qu'elles correspondent à la même adresse physique.
===La mémoire virtuelle===
Toutes les adresses ne sont pas forcément occupées par de la mémoire RAM, s'il n'y a pas assez de RAM installée. Par exemple, un processeur 32 bits peut adresser 4 gibioctets de RAM, même si seulement 3 gibioctets sont installés dans l'ordinateur. L'espace d'adressage contient donc 1 gigas d'adresses inutilisées, et il faut éviter ce surplus d'adresses pose problème.
Sans mémoire virtuelle, seule la mémoire réellement installée est utilisable. Si un programme utilise trop de mémoire, il est censé se rendre compte qu'il n'a pas accès à tout l'espace d'adressage. Quand il demandera au système d'exploitation de lui réserver de la mémoire, le système d'exploitation le préviendra qu'il n'y a plus de mémoire libre. Par exemple, si un programme tente d'utiliser 4 gibioctets sur un ordinateur avec 3 gibioctets de mémoire, il ne pourra pas. Pareil s'il veut utiliser 2 gibioctets de mémoire sur un ordinateur avec 4 gibioctets, mais dont 3 gibioctets sont déjà utilisés par d'autres programmes. Dans les deux cas, l'illusion tombe à plat.
Les techniques de '''mémoire virtuelle''' font que l'espace d'adressage est utilisable au complet, même s'il n'y a pas assez de mémoire installée dans l'ordinateur ou que d'autres programmes utilisent de la RAM. Par exemple, sur un processeur 32 bits, le programme aura accès à 4 gibioctets de RAM, même si d'autres programmes utilisent la RAM, même s'il n'y a que 2 gibioctets de RAM d'installés dans l'ordinateur.
Pour cela, on utilise une partie des mémoires de masse (disques durs) d'un ordinateur en remplacement de la mémoire physique manquante. Le système d'exploitation crée sur le disque dur un fichier, appelé le ''swapfile'' ou '''fichier de ''swap''''', qui est utilisé comme mémoire RAM supplémentaire. Il mémorise le surplus de données et de programmes qui ne peut pas être mis en mémoire RAM.
[[File:Vm1.png|centre|vignette|upright=2.0|Mémoire virtuelle et fichier de Swap.]]
Une technique naïve de mémoire virtuelle serait la suivante. Avant de l'aborder, précisons qu'il s'agit d'une technique abordée à but pédagogique, mais qui n'est implémentée nulle part tellement elle est lente et inefficace. Un espace d'adressage de 4 gigas ne contient que 3 gigas de RAM, ce qui fait 1 giga d'adresses inutilisées. Les accès mémoire aux 3 gigas de RAM se font normalement, mais l'accès aux adresses inutilisées lève une exception matérielle "Memory Unavailable". La routine d'interruption de cette exception accède alors au ''swapfile'' et récupère les données associées à cette adresse. La mémoire virtuelle est alors émulée par le système d'exploitation.
Le défaut de cette méthode est que l'accès au giga manquant est toujours très lent, parce qu'il se fait depuis le disque dur. D'autres techniques de mémoire virtuelle logicielle font beaucoup mieux, mais nous allons les passer sous silence, vu qu'on peut faire mieux, avec l'aide du matériel.
L'idée est de charger les données dont le programme a besoin dans la RAM, et de déplacer les autres sur le disque dur. Par exemple, imaginons la situation suivante : un programme a besoin de 4 gigas de mémoire, mais ne dispose que de 2 gigas de mémoire installée. On peut imaginer découper l'espace d'adressage en 2 blocs de 2 gigas, qui sont chargés à la demande. Si le programme accède aux adresses basses, on charge les 2 gigas d'adresse basse en RAM. S'il accède aux adresses hautes, on charge les 2 gigas d'adresse haute dans la RAM après avoir copié les adresses basses sur le ''swapfile''.
On perd du temps dans les copies de données entre RAM et ''swapfile'', mais on gagne en performance vu que tous les accès mémoire se font en RAM. Du fait de la localité temporelle, le programme utilise les données chargées depuis le swapfile durant un bon moment avant de passer au bloc suivant. La RAM est alors utilisée comme une sorte de cache alors que les données sont placées dans une mémoire fictive représentée par l'espace d'adressage et qui correspond au disque dur.
Mais avec cette technique, la correspondance entre adresses du programme et adresses de la RAM change au cours du temps. Les adresses de la RAM correspondent d'abord aux adresses basses, puis aux adresses hautes, et ainsi de suite. On a donc besoin d'abstraction mémoire. Les correspondances entre adresse logique et physique peuvent varier avec le temps, ce qui permet de déplacer des données de la RAM vers le disque dur ou inversement. Une adresse logique peut correspondre à une adresse physique, ou bien à une donnée swappée sur le disque dur. C'est l'unité de traduction d'adresse qui se charge de faire la différence. Si une correspondance entre adresse logique et physique est trouvée, elle l'utilise pour traduire les adresses. Si aucune correspondance n'est trouvée, alors elle laisse la main au système d'exploitation pour charger la donnée en RAM. Une fois la donnée chargée en RAM, les correspondances entre adresse logique et physiques sont modifiées de manière à ce que l'adresse logique pointe vers la donnée chargée.
===L'extension d'adressage===
Une autre fonctionnalité rendue possible par l'abstraction mémoire est l{{'}}'''extension d'adressage'''. Elle permet d'utiliser plus de mémoire que l'espace d'adressage ne le permet. Par exemple, utiliser 7 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'extension d'adresse est l'exact inverse de la mémoire virtuelle. La mémoire virtuelle sert quand on a moins de mémoire que d'adresses, l'extension d'adresse sert quand on a plus de mémoire que d'adresses.
Il y a quelques chapitres, nous avions vu que c'est possible via la commutation de banques. Mais l'abstraction mémoire est une méthode alternative. Que ce soit avec la commutation de banques ou avec l'abstraction mémoire, les adresses envoyées à la mémoire doivent être plus longues que les adresses gérées par le processeur. La différence est que l'abstraction mémoire étend les adresses d'une manière différente.
Une implémentation possible de l'extension d'adressage fait usage de l'abstraction matérielle des processus. Chaque processus a son propre espace d'adressage, mais ceux-ci sont placés à des endroits différents dans la mémoire physique. Par exemple, sur un ordinateur avec 16 gigas de RAM, mais un espace d'adressage de 2 gigas, on peut remplir la RAM en lançant 8 processus différents et chaque processus aura accès à un bloc de 2 gigas de RAM, pas plus, il ne peut pas dépasser cette limite. Ainsi, chaque processus est limité par son espace d'adressage, mais on remplit la mémoire avec plusieurs processus, ce qui compense. Il s'agit là de l'implémentation la plus simple, qui a en plus l'avantage d'avoir la meilleure compatibilité logicielle. De simples changements dans le système d'exploitation suffisent à l'implémenter.
[[File:Extension de l'espace d'adressage.png|centre|vignette|upright=1.5|Extension de l'espace d'adressage]]
Un autre implémentation donne plusieurs espaces d'adressage différents à chaque processus, et a donc accès à autant de mémoire que permis par la somme de ces espaces d'adressage. Par exemple, sur un ordinateur avec 16 gigas de RAM et un espace d'adressage de 4 gigas, un programme peut utiliser toute la RAM en utilisant 4 espaces d'adressage distincts. On passe d'un espace d'adressage à l'autre en changeant la correspondance adresse logique-physique. L'inconvénient est que la compatibilité logicielle est assez mauvaise. Modifier l'OS ne suffit pas, les programmeurs doivent impérativement concevoir leurs programmes pour qu'ils utilisent explicitement plusieurs espaces d'adressage.
Les deux implémentations font usage des adresses logiques homonymes, mais à l'intérieur d'un même processus. Pour rappel, cela veut dire qu'une adresse logique correspond à des adresses physiques différentes. Rien d'étonnant vu qu'on utilise plusieurs espaces d'adressage, comme pour l'abstraction des processus, sauf que cette fois-ci, on a plusieurs espaces d'adressage par processus. Prenons l'exemple où on a 8 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'idée est qu'une adresse correspondra à une adresse dans les premiers 4 gigas, ou dans les seconds 4 gigas. L'adresse logique X correspondra d'abord à une adresse physique dans les premiers 4 gigas, puis à une adresse physique dans les seconds 4 gigas.
===La protection mémoire===
La '''protection mémoire''' regroupe des techniques très différentes les unes des autres, qui visent à améliorer la sécurité des programmes et des systèmes d'exploitation. Elles visent à empêcher de lire, d'écrire ou d'exécuter certaines portions de mémoire. Sans elle, les programmes peuvent techniquement lire ou écrire les données des autres, ce qui causent des situations non-prévues par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses.
La première technique de protection mémoire est l{{'}}'''isolation des processus''', qu'on a vue plus haut. Elle garantit que chaque programme n'a accès qu'à certaines portions dédiées de la mémoire et rend le reste de la mémoire inaccessible en lecture et en écriture. Le système d'exploitation attribue à chaque programme une ou plusieurs portions de mémoire rien que pour lui, auquel aucun autre programme ne peut accéder. Un tel programme, isolé des autres, s'appelle un '''processus''', d'où le nom de cet objectif. Toute tentative d'accès à une partie de la mémoire non autorisée déclenche une exception matérielle (rappelez-vous le chapitre sur les interruptions) qui est traitée par une routine du système d'exploitation. Généralement, le programme fautif est sauvagement arrêté et un message d'erreur est affiché à l'écran.
La '''protection de l'espace exécutable''' empêche d’exécuter quoique ce soit provenant de certaines zones de la mémoire. En effet, certaines portions de la mémoire sont censées contenir uniquement des données, sans aucun programme ou code exécutable. Cependant, des virus informatiques peuvent se cacher dedans et d’exécuter depuis celles-ci. Ou encore, des failles de sécurités peuvent permettre à un attaquant d'injecter du code exécutable malicieux dans des données, ce qui peut lui permettre de lire les données manipulées par un programme, prendre le contrôle de la machine, injecter des virus, ou autre. Pour éviter cela, le système d'exploitation peut marquer certaines zones mémoire comme n'étant pas exécutable. Toute tentative d’exécuter du code localisé dans ces zones entraîne la levée d'une exception ou d'une erreur et le système d'exploitation réagit en conséquence. Là encore, le processeur doit détecter les exécutions non autorisées.
D'autres méthodes de protection mémoire visent à limiter des actions dangereuses. Pour cela, le processeur et l'OS gèrent des '''droits d'accès''', qui interdisent certaines actions pour des programmes non-autorisés. Lorsqu'on exécute une opération interdite, le système d’exploitation et/ou le processeur réagissent en conséquence. La première technique de ce genre n'est autre que la séparation entre espace noyau et utilisateur, vue dans le chapitre sur les interruptions. Mais il y en a d'autres, comme nous le verrons dans ce chapitre.
==La MMU==
La traduction des adresses logiques en adresses physiques se fait par un circuit spécialisé appelé la '''''Memory Management Unit''''' (MMU), qui est souvent intégré directement dans l'interface mémoire. La MMU est souvent associée à une ou plusieurs mémoires caches, qui visent à accélérer la traduction d'adresses logiques en adresses physiques. En effet, nous verrons plus bas que la traduction d'adresse demande d'accéder à des tableaux, gérés par le système d'exploitation, qui sont en mémoire RAM. Aussi, les processeurs modernes incorporent des mémoires caches appelées des '''''Translation Lookaside Buffers''''', ou encore TLB. Nous nous pouvons pas parler des TLB pour le moment, car nous n'avons pas encore abordé le chapitre sur les mémoires caches, mais un chapitre entier sera dédié aux TLB d'ici peu.
[[File:MMU principle updated.png|centre|vignette|upright=2|MMU.]]
===Les MMU intégrées au processeur===
D'ordinaire, la MMU est intégrée au processeur. Et elle peut l'être de deux manières. La première en fait un circuit séparé, relié au bus d'adresse. La seconde fusionne la MMU avec l'unité de calcul d'adresse. La première solution est surtout utilisée avec une technique d'abstraction mémoire appelée la pagination, alors que l'autre l'est avec une autre méthode appelée la segmentation. La raison est que la traduction d'adresse avec la segmentation est assez simple : elle demande d'additionner le contenu d'un registre avec l'adresse logique, ce qui est le genre de calcul qu'une unité de calcul d'adresse sait déjà faire. La fusion est donc assez évidente.
Pour donner un exemple, l'Intel 8086 fusionnait l'unité de calcul d'adresse et la MMU. Précisément, il utilisait un même additionneur pour incrémenter le ''program counter'' et effectuer des calculs d'adresse liés à la segmentation. Il aurait été logique d'ajouter les pointeurs de pile avec, mais ce n'était pas possible. La raison est que le pointeur de pile ne peut pas être envoyé directement sur le bus d'adresse, vu qu'il doit passer par une phase de traduction en adresse physique liée à la segmentation.
[[File:80186 arch.png|centre|vignette|upright=2|Intel 8086, microarchitecture.]]
===Les MMU séparées du processeur, sur la carte mère===
Il a existé des processeurs avec une MMU externe, soudée sur la carte mère.
Par exemple, les processeurs Motorola 68000 et 68010 pouvaient être combinés avec une MMU de type Motorola 68451. Elle supportait des versions simplifiées de la segmentation et de la pagination. Au minimum, elle ajoutait un support de la protection mémoire contre certains accès non-autorisés. La gestion de la mémoire virtuelle proprement dit n'était possible que si le processeur utilisé était un Motorola 68010, en raison de la manière dont le 68000 gérait ses accès mémoire. La MMU 68451 gérait un espace d'adressage de 16 mébioctets, découpé en maximum 32 pages/segments. On pouvait dépasser cette limite de 32 segments/pages en combinant plusieurs 68451.
Le Motorola 68851 était une MMU qui était prévue pour fonctionner de paire avec le Motorola 68020. Elle gérait la pagination pour un espace d'adressage de 32 bits.
Les processeurs suivants, les 68030, 68040, et 68060, avaient une MMU interne au processeur.
==La relocation matérielle==
Pour rappel, les systèmes d'exploitation moderne permettent de lancer plusieurs programmes en même temps et les laissent se partager la mémoire. Dans le cas le plus simple, qui n'est pas celui des OS modernes, le système d'exploitation découpe la mémoire en blocs d'adresses contiguës qui sont appelés des '''segments''', ou encore des ''partitions mémoire''. Les segments correspondent à un bloc de mémoire RAM. C'est-à-dire qu'un segment de 259 mébioctets sera un segment continu de 259 mébioctets dans la mémoire physique comme dans la mémoire logique. Dans ce qui suit, un segment contient un programme en cours d'exécution, comme illustré ci-dessous.
[[File:CPT Memory Addressable.svg|centre|vignette|upright=2|Espace d'adressage segmenté.]]
Le système d'exploitation mémorise la position de chaque segment en mémoire, ainsi que d'autres informations annexes. Le tout est regroupé dans la '''table de segment''', un tableau dont chaque case est attribuée à un programme/segment. La table des segments est un tableau numéroté, chaque segment ayant un numéro qui précise sa position dans le tableau. Chaque case, chaque entrée, contient un '''descripteur de segment''' qui regroupe plusieurs informations sur le segment : son adresse de base, sa taille, diverses informations.
===La relocation avec la relocation matérielle : le registre de base===
Un segment peut être placé n'importe où en RAM physique et sa position en RAM change à chaque exécution. Le programme est chargé à une adresse, celle du début du segment, qui change à chaque chargement du programme. Et toutes les adresses utilisées par le programme doivent être corrigées lors du chargement du programme, généralement par l'OS. Cette correction s'appelle la '''relocation''', et elle consiste à ajouter l'adresse de début du segment à chaque adresse manipulée par le programme.
[[File:Relocation assistée par matériel.png|centre|vignette|upright=2.5|Relocation.]]
La relocation matérielle fait que la relocation est faite par le processeur, pas par l'OS. La relocation est intégrée dans le processeur par l'intégration d'un registre : le '''registre de base''', aussi appelé '''registre de relocation'''. Il mémorise l'adresse à laquelle commence le segment, la première adresse du programme. Pour effectuer la relocation, le processeur ajoute automatiquement l'adresse de base à chaque accès mémoire, en allant la chercher dans le registre de relocation.
[[File:Registre de base de segment.png|centre|vignette|upright=2|Registre de base de segment.]]
Le processeur s'occupe de la relocation des segments et le programme compilé n'en voit rien. Pour le dire autrement, les programmes manipulent des adresses logiques, qui sont traduites par le processeur en adresses physiques. La traduction se fait en ajoutant le contenu du registre de relocation à l'adresse logique. De plus, cette méthode fait que chaque programme a son propre espace d'adressage.
[[File:CPU created logical address presentation.png|centre|vignette|upright=2|Traduction d'adresse avec la relocation matérielle.]]
Le système d'exploitation mémorise les adresses de base pour chaque programme, dans la table des segments. Le registre de base est mis à jour automatiquement lors de chaque changement de segment. Pour cela, le registre de base est accessible via certaines instructions, accessibles en espace noyau, plus rarement en espace utilisateur. Le registre de segment est censé être adressé implicitement, vu qu'il est unique. Si ce n'est pas le cas, il est possible d'écrire dans ce registre de segment, qui est alors adressable.
===La protection mémoire avec la relocation matérielle : le registre limite===
Sans restrictions supplémentaires, la taille maximale d'un segment est égale à la taille complète de l'espace d'adressage. Sur les processeurs 32 bits, un segment a une taille maximale de 2^32 octets, soit 4 gibioctets. Mais il est possible de limiter la taille du segment à 2 gibioctets, 1 gibioctet, 64 Kibioctets, ou toute autre taille. La limite est définie lors de la création du segment, mais elle peut cependant évoluer au cours de l'exécution du programme, grâce à l'allocation mémoire. Le processeur vérifie à chaque accès mémoire que celui-ci se fait bien dans le segment, en comparant l'adresse accédée à l'adresse de base et l'adresse maximale, l'adresse limite.
Limiter la taille d'un segment demande soit de mémoriser sa taille, soit de mémoriser l'adresse limite (l'adresse de fin de segment, l'adresse limite à ne pas dépasser). Les deux sont possibles et marchent parfaitement, le choix entre les deux solutions est une pure question de préférence. A la rigueur, la vérification des débordements est légèrement plus rapide si on utilise l'adresse de fin du segment. Précisons que l'adresse limite est une adresse logique, le segment commence toujours à l'adresse logique zéro.
Pour cela, la table des segments doit être modifiée. Au lieu de ne contenir que l'adresse de base, elle contient soit l'adresse maximale du segment, soit la taille du segment. En clair, le descripteur de segment est enrichi avec l'adresse limite. 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.
Techniquement, il y a une petite différence de vitesse entre utiliser la taille et l'adresse maximale. Vérifier les débordements avec la taille demande juste de comparer la taille avec l'adresse logique, avant relocation, ce qui peut être fait en parallèle de la relocation. Par contre, l'adresse limite est comparée à une adresse physique, ce qui demande de faire la relocation avant la vérification, ce qui prend un peu plus de temps. Mais l'impact sur les performances est des plus mineurs.
[[File:Registre limite.png|centre|vignette|upright=2|Registre limite]]
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> </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>+ </code><code style="background:#DDF">0001 0010 0011 0100</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </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. Au lieu d'utiliser un additionneur séparé pour le ''program counter'' et un autre pour le calcul de l'adresse physique, un seul additionneur est utilisé pour les deux. L'idée était de partager l'additionneur, qui servait à la fois à incrémenter le ''program counter'' et pour gérer la segmentation. 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|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> </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>+ </code><code style="background:#DDF">1111 1111 1111 1111</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </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 de nos PCs, à partir du 286 d'Intel. 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. A 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. A la place, les segments sont adressés de manière indirecte. A 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.
===La protection mémoire : les accès hors-segments===
Comme avec la relocation matérielle, le processeur utilise l'adresse ou la taille limite pour vérifier si l'accès mémoire ne déborde pas en-dehors du segment en cours. Pour cela, le processeur compare l'adresse logique accédée avec l'adresse limite, ou compare la taille limite avec le décalage. L'information est lue depuis la table des segments à chaque accès.
[[File:Vm7.svg|centre|vignette|upright=2|Traduction d'adresse avec vérification des accès hors-segment.]]
Par contre, une nouveauté fait son apparition avec la segmentation : la '''gestion des droits d'accès'''. 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. 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.
L'implémentation de la protection mémoire dépend du CPU considéré. Mais en général, elle se repose sur 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. Les trois étapes sont réalisées en au moins une micro-opération chacune, souvent plus.
Les CPU microcodés peuvent en théorie utiliser le microcode pour tester si telle ou telle erreur survient. Il suffit que le microcode intègre des micro-branchements pour cela. Par exemple, une écriture va tester le bit R/W du descripteur, qui indique si on peut écrire dans le segment. Le micro-branchement enverra vers une routine du microcode en cas d'erreur. Mais les performances sont alors rarement au rendez-vous. La raison est que les tests de protection mémoire demandent de tester beaucoup de conditions différentes. Par exemple, le CPU Intel 386 avait 148 conditions distinctes à tester, mais heureusement pas 148 par instructions. En réalité, le processeur pouvait se trouver dans 33 situations différentes, chacune demandant de tester moins d'une dizaine de conditions différentes.
L'Intel 386 a donc utilisé une solution alternative : utiliser un circuit combinatoire pour faire les tests adéquats. Pour cela, il intégrait une '''''Protection Test Unit'''''. 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. Le tout était totalement séparé du microcode.
Le PLA recevait un descripteur de segment, lu depuis la mémoire RAM, ainsi qu'un code opération provenant du microcode. Le code opération disait quelles conditions il fallait tester, sur les 33 possibles (33 sur ce processeur, le nombre varie d'un CPU à l'autre). Le microcode vérifiait s'ils 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.
{|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 || C/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
|}
Le PLA testait plus d'une centaine de conditions, en parallèle, en comparant les bits d'entrées avec l'instruction demandée. Par exemple, il vérifiait si le bit R/W était cohérent avec le fait que l'instruction en cours d'exécution est une écriture. Il fournissait en sortie :
* Un bit qui : soit autorisait l'exécution de la lecture/écriture, soit levait une exception.
* Une adresse de 12 bits, pointant dans le microcode, sur un code levant une exception en cas d'erreur.
* 4 bits pouvant être testés par un branchement dans le microcode, qui demandaient :
** soit de modifier le bit A du descripteur de segment (pas d'erreur, on accède au segment) ;
** soit de tester s'il y a un accès hors-limite ;
** de signaler une instruction sur la pile.
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.
===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.
====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. A 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]]
A 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. A 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. A la place, l'entrée contient un '''bit ''global''''', qui précise que cette correspondance est valide pour tous les processus. Le bit global accélère rapidement la traduction d'adresse pour l'accès au noyau.
Un défaut de cette méthode est que le partage d'une page entre plusieurs processus est presque impossible. Impossible de partager une page avec seulement certains processus et pas d'autres : soit on partage une page avec tous les processus, soit on l'alloue avec un seul processus.
====L'usage de plusieurs tables des pages====
Une solution alternative, plus simple, utilise une table des pages par processus lancé sur l'ordinateur, une table des pages unique par espace d'adressage. À chaque changement de processus, le registre qui mémorise la position de la table des pages est modifié pour pointer sur la bonne. C'est le système d'exploitation qui se charge de cette mise à jour.
Avec cette méthode, il est possible de partager une ou plusieurs pages entre plusieurs processus, en configurant les tables des pages convenablement. Les pages partagées sont mappées dans l'espace d'adressage de plusieurs processus, mais pas forcément au même endroit, pas forcément dans les mêmes adresses logiques. On peut placer la page partagée à l'adresse logique 0x0FFF pour un processus, à l'adresse logique 0xFF00 pour un autre processus, etc. Par contre, les entrées de la table des pages pour ces adresses pointent vers la même adresse physique.
[[File:Vm5.png|centre|vignette|upright=2|Tables des pages de plusieurs processus.]]
===La taille des pages===
La taille des pages varie suivant le processeur et le système d'exploitation et tourne souvent autour de 4 kibioctets. Les processeurs actuels gèrent plusieurs tailles différentes pour les pages : 4 kibioctets par défaut, 2 mébioctets, voire 1 à 4 gibioctets pour les pages les plus larges. Les pages de 4 kibioctets sont les pages par défaut, les autres tailles de page sont appelées des ''pages larges''. La taille optimale pour les pages dépend de nombreux paramètres et il n'y a pas de taille qui convienne à tout le monde. Certaines applications gagnent à utiliser des pages larges, d'autres vont au contraire perdre drastiquement en performance en les utilisant.
Le désavantage principal des pages larges est qu'elles favorisent la fragmentation mémoire. Si un programme veut réserver une portion de mémoire, pour une structure de donnée quelconque, il doit réserver une portion dont la taille est multiple de la taille d'une page. Par exemple, un programme ayant besoin de 110 kibioctets allouera 28 pages de 4 kibioctets, soit 120 kibioctets : 2 kibioctets seront perdus. Par contre, avec des pages larges de 2 mébioctets, on aura une perte de 2048 - 110 = 1938 kibioctets. En somme, des morceaux de mémoire seront perdus, car les pages sont trop grandes pour les données qu'on veut y mettre. Le résultat est que le programme qui utilise les pages larges utilisent plus de mémoire et ce d'autant plus qu'il utilise des données de petite taille. Un autre désavantage est qu'elles se marient mal avec certaines techniques d'optimisations de type ''copy-on-write''.
Mais l'avantage est que la traduction des adresses est plus performante. Une taille des pages plus élevée signifie moins de pages, donc des tables des pages plus petites. Et des pages des tables plus petites n'ont pas besoin de beaucoup de niveaux de hiérarchie, voire peuvent se limiter à des tables des pages simples, ce qui rend la traduction d'adresse plus simple et plus rapide. De plus, les programmes ont une certaine localité spatiale, qui font qu'ils accèdent souvent à des données proches. La traduction d'adresse peut alors profiter de systèmes de mise en cache dont nous parlerons dans le prochain chapitre, et ces systèmes de cache marchent nettement mieux avec des pages larges.
Il faut noter que la taille des pages est presque toujours une puissance de deux. Cela a de nombreux avantages, mais n'est pas une nécessité. Par exemple, le tout premier processeur avec de la pagination, le super-ordinateur Atlas, avait des pages de 3 kibioctets. L'avantage principal est que la traduction de l'adresse physique en adresse logique est trivial avec une puissance de deux. Cela garantit que l'on peut diviser l'adresse en un numéro de page et un ''offset'' : la traduction demande juste de remplacer les bits de poids forts par le numéro de page voulu. Sans cela, la traduction d'adresse implique des divisions et des multiplications, qui sont des opérations assez couteuses.
===Les entrées de la table des pages===
Avant de poursuivre, faisons un rapide rappel sur les entrées de la table des pages. Nous venons de voir que la table des pages contient de nombreuses informations : un bit ''valid'' pour la mémoire virtuelle, des bits ''dirty'' et ''accessed'' utilisés par l'OS, des bits de protection mémoire, un bit ''global'' et un potentiellement un identifiant de processus, etc. Étudions rapidement le format de la table des pages sur un processeur x86 32 bits.
* Elle contient d'abord le numéro de page physique.
* Les bits AVL sont inutilisés et peuvent être configurés à loisir par l'OS.
* Le bit G est le bit ''global''.
* Le bit PS vaut 0 pour une page de 4 kibioctets, mais est mis à 1 pour une page de 4 mébioctets dans le cas où le processus utilise des pages larges.
* Le bit D est le bit ''dirty''.
* Le bit A est le bit ''accessed''.
* Le bit PCD indique que la page ne peut pas être cachée, dans le sens où le processeur ne peut copier son contenu dans le cache et doit toujours lire ou écrire cette page directement dans la RAM.
* Le bit PWT indique que les écritures doivent mettre à jour le cache et la page en RAM (dans le chapitre sur le cache, on verra qu'il force le cache à se comporter comme un cache ''write-through'' pour cette page).
* Le bit U/S précise si la page est accessible en mode noyau ou utilisateur.
* Le bit R/W indique si la page est accessible en écriture, toutes les pages sont par défaut accessibles en lecture.
* Le bit P est le bit ''valid''.
[[File:PDE.png|centre|vignette|upright=2.5|Table des pages des processeurs Intel 32 bits.]]
==Comparaison des différentes techniques d'abstraction mémoire==
Pour résumer, l'abstraction mémoire permet de gérer : la relocation, la protection mémoire, l'isolation des processus, la mémoire virtuelle, l'extension de l'espace d'adressage, le partage de mémoire, etc. Elles sont souvent implémentées en même temps. Ce qui fait qu'elles sont souvent confondues, alors que ce sont des concepts sont différents. Ces liens sont résumés dans le tableau ci-dessous.
{|class="wikitable"
|-
!
! colspan="5" | Avec abstraction mémoire
! rowspan="2" | Sans abstraction mémoire
|-
!
! Relocation matérielle
! Segmentation en mode réel (x86)
! Segmentation, général
! Architectures à capacités
! Pagination
|-
! Abstraction matérielle des processus
| colspan="4" | Oui, relocation matérielle
| Oui, liée à la traduction d'adresse
| Impossible
|-
! Mémoire virtuelle
| colspan="2" | Non, sauf émulation logicielle
| colspan="3" | Oui, gérée par le processeur et l'OS
| Non, sauf émulation logicielle
|-
! Extension de l'espace d'adressage
| colspan="2" | Oui : registre de base élargi
| colspan="2" | Oui : adresse de base élargie dans la table des segments
| ''Physical Adress Extension'' des processeurs 32 bits
| Commutation de banques
|-
! Protection mémoire
| Registre limite
| Aucune
| colspan="2" | Registre limite, droits d'accès aux segments
| Gestion des droits d'accès aux pages
| Possible, méthodes variées
|-
! Partage de mémoire
| colspan="2" | Non
| colspan="2" | Segment partagés
| Pages partagées
| Possible, méthodes variées
|}
===Les différents types de segmentation===
La segmentation regroupe plusieurs techniques franchement différentes, qui auraient gagné à être nommées différemment. La principale différence est l'usage de registres de relocation versus des registres de sélecteurs de segments. L'usage de registres de relocation est le fait de la relocation matérielle, mais aussi de la segmentation en mode réel des CPU x86. Par contre, l'usage de sélecteurs de segments est le fait des autres formes de segmentation, architectures à capacité inclues.
La différence entre les deux est le nombre de segments. L'usage de registres de relocation fait que le CPU ne gère qu'un petit nombre de segments de grande taille. La mémoire virtuelle est donc rarement implémentée vu que swapper des segments de grande taille est trop long, l'impact sur les performances est trop important. Sans compter que l'usage de registres de base se marie très mal avec la mémoire virtuelle. Vu qu'un segment peut être swappé ou déplacée n'importe quand, il faut invalider les registres de base au moment du swap/déplacement, ce qui n'est pas chose aisée. Aucun processeur ne gère cela, les méthodes pour n'existent tout simplement pas. L'usage de registres de base implique que la mémoire virtuelle est absente.
La protection mémoire est aussi plus limitée avec l'usage de registres de relocation. Elle se limite à des registres limite, mais la gestion des droits d'accès est limitée. En théorie, la segmentation en mode réel pourrait implémenter une version limitée de protection mémoire, avec une protection de l'espace exécutable. Mais ca n'a jamais été fait en pratique sur les processeurs x86.
Le partage de la mémoire est aussi difficile sur les architectures avec des registres de base. L'absence de table des segments fait que le partage d'un segment est basiquement impossible sans utiliser des méthodes complétement tordues, qui ne sont jamais implémentées en pratique.
===Segmentation versus pagination===
Par rapport à la pagination, la segmentation a des avantages et des inconvénients. Tous sont liés aux propriétés des segments et pages : les segments sont de grande taille et de taille variable, les pages sont petites et de taille fixe.
L'avantage principal de la segmentation est sa rapidité. Le fait que les segments sont de grande taille fait qu'on a pas besoin d'équivalent aux tables des pages inversée ou multiple, juste d'une table des segments toute simple. De plus, les échanges entre table des pages/segments et registres sont plus rares avec la segmentation. Par exemple, si un programme utilise un segment de 2 gigas, tous les accès dans le segment se feront avec une seule consultation de la table des segments. Alors qu'avec la pagination, il faudra une consultation de la table des pages chaque bloc de 4 kibioctet, au minimum.
Mais les désavantages sont nombreux. Le système d'exploitation doit agencer les segments en RAM, et c'est une tâche complexe. Le fait que les segments puisse changer de taille rend le tout encore plus complexe. Par exemple, si on colle les segments les uns à la suite des autres, changer la taille d'un segment demande de réorganiser tous les segments en RAM, ce qui demande énormément de copies RAM-RAM. Une autre possibilité est de laisser assez d'espace entre les segments, mais cet espace est alors gâché, dans le sens où on ne peut pas y placer un nouveau segment.
Swapper un segment est aussi très long, vu que les segments sont de grande taille, alors que swapper une page est très rapide.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'espace d'adressage du processeur
| prevText=L'espace d'adressage du processeur
| next=Les méthodes de synchronisation entre processeur et périphériques
| nextText=Les méthodes de synchronisation entre processeur et périphériques
}}
</noinclude>
gjhd5ifauvzo63xdkbsekx5mz8ig303
763690
763689
2026-04-14T22:09:18Z
Mewtow
31375
/* La segmentation avec une table des segments */
763690
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 envoyées à 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 sachez qu'il existe 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 les adresses de l'espace d'adressage sont 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'abstraction mémoire implémente plusieurs fonctionnalités complémentaires==
L'utilité de l'abstraction matérielle n'est pas évidente, mais sachez qu'elle est si utile que tous les processeurs modernes la prennent en charge. Elle sert notamment à implémenter la mémoire virtuelle, que nous aborderons dans ce qui suit. La plupart de ces fonctionnalités manipulent la relation entre adresses logiques et physique. Dans le cas le plus simple, une adresse logique correspond à une seule adresse physique. Mais beaucoup de fonctionnalités avancées ne respectent pas cette règle.
===L'abstraction matérielle des processus===
Les systèmes d'exploitation modernes sont dits multi-tâche, à savoir qu'ils sont capables d'exécuter plusieurs logiciels en même temps. Et ce même si un seul processeur est présent dans l'ordinateur : les logiciels sont alors exécutés à tour de rôle. Toutefois, cela amène un paquet de problèmes qu'il faut résoudre au mieux. Par exemple, les programmes exécutés doivent se partager la mémoire RAM, ce qui ne vient pas sans problèmes. Le problème principal est que les programmes ne doivent pas lire ou écrire dans les données d'un autre, sans quoi on se retrouverait rapidement avec des problèmes. Il faut donc introduire des mécanismes d{{'}}'''isolement des processus''', pour isoler les programmes les uns des autres.
Un de ces mécanismes est l{{'}}'''abstraction matérielle des processus''', une technique qui fait que chaque programme a son propre espace d'adressage. Chaque programme a l'impression d'avoir accès à tout l'espace d'adressage, de l'adresse 0 à l'adresse maximale gérée par le processeur. Évidemment, il s'agit d'une illusion maintenue justement grâce à la traduction d'adresse. Les espaces d'adressage contiennent des adresses logiques, les adresses de la RAM sont des adresses physiques, la nécessité de l'abstraction mémoire est évidente.
Implémenter l'abstraction mémoire peut se faire de plusieurs manières. Mais dans tous les cas, il faut que la correspondance adresse logique - physique change d'un programme à l'autre. Ce qui est normal, vu que les deux processus sont placés à des endroits différents en RAM physique. La conséquence est qu'avec l'abstraction mémoire, une adresse logique correspond à plusieurs adresses physiques. Une même adresse logique dans deux processus différents correspond à deux adresses phsiques différentes, une par processus. Une adresse logique dans un processus correspondra à l'adresse physique X, la même adresse dans un autre processus correspondra à l'adresse Y.
Les adresses physiques qui partagent la même adresse logique sont alors appelées des '''adresses homonymes'''. Le choix de la bonne adresse étant réalisé par un mécanisme matériel et dépend du programme en cours. Le mécanisme pour choisir la bonne adresse dépend du processeur, mais il y en a deux grands types :
* La première consiste à utiliser l'identifiant de processus CPU, vu au chapitre précédent. C'est, pour rappel, un numéro attribué à chaque processus par le processeur. L'identifiant du processus en cours d'exécution est mémorisé dans un registre du processeur. La traduction d'adresse utilise cet identifiant, en plus de l'adresse logique, pour déterminer l'adresse physique.
* La seconde solution mémorise les correspondances adresses logiques-physique dans des tables en mémoire RAM, qui sont différentes pour chaque programme. Les tables sont accédées à chaque accès mémoire, afin de déterminer l'adresse physique.
===Le partage de la mémoire===
L'isolation des processus est très importante sur les systèmes d'exploitation modernes. Cependant, il existe quelques situations où elle doit être contournée ou du moins mise en pause. Les situations sont multiples : gestion de bibliothèques partagées, communication entre processus, usage de ''threads'', etc. Elles impliquent toutes un '''partage de mémoire''', à savoir qu'une portion de mémoire RAM est partagée entre plusieurs programmes. Le partage de mémoire est une sorte de brèche de l'isolation des processus, mais qui est autorisée car elle est utile.
Un cas intéressant est celui des '''bibliothèques partagées'''. Les bibliothèques sont des collections de fonctions regroupées ensemble, dans une seule unité de code. Un programme qui utilise une bibliothèque peut appeler n’importe quelle fonction présente dans la bibliothèque. La bibliothèque peut être simplement inclue dans le programme lui-même, on parle alors de bibliothèques statiques. De telles bibliothèques fonctionnent très bien, mais avec un petit défaut pour les bibliothèques très utilisées : plusieurs programmes qui utilisent la même bibliothèque vont chacun l'inclure dans leur code, ce qui fera doublon.
Pour éviter cela, les OS modernes gèrent des bibliothèques partagées, à savoir qu'un seul exemplaire de la bibliothèque est partagé entre plusieurs programmes. Chaque programme peut exécuter une fonction de la bibliothèque quand il le souhaite, en effectuant un branchement adéquat. Mais cela implique que la bibliothèque soit présente dans l'espace d'adressage du programme en question. Une bibliothèque est donc présente dans plusieurs espaces d'adressage, alors qu'il n'y en a qu'un seul exemplaire en mémoire RAM.
[[File:Ogg vorbis libs and application dia.svg|centre|vignette|upright=2|Exemple de bibliothèques, avec Ogg vorbis.]]
D'autres situations demandent de partager de la mémoire entre deux programmes. Par exemple, les systèmes d'exploitation modernes gèrent nativement des systèmes de '''communication inter-processus''', très utilisés par les programmes modernes pour échanger des données. Et la plupart demandant de partager un bout de mémoire entre processus, même si c'est seulement temporairement. Typiquement, deux processus partagent un intervalle d'adresse où l'un écrit les données à l'autre, l'autre lisant les données envoyées.
Une dernière utilisation de la mémoire partagée est l{{'}}'''accès direct au noyau'''. Sur les systèmes d'exploitations moderne, dans l'espace d'adressage de chaque programme, les adresses hautes sont remplies avec une partie du noyau ! Évidemment, ces adresses sont accessibles uniquement en lecture, pas en écriture. Pas question de modifier le noyau de l'OS ! De plus, il s'agit d'une portion du noyau dont on sait que la consultation ne pose pas de problèmes de sécurité.
Le programme peut lire des données dans cette portion du noyau, mais aussi exécuter les fonctions du noyau qui sont dedans. L'idée est d'éviter des appels systèmes trop fréquents. Au lieu d'effectuer un véritable appel système, avec une interruption logicielle, le programme peut exécuter des appels systèmes simplifiés, de simples appels de fonctions couplés avec un changement de niveau de privilège (passage en espace noyau nécessaire).
[[File:AMD64-canonical--48-bit.png|vignette|Répartition des adresses entre noyau (jaune/orange) et programme (verte), sur les systèmes x86-64 bits, avec des adresses physiques de 48 bits.]]
L'espace d'adressage est donc séparé en deux portions : l'OS d'un côté, le programme de l'autre. La répartition des adresses entre noyau et programme varie suivant l'OS ou le processeur utilisé. Sur les PC x86 32 bits, Linux attribuait 3 gigas pour les programmes et 1 giga pour le noyau, Windows attribuait 2 gigas à chacun. Sur les systèmes x86 64 bits, l'espace d'adressage d'un programme est coupé en trois, comme illustré ci-contre : une partie basse de 2^48 octets, une partie haute de même taille, et un bloc d'adresses invalides entre les deux. Les adresses basses sont utilisées pour le programme, les adresses hautes pour le noyau, il n'y a rien entre les deux.
Avec le partage de mémoire, plusieurs adresses logiques correspondent à la même adresse physique. Tel processus verra la zone de mémoire partagée à l'adresse X, l'autre la verra à l'adresse Y. Mais il s'agira de la même portion de mémoire physique, avec une seule adresse physique. En clair, lorsque deux processus partagent une même zone de mémoire, la zone sera mappées à des adresses logiques différentes. Les adresses logiques sont alors appelées des '''adresses synonymes''', terme qui trahit le fait qu'elles correspondent à la même adresse physique.
===La mémoire virtuelle===
Toutes les adresses ne sont pas forcément occupées par de la mémoire RAM, s'il n'y a pas assez de RAM installée. Par exemple, un processeur 32 bits peut adresser 4 gibioctets de RAM, même si seulement 3 gibioctets sont installés dans l'ordinateur. L'espace d'adressage contient donc 1 gigas d'adresses inutilisées, et il faut éviter ce surplus d'adresses pose problème.
Sans mémoire virtuelle, seule la mémoire réellement installée est utilisable. Si un programme utilise trop de mémoire, il est censé se rendre compte qu'il n'a pas accès à tout l'espace d'adressage. Quand il demandera au système d'exploitation de lui réserver de la mémoire, le système d'exploitation le préviendra qu'il n'y a plus de mémoire libre. Par exemple, si un programme tente d'utiliser 4 gibioctets sur un ordinateur avec 3 gibioctets de mémoire, il ne pourra pas. Pareil s'il veut utiliser 2 gibioctets de mémoire sur un ordinateur avec 4 gibioctets, mais dont 3 gibioctets sont déjà utilisés par d'autres programmes. Dans les deux cas, l'illusion tombe à plat.
Les techniques de '''mémoire virtuelle''' font que l'espace d'adressage est utilisable au complet, même s'il n'y a pas assez de mémoire installée dans l'ordinateur ou que d'autres programmes utilisent de la RAM. Par exemple, sur un processeur 32 bits, le programme aura accès à 4 gibioctets de RAM, même si d'autres programmes utilisent la RAM, même s'il n'y a que 2 gibioctets de RAM d'installés dans l'ordinateur.
Pour cela, on utilise une partie des mémoires de masse (disques durs) d'un ordinateur en remplacement de la mémoire physique manquante. Le système d'exploitation crée sur le disque dur un fichier, appelé le ''swapfile'' ou '''fichier de ''swap''''', qui est utilisé comme mémoire RAM supplémentaire. Il mémorise le surplus de données et de programmes qui ne peut pas être mis en mémoire RAM.
[[File:Vm1.png|centre|vignette|upright=2.0|Mémoire virtuelle et fichier de Swap.]]
Une technique naïve de mémoire virtuelle serait la suivante. Avant de l'aborder, précisons qu'il s'agit d'une technique abordée à but pédagogique, mais qui n'est implémentée nulle part tellement elle est lente et inefficace. Un espace d'adressage de 4 gigas ne contient que 3 gigas de RAM, ce qui fait 1 giga d'adresses inutilisées. Les accès mémoire aux 3 gigas de RAM se font normalement, mais l'accès aux adresses inutilisées lève une exception matérielle "Memory Unavailable". La routine d'interruption de cette exception accède alors au ''swapfile'' et récupère les données associées à cette adresse. La mémoire virtuelle est alors émulée par le système d'exploitation.
Le défaut de cette méthode est que l'accès au giga manquant est toujours très lent, parce qu'il se fait depuis le disque dur. D'autres techniques de mémoire virtuelle logicielle font beaucoup mieux, mais nous allons les passer sous silence, vu qu'on peut faire mieux, avec l'aide du matériel.
L'idée est de charger les données dont le programme a besoin dans la RAM, et de déplacer les autres sur le disque dur. Par exemple, imaginons la situation suivante : un programme a besoin de 4 gigas de mémoire, mais ne dispose que de 2 gigas de mémoire installée. On peut imaginer découper l'espace d'adressage en 2 blocs de 2 gigas, qui sont chargés à la demande. Si le programme accède aux adresses basses, on charge les 2 gigas d'adresse basse en RAM. S'il accède aux adresses hautes, on charge les 2 gigas d'adresse haute dans la RAM après avoir copié les adresses basses sur le ''swapfile''.
On perd du temps dans les copies de données entre RAM et ''swapfile'', mais on gagne en performance vu que tous les accès mémoire se font en RAM. Du fait de la localité temporelle, le programme utilise les données chargées depuis le swapfile durant un bon moment avant de passer au bloc suivant. La RAM est alors utilisée comme une sorte de cache alors que les données sont placées dans une mémoire fictive représentée par l'espace d'adressage et qui correspond au disque dur.
Mais avec cette technique, la correspondance entre adresses du programme et adresses de la RAM change au cours du temps. Les adresses de la RAM correspondent d'abord aux adresses basses, puis aux adresses hautes, et ainsi de suite. On a donc besoin d'abstraction mémoire. Les correspondances entre adresse logique et physique peuvent varier avec le temps, ce qui permet de déplacer des données de la RAM vers le disque dur ou inversement. Une adresse logique peut correspondre à une adresse physique, ou bien à une donnée swappée sur le disque dur. C'est l'unité de traduction d'adresse qui se charge de faire la différence. Si une correspondance entre adresse logique et physique est trouvée, elle l'utilise pour traduire les adresses. Si aucune correspondance n'est trouvée, alors elle laisse la main au système d'exploitation pour charger la donnée en RAM. Une fois la donnée chargée en RAM, les correspondances entre adresse logique et physiques sont modifiées de manière à ce que l'adresse logique pointe vers la donnée chargée.
===L'extension d'adressage===
Une autre fonctionnalité rendue possible par l'abstraction mémoire est l{{'}}'''extension d'adressage'''. Elle permet d'utiliser plus de mémoire que l'espace d'adressage ne le permet. Par exemple, utiliser 7 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'extension d'adresse est l'exact inverse de la mémoire virtuelle. La mémoire virtuelle sert quand on a moins de mémoire que d'adresses, l'extension d'adresse sert quand on a plus de mémoire que d'adresses.
Il y a quelques chapitres, nous avions vu que c'est possible via la commutation de banques. Mais l'abstraction mémoire est une méthode alternative. Que ce soit avec la commutation de banques ou avec l'abstraction mémoire, les adresses envoyées à la mémoire doivent être plus longues que les adresses gérées par le processeur. La différence est que l'abstraction mémoire étend les adresses d'une manière différente.
Une implémentation possible de l'extension d'adressage fait usage de l'abstraction matérielle des processus. Chaque processus a son propre espace d'adressage, mais ceux-ci sont placés à des endroits différents dans la mémoire physique. Par exemple, sur un ordinateur avec 16 gigas de RAM, mais un espace d'adressage de 2 gigas, on peut remplir la RAM en lançant 8 processus différents et chaque processus aura accès à un bloc de 2 gigas de RAM, pas plus, il ne peut pas dépasser cette limite. Ainsi, chaque processus est limité par son espace d'adressage, mais on remplit la mémoire avec plusieurs processus, ce qui compense. Il s'agit là de l'implémentation la plus simple, qui a en plus l'avantage d'avoir la meilleure compatibilité logicielle. De simples changements dans le système d'exploitation suffisent à l'implémenter.
[[File:Extension de l'espace d'adressage.png|centre|vignette|upright=1.5|Extension de l'espace d'adressage]]
Un autre implémentation donne plusieurs espaces d'adressage différents à chaque processus, et a donc accès à autant de mémoire que permis par la somme de ces espaces d'adressage. Par exemple, sur un ordinateur avec 16 gigas de RAM et un espace d'adressage de 4 gigas, un programme peut utiliser toute la RAM en utilisant 4 espaces d'adressage distincts. On passe d'un espace d'adressage à l'autre en changeant la correspondance adresse logique-physique. L'inconvénient est que la compatibilité logicielle est assez mauvaise. Modifier l'OS ne suffit pas, les programmeurs doivent impérativement concevoir leurs programmes pour qu'ils utilisent explicitement plusieurs espaces d'adressage.
Les deux implémentations font usage des adresses logiques homonymes, mais à l'intérieur d'un même processus. Pour rappel, cela veut dire qu'une adresse logique correspond à des adresses physiques différentes. Rien d'étonnant vu qu'on utilise plusieurs espaces d'adressage, comme pour l'abstraction des processus, sauf que cette fois-ci, on a plusieurs espaces d'adressage par processus. Prenons l'exemple où on a 8 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'idée est qu'une adresse correspondra à une adresse dans les premiers 4 gigas, ou dans les seconds 4 gigas. L'adresse logique X correspondra d'abord à une adresse physique dans les premiers 4 gigas, puis à une adresse physique dans les seconds 4 gigas.
===La protection mémoire===
La '''protection mémoire''' regroupe des techniques très différentes les unes des autres, qui visent à améliorer la sécurité des programmes et des systèmes d'exploitation. Elles visent à empêcher de lire, d'écrire ou d'exécuter certaines portions de mémoire. Sans elle, les programmes peuvent techniquement lire ou écrire les données des autres, ce qui causent des situations non-prévues par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses.
La première technique de protection mémoire est l{{'}}'''isolation des processus''', qu'on a vue plus haut. Elle garantit que chaque programme n'a accès qu'à certaines portions dédiées de la mémoire et rend le reste de la mémoire inaccessible en lecture et en écriture. Le système d'exploitation attribue à chaque programme une ou plusieurs portions de mémoire rien que pour lui, auquel aucun autre programme ne peut accéder. Un tel programme, isolé des autres, s'appelle un '''processus''', d'où le nom de cet objectif. Toute tentative d'accès à une partie de la mémoire non autorisée déclenche une exception matérielle (rappelez-vous le chapitre sur les interruptions) qui est traitée par une routine du système d'exploitation. Généralement, le programme fautif est sauvagement arrêté et un message d'erreur est affiché à l'écran.
La '''protection de l'espace exécutable''' empêche d’exécuter quoique ce soit provenant de certaines zones de la mémoire. En effet, certaines portions de la mémoire sont censées contenir uniquement des données, sans aucun programme ou code exécutable. Cependant, des virus informatiques peuvent se cacher dedans et d’exécuter depuis celles-ci. Ou encore, des failles de sécurités peuvent permettre à un attaquant d'injecter du code exécutable malicieux dans des données, ce qui peut lui permettre de lire les données manipulées par un programme, prendre le contrôle de la machine, injecter des virus, ou autre. Pour éviter cela, le système d'exploitation peut marquer certaines zones mémoire comme n'étant pas exécutable. Toute tentative d’exécuter du code localisé dans ces zones entraîne la levée d'une exception ou d'une erreur et le système d'exploitation réagit en conséquence. Là encore, le processeur doit détecter les exécutions non autorisées.
D'autres méthodes de protection mémoire visent à limiter des actions dangereuses. Pour cela, le processeur et l'OS gèrent des '''droits d'accès''', qui interdisent certaines actions pour des programmes non-autorisés. Lorsqu'on exécute une opération interdite, le système d’exploitation et/ou le processeur réagissent en conséquence. La première technique de ce genre n'est autre que la séparation entre espace noyau et utilisateur, vue dans le chapitre sur les interruptions. Mais il y en a d'autres, comme nous le verrons dans ce chapitre.
==La MMU==
La traduction des adresses logiques en adresses physiques se fait par un circuit spécialisé appelé la '''''Memory Management Unit''''' (MMU), qui est souvent intégré directement dans l'interface mémoire. La MMU est souvent associée à une ou plusieurs mémoires caches, qui visent à accélérer la traduction d'adresses logiques en adresses physiques. En effet, nous verrons plus bas que la traduction d'adresse demande d'accéder à des tableaux, gérés par le système d'exploitation, qui sont en mémoire RAM. Aussi, les processeurs modernes incorporent des mémoires caches appelées des '''''Translation Lookaside Buffers''''', ou encore TLB. Nous nous pouvons pas parler des TLB pour le moment, car nous n'avons pas encore abordé le chapitre sur les mémoires caches, mais un chapitre entier sera dédié aux TLB d'ici peu.
[[File:MMU principle updated.png|centre|vignette|upright=2|MMU.]]
===Les MMU intégrées au processeur===
D'ordinaire, la MMU est intégrée au processeur. Et elle peut l'être de deux manières. La première en fait un circuit séparé, relié au bus d'adresse. La seconde fusionne la MMU avec l'unité de calcul d'adresse. La première solution est surtout utilisée avec une technique d'abstraction mémoire appelée la pagination, alors que l'autre l'est avec une autre méthode appelée la segmentation. La raison est que la traduction d'adresse avec la segmentation est assez simple : elle demande d'additionner le contenu d'un registre avec l'adresse logique, ce qui est le genre de calcul qu'une unité de calcul d'adresse sait déjà faire. La fusion est donc assez évidente.
Pour donner un exemple, l'Intel 8086 fusionnait l'unité de calcul d'adresse et la MMU. Précisément, il utilisait un même additionneur pour incrémenter le ''program counter'' et effectuer des calculs d'adresse liés à la segmentation. Il aurait été logique d'ajouter les pointeurs de pile avec, mais ce n'était pas possible. La raison est que le pointeur de pile ne peut pas être envoyé directement sur le bus d'adresse, vu qu'il doit passer par une phase de traduction en adresse physique liée à la segmentation.
[[File:80186 arch.png|centre|vignette|upright=2|Intel 8086, microarchitecture.]]
===Les MMU séparées du processeur, sur la carte mère===
Il a existé des processeurs avec une MMU externe, soudée sur la carte mère.
Par exemple, les processeurs Motorola 68000 et 68010 pouvaient être combinés avec une MMU de type Motorola 68451. Elle supportait des versions simplifiées de la segmentation et de la pagination. Au minimum, elle ajoutait un support de la protection mémoire contre certains accès non-autorisés. La gestion de la mémoire virtuelle proprement dit n'était possible que si le processeur utilisé était un Motorola 68010, en raison de la manière dont le 68000 gérait ses accès mémoire. La MMU 68451 gérait un espace d'adressage de 16 mébioctets, découpé en maximum 32 pages/segments. On pouvait dépasser cette limite de 32 segments/pages en combinant plusieurs 68451.
Le Motorola 68851 était une MMU qui était prévue pour fonctionner de paire avec le Motorola 68020. Elle gérait la pagination pour un espace d'adressage de 32 bits.
Les processeurs suivants, les 68030, 68040, et 68060, avaient une MMU interne au processeur.
==La relocation matérielle==
Pour rappel, les systèmes d'exploitation moderne permettent de lancer plusieurs programmes en même temps et les laissent se partager la mémoire. Dans le cas le plus simple, qui n'est pas celui des OS modernes, le système d'exploitation découpe la mémoire en blocs d'adresses contiguës qui sont appelés des '''segments''', ou encore des ''partitions mémoire''. Les segments correspondent à un bloc de mémoire RAM. C'est-à-dire qu'un segment de 259 mébioctets sera un segment continu de 259 mébioctets dans la mémoire physique comme dans la mémoire logique. Dans ce qui suit, un segment contient un programme en cours d'exécution, comme illustré ci-dessous.
[[File:CPT Memory Addressable.svg|centre|vignette|upright=2|Espace d'adressage segmenté.]]
Le système d'exploitation mémorise la position de chaque segment en mémoire, ainsi que d'autres informations annexes. Le tout est regroupé dans la '''table de segment''', un tableau dont chaque case est attribuée à un programme/segment. La table des segments est un tableau numéroté, chaque segment ayant un numéro qui précise sa position dans le tableau. Chaque case, chaque entrée, contient un '''descripteur de segment''' qui regroupe plusieurs informations sur le segment : son adresse de base, sa taille, diverses informations.
===La relocation avec la relocation matérielle : le registre de base===
Un segment peut être placé n'importe où en RAM physique et sa position en RAM change à chaque exécution. Le programme est chargé à une adresse, celle du début du segment, qui change à chaque chargement du programme. Et toutes les adresses utilisées par le programme doivent être corrigées lors du chargement du programme, généralement par l'OS. Cette correction s'appelle la '''relocation''', et elle consiste à ajouter l'adresse de début du segment à chaque adresse manipulée par le programme.
[[File:Relocation assistée par matériel.png|centre|vignette|upright=2.5|Relocation.]]
La relocation matérielle fait que la relocation est faite par le processeur, pas par l'OS. La relocation est intégrée dans le processeur par l'intégration d'un registre : le '''registre de base''', aussi appelé '''registre de relocation'''. Il mémorise l'adresse à laquelle commence le segment, la première adresse du programme. Pour effectuer la relocation, le processeur ajoute automatiquement l'adresse de base à chaque accès mémoire, en allant la chercher dans le registre de relocation.
[[File:Registre de base de segment.png|centre|vignette|upright=2|Registre de base de segment.]]
Le processeur s'occupe de la relocation des segments et le programme compilé n'en voit rien. Pour le dire autrement, les programmes manipulent des adresses logiques, qui sont traduites par le processeur en adresses physiques. La traduction se fait en ajoutant le contenu du registre de relocation à l'adresse logique. De plus, cette méthode fait que chaque programme a son propre espace d'adressage.
[[File:CPU created logical address presentation.png|centre|vignette|upright=2|Traduction d'adresse avec la relocation matérielle.]]
Le système d'exploitation mémorise les adresses de base pour chaque programme, dans la table des segments. Le registre de base est mis à jour automatiquement lors de chaque changement de segment. Pour cela, le registre de base est accessible via certaines instructions, accessibles en espace noyau, plus rarement en espace utilisateur. Le registre de segment est censé être adressé implicitement, vu qu'il est unique. Si ce n'est pas le cas, il est possible d'écrire dans ce registre de segment, qui est alors adressable.
===La protection mémoire avec la relocation matérielle : le registre limite===
Sans restrictions supplémentaires, la taille maximale d'un segment est égale à la taille complète de l'espace d'adressage. Sur les processeurs 32 bits, un segment a une taille maximale de 2^32 octets, soit 4 gibioctets. Mais il est possible de limiter la taille du segment à 2 gibioctets, 1 gibioctet, 64 Kibioctets, ou toute autre taille. La limite est définie lors de la création du segment, mais elle peut cependant évoluer au cours de l'exécution du programme, grâce à l'allocation mémoire. Le processeur vérifie à chaque accès mémoire que celui-ci se fait bien dans le segment, en comparant l'adresse accédée à l'adresse de base et l'adresse maximale, l'adresse limite.
Limiter la taille d'un segment demande soit de mémoriser sa taille, soit de mémoriser l'adresse limite (l'adresse de fin de segment, l'adresse limite à ne pas dépasser). Les deux sont possibles et marchent parfaitement, le choix entre les deux solutions est une pure question de préférence. A la rigueur, la vérification des débordements est légèrement plus rapide si on utilise l'adresse de fin du segment. Précisons que l'adresse limite est une adresse logique, le segment commence toujours à l'adresse logique zéro.
Pour cela, la table des segments doit être modifiée. Au lieu de ne contenir que l'adresse de base, elle contient soit l'adresse maximale du segment, soit la taille du segment. En clair, le descripteur de segment est enrichi avec l'adresse limite. 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.
Techniquement, il y a une petite différence de vitesse entre utiliser la taille et l'adresse maximale. Vérifier les débordements avec la taille demande juste de comparer la taille avec l'adresse logique, avant relocation, ce qui peut être fait en parallèle de la relocation. Par contre, l'adresse limite est comparée à une adresse physique, ce qui demande de faire la relocation avant la vérification, ce qui prend un peu plus de temps. Mais l'impact sur les performances est des plus mineurs.
[[File:Registre limite.png|centre|vignette|upright=2|Registre limite]]
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> </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>+ </code><code style="background:#DDF">0001 0010 0011 0100</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </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. Au lieu d'utiliser un additionneur séparé pour le ''program counter'' et un autre pour le calcul de l'adresse physique, un seul additionneur est utilisé pour les deux. L'idée était de partager l'additionneur, qui servait à la fois à incrémenter le ''program counter'' et pour gérer la segmentation. 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|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> </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>+ </code><code style="background:#DDF">1111 1111 1111 1111</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </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 de nos PCs, à partir du 286 d'Intel. 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. A 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. A la place, les segments sont adressés de manière indirecte. A 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.
===La protection mémoire : les accès hors-segments===
Comme avec la relocation matérielle, le processeur utilise l'adresse ou la taille limite pour vérifier si l'accès mémoire ne déborde pas en-dehors du segment en cours. Pour cela, le processeur compare l'adresse logique accédée avec l'adresse limite, ou compare la taille limite avec le décalage. L'information est lue depuis la table des segments à chaque accès.
[[File:Vm7.svg|centre|vignette|upright=2|Traduction d'adresse avec vérification des accès hors-segment.]]
Par contre, une nouveauté fait son apparition avec la segmentation : la '''gestion des droits d'accès'''. 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. 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.
L'implémentation de la protection mémoire dépend du CPU considéré. Mais en général, elle se repose sur 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. Les trois étapes sont réalisées en au moins une micro-opération chacune, souvent plus.
Les CPU microcodés peuvent en théorie utiliser le microcode pour tester si telle ou telle erreur survient. Il suffit que le microcode intègre des micro-branchements pour cela. Par exemple, une écriture va tester le bit R/W du descripteur, qui indique si on peut écrire dans le segment. Le micro-branchement enverra vers une routine du microcode en cas d'erreur. Mais les performances sont alors rarement au rendez-vous. La raison est que les tests de protection mémoire demandent de tester beaucoup de conditions différentes. Par exemple, le CPU Intel 386 testait moins d'une dizaine de conditions pour certaines instructions.
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.
====L'implémentation de la protection mémoire sur le 386====
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, quelques test étaient réalisés par le microcode.
Précisément, le processeur pouvait tester 148 conditions différentes en parallèle, mais toutes n'étaient pas pertinentes. Par exemple, 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.
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.
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. Le PLA recevait un descripteur de segment, lu depuis la mémoire RAM, ainsi qu'un code opération provenant du microcode. La PTU testait plus d'une centaine de conditions, en parallèle, en comparant les bits d'entrées avec l'instruction demandée. Par exemple, il vérifiait si le bit R/W était cohérent avec le fait que l'instruction en cours d'exécution est une écriture.
{|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 || C/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 : soit autorisait l'exécution de la lecture/écriture, soit levait une exception.
* Une adresse de 12 bits, pointant dans le microcode, sur un code levant une exception en cas d'erreur.
* 4 bits pouvant être testés par un branchement dans le microcode, qui demandaient :
** soit de modifier le bit A du descripteur de segment (pas d'erreur, on accède au segment) ;
** soit de tester s'il y a un accès hors-limite ;
** de signaler une instruction sur la pile.
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 ''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. A 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]]
A 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. A 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. A la place, l'entrée contient un '''bit ''global''''', qui précise que cette correspondance est valide pour tous les processus. Le bit global accélère rapidement la traduction d'adresse pour l'accès au noyau.
Un défaut de cette méthode est que le partage d'une page entre plusieurs processus est presque impossible. Impossible de partager une page avec seulement certains processus et pas d'autres : soit on partage une page avec tous les processus, soit on l'alloue avec un seul processus.
====L'usage de plusieurs tables des pages====
Une solution alternative, plus simple, utilise une table des pages par processus lancé sur l'ordinateur, une table des pages unique par espace d'adressage. À chaque changement de processus, le registre qui mémorise la position de la table des pages est modifié pour pointer sur la bonne. C'est le système d'exploitation qui se charge de cette mise à jour.
Avec cette méthode, il est possible de partager une ou plusieurs pages entre plusieurs processus, en configurant les tables des pages convenablement. Les pages partagées sont mappées dans l'espace d'adressage de plusieurs processus, mais pas forcément au même endroit, pas forcément dans les mêmes adresses logiques. On peut placer la page partagée à l'adresse logique 0x0FFF pour un processus, à l'adresse logique 0xFF00 pour un autre processus, etc. Par contre, les entrées de la table des pages pour ces adresses pointent vers la même adresse physique.
[[File:Vm5.png|centre|vignette|upright=2|Tables des pages de plusieurs processus.]]
===La taille des pages===
La taille des pages varie suivant le processeur et le système d'exploitation et tourne souvent autour de 4 kibioctets. Les processeurs actuels gèrent plusieurs tailles différentes pour les pages : 4 kibioctets par défaut, 2 mébioctets, voire 1 à 4 gibioctets pour les pages les plus larges. Les pages de 4 kibioctets sont les pages par défaut, les autres tailles de page sont appelées des ''pages larges''. La taille optimale pour les pages dépend de nombreux paramètres et il n'y a pas de taille qui convienne à tout le monde. Certaines applications gagnent à utiliser des pages larges, d'autres vont au contraire perdre drastiquement en performance en les utilisant.
Le désavantage principal des pages larges est qu'elles favorisent la fragmentation mémoire. Si un programme veut réserver une portion de mémoire, pour une structure de donnée quelconque, il doit réserver une portion dont la taille est multiple de la taille d'une page. Par exemple, un programme ayant besoin de 110 kibioctets allouera 28 pages de 4 kibioctets, soit 120 kibioctets : 2 kibioctets seront perdus. Par contre, avec des pages larges de 2 mébioctets, on aura une perte de 2048 - 110 = 1938 kibioctets. En somme, des morceaux de mémoire seront perdus, car les pages sont trop grandes pour les données qu'on veut y mettre. Le résultat est que le programme qui utilise les pages larges utilisent plus de mémoire et ce d'autant plus qu'il utilise des données de petite taille. Un autre désavantage est qu'elles se marient mal avec certaines techniques d'optimisations de type ''copy-on-write''.
Mais l'avantage est que la traduction des adresses est plus performante. Une taille des pages plus élevée signifie moins de pages, donc des tables des pages plus petites. Et des pages des tables plus petites n'ont pas besoin de beaucoup de niveaux de hiérarchie, voire peuvent se limiter à des tables des pages simples, ce qui rend la traduction d'adresse plus simple et plus rapide. De plus, les programmes ont une certaine localité spatiale, qui font qu'ils accèdent souvent à des données proches. La traduction d'adresse peut alors profiter de systèmes de mise en cache dont nous parlerons dans le prochain chapitre, et ces systèmes de cache marchent nettement mieux avec des pages larges.
Il faut noter que la taille des pages est presque toujours une puissance de deux. Cela a de nombreux avantages, mais n'est pas une nécessité. Par exemple, le tout premier processeur avec de la pagination, le super-ordinateur Atlas, avait des pages de 3 kibioctets. L'avantage principal est que la traduction de l'adresse physique en adresse logique est trivial avec une puissance de deux. Cela garantit que l'on peut diviser l'adresse en un numéro de page et un ''offset'' : la traduction demande juste de remplacer les bits de poids forts par le numéro de page voulu. Sans cela, la traduction d'adresse implique des divisions et des multiplications, qui sont des opérations assez couteuses.
===Les entrées de la table des pages===
Avant de poursuivre, faisons un rapide rappel sur les entrées de la table des pages. Nous venons de voir que la table des pages contient de nombreuses informations : un bit ''valid'' pour la mémoire virtuelle, des bits ''dirty'' et ''accessed'' utilisés par l'OS, des bits de protection mémoire, un bit ''global'' et un potentiellement un identifiant de processus, etc. Étudions rapidement le format de la table des pages sur un processeur x86 32 bits.
* Elle contient d'abord le numéro de page physique.
* Les bits AVL sont inutilisés et peuvent être configurés à loisir par l'OS.
* Le bit G est le bit ''global''.
* Le bit PS vaut 0 pour une page de 4 kibioctets, mais est mis à 1 pour une page de 4 mébioctets dans le cas où le processus utilise des pages larges.
* Le bit D est le bit ''dirty''.
* Le bit A est le bit ''accessed''.
* Le bit PCD indique que la page ne peut pas être cachée, dans le sens où le processeur ne peut copier son contenu dans le cache et doit toujours lire ou écrire cette page directement dans la RAM.
* Le bit PWT indique que les écritures doivent mettre à jour le cache et la page en RAM (dans le chapitre sur le cache, on verra qu'il force le cache à se comporter comme un cache ''write-through'' pour cette page).
* Le bit U/S précise si la page est accessible en mode noyau ou utilisateur.
* Le bit R/W indique si la page est accessible en écriture, toutes les pages sont par défaut accessibles en lecture.
* Le bit P est le bit ''valid''.
[[File:PDE.png|centre|vignette|upright=2.5|Table des pages des processeurs Intel 32 bits.]]
==Comparaison des différentes techniques d'abstraction mémoire==
Pour résumer, l'abstraction mémoire permet de gérer : la relocation, la protection mémoire, l'isolation des processus, la mémoire virtuelle, l'extension de l'espace d'adressage, le partage de mémoire, etc. Elles sont souvent implémentées en même temps. Ce qui fait qu'elles sont souvent confondues, alors que ce sont des concepts sont différents. Ces liens sont résumés dans le tableau ci-dessous.
{|class="wikitable"
|-
!
! colspan="5" | Avec abstraction mémoire
! rowspan="2" | Sans abstraction mémoire
|-
!
! Relocation matérielle
! Segmentation en mode réel (x86)
! Segmentation, général
! Architectures à capacités
! Pagination
|-
! Abstraction matérielle des processus
| colspan="4" | Oui, relocation matérielle
| Oui, liée à la traduction d'adresse
| Impossible
|-
! Mémoire virtuelle
| colspan="2" | Non, sauf émulation logicielle
| colspan="3" | Oui, gérée par le processeur et l'OS
| Non, sauf émulation logicielle
|-
! Extension de l'espace d'adressage
| colspan="2" | Oui : registre de base élargi
| colspan="2" | Oui : adresse de base élargie dans la table des segments
| ''Physical Adress Extension'' des processeurs 32 bits
| Commutation de banques
|-
! Protection mémoire
| Registre limite
| Aucune
| colspan="2" | Registre limite, droits d'accès aux segments
| Gestion des droits d'accès aux pages
| Possible, méthodes variées
|-
! Partage de mémoire
| colspan="2" | Non
| colspan="2" | Segment partagés
| Pages partagées
| Possible, méthodes variées
|}
===Les différents types de segmentation===
La segmentation regroupe plusieurs techniques franchement différentes, qui auraient gagné à être nommées différemment. La principale différence est l'usage de registres de relocation versus des registres de sélecteurs de segments. L'usage de registres de relocation est le fait de la relocation matérielle, mais aussi de la segmentation en mode réel des CPU x86. Par contre, l'usage de sélecteurs de segments est le fait des autres formes de segmentation, architectures à capacité inclues.
La différence entre les deux est le nombre de segments. L'usage de registres de relocation fait que le CPU ne gère qu'un petit nombre de segments de grande taille. La mémoire virtuelle est donc rarement implémentée vu que swapper des segments de grande taille est trop long, l'impact sur les performances est trop important. Sans compter que l'usage de registres de base se marie très mal avec la mémoire virtuelle. Vu qu'un segment peut être swappé ou déplacée n'importe quand, il faut invalider les registres de base au moment du swap/déplacement, ce qui n'est pas chose aisée. Aucun processeur ne gère cela, les méthodes pour n'existent tout simplement pas. L'usage de registres de base implique que la mémoire virtuelle est absente.
La protection mémoire est aussi plus limitée avec l'usage de registres de relocation. Elle se limite à des registres limite, mais la gestion des droits d'accès est limitée. En théorie, la segmentation en mode réel pourrait implémenter une version limitée de protection mémoire, avec une protection de l'espace exécutable. Mais ca n'a jamais été fait en pratique sur les processeurs x86.
Le partage de la mémoire est aussi difficile sur les architectures avec des registres de base. L'absence de table des segments fait que le partage d'un segment est basiquement impossible sans utiliser des méthodes complétement tordues, qui ne sont jamais implémentées en pratique.
===Segmentation versus pagination===
Par rapport à la pagination, la segmentation a des avantages et des inconvénients. Tous sont liés aux propriétés des segments et pages : les segments sont de grande taille et de taille variable, les pages sont petites et de taille fixe.
L'avantage principal de la segmentation est sa rapidité. Le fait que les segments sont de grande taille fait qu'on a pas besoin d'équivalent aux tables des pages inversée ou multiple, juste d'une table des segments toute simple. De plus, les échanges entre table des pages/segments et registres sont plus rares avec la segmentation. Par exemple, si un programme utilise un segment de 2 gigas, tous les accès dans le segment se feront avec une seule consultation de la table des segments. Alors qu'avec la pagination, il faudra une consultation de la table des pages chaque bloc de 4 kibioctet, au minimum.
Mais les désavantages sont nombreux. Le système d'exploitation doit agencer les segments en RAM, et c'est une tâche complexe. Le fait que les segments puisse changer de taille rend le tout encore plus complexe. Par exemple, si on colle les segments les uns à la suite des autres, changer la taille d'un segment demande de réorganiser tous les segments en RAM, ce qui demande énormément de copies RAM-RAM. Une autre possibilité est de laisser assez d'espace entre les segments, mais cet espace est alors gâché, dans le sens où on ne peut pas y placer un nouveau segment.
Swapper un segment est aussi très long, vu que les segments sont de grande taille, alors que swapper une page est très rapide.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'espace d'adressage du processeur
| prevText=L'espace d'adressage du processeur
| next=Les méthodes de synchronisation entre processeur et périphériques
| nextText=Les méthodes de synchronisation entre processeur et périphériques
}}
</noinclude>
ico1a1oocdiu20nqv2rxggkjfkp1hrf
763691
763690
2026-04-14T22:17:49Z
Mewtow
31375
/* L'implémentation de la protection mémoire sur le 386 */
763691
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 envoyées à 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 sachez qu'il existe 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 les adresses de l'espace d'adressage sont 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'abstraction mémoire implémente plusieurs fonctionnalités complémentaires==
L'utilité de l'abstraction matérielle n'est pas évidente, mais sachez qu'elle est si utile que tous les processeurs modernes la prennent en charge. Elle sert notamment à implémenter la mémoire virtuelle, que nous aborderons dans ce qui suit. La plupart de ces fonctionnalités manipulent la relation entre adresses logiques et physique. Dans le cas le plus simple, une adresse logique correspond à une seule adresse physique. Mais beaucoup de fonctionnalités avancées ne respectent pas cette règle.
===L'abstraction matérielle des processus===
Les systèmes d'exploitation modernes sont dits multi-tâche, à savoir qu'ils sont capables d'exécuter plusieurs logiciels en même temps. Et ce même si un seul processeur est présent dans l'ordinateur : les logiciels sont alors exécutés à tour de rôle. Toutefois, cela amène un paquet de problèmes qu'il faut résoudre au mieux. Par exemple, les programmes exécutés doivent se partager la mémoire RAM, ce qui ne vient pas sans problèmes. Le problème principal est que les programmes ne doivent pas lire ou écrire dans les données d'un autre, sans quoi on se retrouverait rapidement avec des problèmes. Il faut donc introduire des mécanismes d{{'}}'''isolement des processus''', pour isoler les programmes les uns des autres.
Un de ces mécanismes est l{{'}}'''abstraction matérielle des processus''', une technique qui fait que chaque programme a son propre espace d'adressage. Chaque programme a l'impression d'avoir accès à tout l'espace d'adressage, de l'adresse 0 à l'adresse maximale gérée par le processeur. Évidemment, il s'agit d'une illusion maintenue justement grâce à la traduction d'adresse. Les espaces d'adressage contiennent des adresses logiques, les adresses de la RAM sont des adresses physiques, la nécessité de l'abstraction mémoire est évidente.
Implémenter l'abstraction mémoire peut se faire de plusieurs manières. Mais dans tous les cas, il faut que la correspondance adresse logique - physique change d'un programme à l'autre. Ce qui est normal, vu que les deux processus sont placés à des endroits différents en RAM physique. La conséquence est qu'avec l'abstraction mémoire, une adresse logique correspond à plusieurs adresses physiques. Une même adresse logique dans deux processus différents correspond à deux adresses phsiques différentes, une par processus. Une adresse logique dans un processus correspondra à l'adresse physique X, la même adresse dans un autre processus correspondra à l'adresse Y.
Les adresses physiques qui partagent la même adresse logique sont alors appelées des '''adresses homonymes'''. Le choix de la bonne adresse étant réalisé par un mécanisme matériel et dépend du programme en cours. Le mécanisme pour choisir la bonne adresse dépend du processeur, mais il y en a deux grands types :
* La première consiste à utiliser l'identifiant de processus CPU, vu au chapitre précédent. C'est, pour rappel, un numéro attribué à chaque processus par le processeur. L'identifiant du processus en cours d'exécution est mémorisé dans un registre du processeur. La traduction d'adresse utilise cet identifiant, en plus de l'adresse logique, pour déterminer l'adresse physique.
* La seconde solution mémorise les correspondances adresses logiques-physique dans des tables en mémoire RAM, qui sont différentes pour chaque programme. Les tables sont accédées à chaque accès mémoire, afin de déterminer l'adresse physique.
===Le partage de la mémoire===
L'isolation des processus est très importante sur les systèmes d'exploitation modernes. Cependant, il existe quelques situations où elle doit être contournée ou du moins mise en pause. Les situations sont multiples : gestion de bibliothèques partagées, communication entre processus, usage de ''threads'', etc. Elles impliquent toutes un '''partage de mémoire''', à savoir qu'une portion de mémoire RAM est partagée entre plusieurs programmes. Le partage de mémoire est une sorte de brèche de l'isolation des processus, mais qui est autorisée car elle est utile.
Un cas intéressant est celui des '''bibliothèques partagées'''. Les bibliothèques sont des collections de fonctions regroupées ensemble, dans une seule unité de code. Un programme qui utilise une bibliothèque peut appeler n’importe quelle fonction présente dans la bibliothèque. La bibliothèque peut être simplement inclue dans le programme lui-même, on parle alors de bibliothèques statiques. De telles bibliothèques fonctionnent très bien, mais avec un petit défaut pour les bibliothèques très utilisées : plusieurs programmes qui utilisent la même bibliothèque vont chacun l'inclure dans leur code, ce qui fera doublon.
Pour éviter cela, les OS modernes gèrent des bibliothèques partagées, à savoir qu'un seul exemplaire de la bibliothèque est partagé entre plusieurs programmes. Chaque programme peut exécuter une fonction de la bibliothèque quand il le souhaite, en effectuant un branchement adéquat. Mais cela implique que la bibliothèque soit présente dans l'espace d'adressage du programme en question. Une bibliothèque est donc présente dans plusieurs espaces d'adressage, alors qu'il n'y en a qu'un seul exemplaire en mémoire RAM.
[[File:Ogg vorbis libs and application dia.svg|centre|vignette|upright=2|Exemple de bibliothèques, avec Ogg vorbis.]]
D'autres situations demandent de partager de la mémoire entre deux programmes. Par exemple, les systèmes d'exploitation modernes gèrent nativement des systèmes de '''communication inter-processus''', très utilisés par les programmes modernes pour échanger des données. Et la plupart demandant de partager un bout de mémoire entre processus, même si c'est seulement temporairement. Typiquement, deux processus partagent un intervalle d'adresse où l'un écrit les données à l'autre, l'autre lisant les données envoyées.
Une dernière utilisation de la mémoire partagée est l{{'}}'''accès direct au noyau'''. Sur les systèmes d'exploitations moderne, dans l'espace d'adressage de chaque programme, les adresses hautes sont remplies avec une partie du noyau ! Évidemment, ces adresses sont accessibles uniquement en lecture, pas en écriture. Pas question de modifier le noyau de l'OS ! De plus, il s'agit d'une portion du noyau dont on sait que la consultation ne pose pas de problèmes de sécurité.
Le programme peut lire des données dans cette portion du noyau, mais aussi exécuter les fonctions du noyau qui sont dedans. L'idée est d'éviter des appels systèmes trop fréquents. Au lieu d'effectuer un véritable appel système, avec une interruption logicielle, le programme peut exécuter des appels systèmes simplifiés, de simples appels de fonctions couplés avec un changement de niveau de privilège (passage en espace noyau nécessaire).
[[File:AMD64-canonical--48-bit.png|vignette|Répartition des adresses entre noyau (jaune/orange) et programme (verte), sur les systèmes x86-64 bits, avec des adresses physiques de 48 bits.]]
L'espace d'adressage est donc séparé en deux portions : l'OS d'un côté, le programme de l'autre. La répartition des adresses entre noyau et programme varie suivant l'OS ou le processeur utilisé. Sur les PC x86 32 bits, Linux attribuait 3 gigas pour les programmes et 1 giga pour le noyau, Windows attribuait 2 gigas à chacun. Sur les systèmes x86 64 bits, l'espace d'adressage d'un programme est coupé en trois, comme illustré ci-contre : une partie basse de 2^48 octets, une partie haute de même taille, et un bloc d'adresses invalides entre les deux. Les adresses basses sont utilisées pour le programme, les adresses hautes pour le noyau, il n'y a rien entre les deux.
Avec le partage de mémoire, plusieurs adresses logiques correspondent à la même adresse physique. Tel processus verra la zone de mémoire partagée à l'adresse X, l'autre la verra à l'adresse Y. Mais il s'agira de la même portion de mémoire physique, avec une seule adresse physique. En clair, lorsque deux processus partagent une même zone de mémoire, la zone sera mappées à des adresses logiques différentes. Les adresses logiques sont alors appelées des '''adresses synonymes''', terme qui trahit le fait qu'elles correspondent à la même adresse physique.
===La mémoire virtuelle===
Toutes les adresses ne sont pas forcément occupées par de la mémoire RAM, s'il n'y a pas assez de RAM installée. Par exemple, un processeur 32 bits peut adresser 4 gibioctets de RAM, même si seulement 3 gibioctets sont installés dans l'ordinateur. L'espace d'adressage contient donc 1 gigas d'adresses inutilisées, et il faut éviter ce surplus d'adresses pose problème.
Sans mémoire virtuelle, seule la mémoire réellement installée est utilisable. Si un programme utilise trop de mémoire, il est censé se rendre compte qu'il n'a pas accès à tout l'espace d'adressage. Quand il demandera au système d'exploitation de lui réserver de la mémoire, le système d'exploitation le préviendra qu'il n'y a plus de mémoire libre. Par exemple, si un programme tente d'utiliser 4 gibioctets sur un ordinateur avec 3 gibioctets de mémoire, il ne pourra pas. Pareil s'il veut utiliser 2 gibioctets de mémoire sur un ordinateur avec 4 gibioctets, mais dont 3 gibioctets sont déjà utilisés par d'autres programmes. Dans les deux cas, l'illusion tombe à plat.
Les techniques de '''mémoire virtuelle''' font que l'espace d'adressage est utilisable au complet, même s'il n'y a pas assez de mémoire installée dans l'ordinateur ou que d'autres programmes utilisent de la RAM. Par exemple, sur un processeur 32 bits, le programme aura accès à 4 gibioctets de RAM, même si d'autres programmes utilisent la RAM, même s'il n'y a que 2 gibioctets de RAM d'installés dans l'ordinateur.
Pour cela, on utilise une partie des mémoires de masse (disques durs) d'un ordinateur en remplacement de la mémoire physique manquante. Le système d'exploitation crée sur le disque dur un fichier, appelé le ''swapfile'' ou '''fichier de ''swap''''', qui est utilisé comme mémoire RAM supplémentaire. Il mémorise le surplus de données et de programmes qui ne peut pas être mis en mémoire RAM.
[[File:Vm1.png|centre|vignette|upright=2.0|Mémoire virtuelle et fichier de Swap.]]
Une technique naïve de mémoire virtuelle serait la suivante. Avant de l'aborder, précisons qu'il s'agit d'une technique abordée à but pédagogique, mais qui n'est implémentée nulle part tellement elle est lente et inefficace. Un espace d'adressage de 4 gigas ne contient que 3 gigas de RAM, ce qui fait 1 giga d'adresses inutilisées. Les accès mémoire aux 3 gigas de RAM se font normalement, mais l'accès aux adresses inutilisées lève une exception matérielle "Memory Unavailable". La routine d'interruption de cette exception accède alors au ''swapfile'' et récupère les données associées à cette adresse. La mémoire virtuelle est alors émulée par le système d'exploitation.
Le défaut de cette méthode est que l'accès au giga manquant est toujours très lent, parce qu'il se fait depuis le disque dur. D'autres techniques de mémoire virtuelle logicielle font beaucoup mieux, mais nous allons les passer sous silence, vu qu'on peut faire mieux, avec l'aide du matériel.
L'idée est de charger les données dont le programme a besoin dans la RAM, et de déplacer les autres sur le disque dur. Par exemple, imaginons la situation suivante : un programme a besoin de 4 gigas de mémoire, mais ne dispose que de 2 gigas de mémoire installée. On peut imaginer découper l'espace d'adressage en 2 blocs de 2 gigas, qui sont chargés à la demande. Si le programme accède aux adresses basses, on charge les 2 gigas d'adresse basse en RAM. S'il accède aux adresses hautes, on charge les 2 gigas d'adresse haute dans la RAM après avoir copié les adresses basses sur le ''swapfile''.
On perd du temps dans les copies de données entre RAM et ''swapfile'', mais on gagne en performance vu que tous les accès mémoire se font en RAM. Du fait de la localité temporelle, le programme utilise les données chargées depuis le swapfile durant un bon moment avant de passer au bloc suivant. La RAM est alors utilisée comme une sorte de cache alors que les données sont placées dans une mémoire fictive représentée par l'espace d'adressage et qui correspond au disque dur.
Mais avec cette technique, la correspondance entre adresses du programme et adresses de la RAM change au cours du temps. Les adresses de la RAM correspondent d'abord aux adresses basses, puis aux adresses hautes, et ainsi de suite. On a donc besoin d'abstraction mémoire. Les correspondances entre adresse logique et physique peuvent varier avec le temps, ce qui permet de déplacer des données de la RAM vers le disque dur ou inversement. Une adresse logique peut correspondre à une adresse physique, ou bien à une donnée swappée sur le disque dur. C'est l'unité de traduction d'adresse qui se charge de faire la différence. Si une correspondance entre adresse logique et physique est trouvée, elle l'utilise pour traduire les adresses. Si aucune correspondance n'est trouvée, alors elle laisse la main au système d'exploitation pour charger la donnée en RAM. Une fois la donnée chargée en RAM, les correspondances entre adresse logique et physiques sont modifiées de manière à ce que l'adresse logique pointe vers la donnée chargée.
===L'extension d'adressage===
Une autre fonctionnalité rendue possible par l'abstraction mémoire est l{{'}}'''extension d'adressage'''. Elle permet d'utiliser plus de mémoire que l'espace d'adressage ne le permet. Par exemple, utiliser 7 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'extension d'adresse est l'exact inverse de la mémoire virtuelle. La mémoire virtuelle sert quand on a moins de mémoire que d'adresses, l'extension d'adresse sert quand on a plus de mémoire que d'adresses.
Il y a quelques chapitres, nous avions vu que c'est possible via la commutation de banques. Mais l'abstraction mémoire est une méthode alternative. Que ce soit avec la commutation de banques ou avec l'abstraction mémoire, les adresses envoyées à la mémoire doivent être plus longues que les adresses gérées par le processeur. La différence est que l'abstraction mémoire étend les adresses d'une manière différente.
Une implémentation possible de l'extension d'adressage fait usage de l'abstraction matérielle des processus. Chaque processus a son propre espace d'adressage, mais ceux-ci sont placés à des endroits différents dans la mémoire physique. Par exemple, sur un ordinateur avec 16 gigas de RAM, mais un espace d'adressage de 2 gigas, on peut remplir la RAM en lançant 8 processus différents et chaque processus aura accès à un bloc de 2 gigas de RAM, pas plus, il ne peut pas dépasser cette limite. Ainsi, chaque processus est limité par son espace d'adressage, mais on remplit la mémoire avec plusieurs processus, ce qui compense. Il s'agit là de l'implémentation la plus simple, qui a en plus l'avantage d'avoir la meilleure compatibilité logicielle. De simples changements dans le système d'exploitation suffisent à l'implémenter.
[[File:Extension de l'espace d'adressage.png|centre|vignette|upright=1.5|Extension de l'espace d'adressage]]
Un autre implémentation donne plusieurs espaces d'adressage différents à chaque processus, et a donc accès à autant de mémoire que permis par la somme de ces espaces d'adressage. Par exemple, sur un ordinateur avec 16 gigas de RAM et un espace d'adressage de 4 gigas, un programme peut utiliser toute la RAM en utilisant 4 espaces d'adressage distincts. On passe d'un espace d'adressage à l'autre en changeant la correspondance adresse logique-physique. L'inconvénient est que la compatibilité logicielle est assez mauvaise. Modifier l'OS ne suffit pas, les programmeurs doivent impérativement concevoir leurs programmes pour qu'ils utilisent explicitement plusieurs espaces d'adressage.
Les deux implémentations font usage des adresses logiques homonymes, mais à l'intérieur d'un même processus. Pour rappel, cela veut dire qu'une adresse logique correspond à des adresses physiques différentes. Rien d'étonnant vu qu'on utilise plusieurs espaces d'adressage, comme pour l'abstraction des processus, sauf que cette fois-ci, on a plusieurs espaces d'adressage par processus. Prenons l'exemple où on a 8 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'idée est qu'une adresse correspondra à une adresse dans les premiers 4 gigas, ou dans les seconds 4 gigas. L'adresse logique X correspondra d'abord à une adresse physique dans les premiers 4 gigas, puis à une adresse physique dans les seconds 4 gigas.
===La protection mémoire===
La '''protection mémoire''' regroupe des techniques très différentes les unes des autres, qui visent à améliorer la sécurité des programmes et des systèmes d'exploitation. Elles visent à empêcher de lire, d'écrire ou d'exécuter certaines portions de mémoire. Sans elle, les programmes peuvent techniquement lire ou écrire les données des autres, ce qui causent des situations non-prévues par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses.
La première technique de protection mémoire est l{{'}}'''isolation des processus''', qu'on a vue plus haut. Elle garantit que chaque programme n'a accès qu'à certaines portions dédiées de la mémoire et rend le reste de la mémoire inaccessible en lecture et en écriture. Le système d'exploitation attribue à chaque programme une ou plusieurs portions de mémoire rien que pour lui, auquel aucun autre programme ne peut accéder. Un tel programme, isolé des autres, s'appelle un '''processus''', d'où le nom de cet objectif. Toute tentative d'accès à une partie de la mémoire non autorisée déclenche une exception matérielle (rappelez-vous le chapitre sur les interruptions) qui est traitée par une routine du système d'exploitation. Généralement, le programme fautif est sauvagement arrêté et un message d'erreur est affiché à l'écran.
La '''protection de l'espace exécutable''' empêche d’exécuter quoique ce soit provenant de certaines zones de la mémoire. En effet, certaines portions de la mémoire sont censées contenir uniquement des données, sans aucun programme ou code exécutable. Cependant, des virus informatiques peuvent se cacher dedans et d’exécuter depuis celles-ci. Ou encore, des failles de sécurités peuvent permettre à un attaquant d'injecter du code exécutable malicieux dans des données, ce qui peut lui permettre de lire les données manipulées par un programme, prendre le contrôle de la machine, injecter des virus, ou autre. Pour éviter cela, le système d'exploitation peut marquer certaines zones mémoire comme n'étant pas exécutable. Toute tentative d’exécuter du code localisé dans ces zones entraîne la levée d'une exception ou d'une erreur et le système d'exploitation réagit en conséquence. Là encore, le processeur doit détecter les exécutions non autorisées.
D'autres méthodes de protection mémoire visent à limiter des actions dangereuses. Pour cela, le processeur et l'OS gèrent des '''droits d'accès''', qui interdisent certaines actions pour des programmes non-autorisés. Lorsqu'on exécute une opération interdite, le système d’exploitation et/ou le processeur réagissent en conséquence. La première technique de ce genre n'est autre que la séparation entre espace noyau et utilisateur, vue dans le chapitre sur les interruptions. Mais il y en a d'autres, comme nous le verrons dans ce chapitre.
==La MMU==
La traduction des adresses logiques en adresses physiques se fait par un circuit spécialisé appelé la '''''Memory Management Unit''''' (MMU), qui est souvent intégré directement dans l'interface mémoire. La MMU est souvent associée à une ou plusieurs mémoires caches, qui visent à accélérer la traduction d'adresses logiques en adresses physiques. En effet, nous verrons plus bas que la traduction d'adresse demande d'accéder à des tableaux, gérés par le système d'exploitation, qui sont en mémoire RAM. Aussi, les processeurs modernes incorporent des mémoires caches appelées des '''''Translation Lookaside Buffers''''', ou encore TLB. Nous nous pouvons pas parler des TLB pour le moment, car nous n'avons pas encore abordé le chapitre sur les mémoires caches, mais un chapitre entier sera dédié aux TLB d'ici peu.
[[File:MMU principle updated.png|centre|vignette|upright=2|MMU.]]
===Les MMU intégrées au processeur===
D'ordinaire, la MMU est intégrée au processeur. Et elle peut l'être de deux manières. La première en fait un circuit séparé, relié au bus d'adresse. La seconde fusionne la MMU avec l'unité de calcul d'adresse. La première solution est surtout utilisée avec une technique d'abstraction mémoire appelée la pagination, alors que l'autre l'est avec une autre méthode appelée la segmentation. La raison est que la traduction d'adresse avec la segmentation est assez simple : elle demande d'additionner le contenu d'un registre avec l'adresse logique, ce qui est le genre de calcul qu'une unité de calcul d'adresse sait déjà faire. La fusion est donc assez évidente.
Pour donner un exemple, l'Intel 8086 fusionnait l'unité de calcul d'adresse et la MMU. Précisément, il utilisait un même additionneur pour incrémenter le ''program counter'' et effectuer des calculs d'adresse liés à la segmentation. Il aurait été logique d'ajouter les pointeurs de pile avec, mais ce n'était pas possible. La raison est que le pointeur de pile ne peut pas être envoyé directement sur le bus d'adresse, vu qu'il doit passer par une phase de traduction en adresse physique liée à la segmentation.
[[File:80186 arch.png|centre|vignette|upright=2|Intel 8086, microarchitecture.]]
===Les MMU séparées du processeur, sur la carte mère===
Il a existé des processeurs avec une MMU externe, soudée sur la carte mère.
Par exemple, les processeurs Motorola 68000 et 68010 pouvaient être combinés avec une MMU de type Motorola 68451. Elle supportait des versions simplifiées de la segmentation et de la pagination. Au minimum, elle ajoutait un support de la protection mémoire contre certains accès non-autorisés. La gestion de la mémoire virtuelle proprement dit n'était possible que si le processeur utilisé était un Motorola 68010, en raison de la manière dont le 68000 gérait ses accès mémoire. La MMU 68451 gérait un espace d'adressage de 16 mébioctets, découpé en maximum 32 pages/segments. On pouvait dépasser cette limite de 32 segments/pages en combinant plusieurs 68451.
Le Motorola 68851 était une MMU qui était prévue pour fonctionner de paire avec le Motorola 68020. Elle gérait la pagination pour un espace d'adressage de 32 bits.
Les processeurs suivants, les 68030, 68040, et 68060, avaient une MMU interne au processeur.
==La relocation matérielle==
Pour rappel, les systèmes d'exploitation moderne permettent de lancer plusieurs programmes en même temps et les laissent se partager la mémoire. Dans le cas le plus simple, qui n'est pas celui des OS modernes, le système d'exploitation découpe la mémoire en blocs d'adresses contiguës qui sont appelés des '''segments''', ou encore des ''partitions mémoire''. Les segments correspondent à un bloc de mémoire RAM. C'est-à-dire qu'un segment de 259 mébioctets sera un segment continu de 259 mébioctets dans la mémoire physique comme dans la mémoire logique. Dans ce qui suit, un segment contient un programme en cours d'exécution, comme illustré ci-dessous.
[[File:CPT Memory Addressable.svg|centre|vignette|upright=2|Espace d'adressage segmenté.]]
Le système d'exploitation mémorise la position de chaque segment en mémoire, ainsi que d'autres informations annexes. Le tout est regroupé dans la '''table de segment''', un tableau dont chaque case est attribuée à un programme/segment. La table des segments est un tableau numéroté, chaque segment ayant un numéro qui précise sa position dans le tableau. Chaque case, chaque entrée, contient un '''descripteur de segment''' qui regroupe plusieurs informations sur le segment : son adresse de base, sa taille, diverses informations.
===La relocation avec la relocation matérielle : le registre de base===
Un segment peut être placé n'importe où en RAM physique et sa position en RAM change à chaque exécution. Le programme est chargé à une adresse, celle du début du segment, qui change à chaque chargement du programme. Et toutes les adresses utilisées par le programme doivent être corrigées lors du chargement du programme, généralement par l'OS. Cette correction s'appelle la '''relocation''', et elle consiste à ajouter l'adresse de début du segment à chaque adresse manipulée par le programme.
[[File:Relocation assistée par matériel.png|centre|vignette|upright=2.5|Relocation.]]
La relocation matérielle fait que la relocation est faite par le processeur, pas par l'OS. La relocation est intégrée dans le processeur par l'intégration d'un registre : le '''registre de base''', aussi appelé '''registre de relocation'''. Il mémorise l'adresse à laquelle commence le segment, la première adresse du programme. Pour effectuer la relocation, le processeur ajoute automatiquement l'adresse de base à chaque accès mémoire, en allant la chercher dans le registre de relocation.
[[File:Registre de base de segment.png|centre|vignette|upright=2|Registre de base de segment.]]
Le processeur s'occupe de la relocation des segments et le programme compilé n'en voit rien. Pour le dire autrement, les programmes manipulent des adresses logiques, qui sont traduites par le processeur en adresses physiques. La traduction se fait en ajoutant le contenu du registre de relocation à l'adresse logique. De plus, cette méthode fait que chaque programme a son propre espace d'adressage.
[[File:CPU created logical address presentation.png|centre|vignette|upright=2|Traduction d'adresse avec la relocation matérielle.]]
Le système d'exploitation mémorise les adresses de base pour chaque programme, dans la table des segments. Le registre de base est mis à jour automatiquement lors de chaque changement de segment. Pour cela, le registre de base est accessible via certaines instructions, accessibles en espace noyau, plus rarement en espace utilisateur. Le registre de segment est censé être adressé implicitement, vu qu'il est unique. Si ce n'est pas le cas, il est possible d'écrire dans ce registre de segment, qui est alors adressable.
===La protection mémoire avec la relocation matérielle : le registre limite===
Sans restrictions supplémentaires, la taille maximale d'un segment est égale à la taille complète de l'espace d'adressage. Sur les processeurs 32 bits, un segment a une taille maximale de 2^32 octets, soit 4 gibioctets. Mais il est possible de limiter la taille du segment à 2 gibioctets, 1 gibioctet, 64 Kibioctets, ou toute autre taille. La limite est définie lors de la création du segment, mais elle peut cependant évoluer au cours de l'exécution du programme, grâce à l'allocation mémoire. Le processeur vérifie à chaque accès mémoire que celui-ci se fait bien dans le segment, en comparant l'adresse accédée à l'adresse de base et l'adresse maximale, l'adresse limite.
Limiter la taille d'un segment demande soit de mémoriser sa taille, soit de mémoriser l'adresse limite (l'adresse de fin de segment, l'adresse limite à ne pas dépasser). Les deux sont possibles et marchent parfaitement, le choix entre les deux solutions est une pure question de préférence. A la rigueur, la vérification des débordements est légèrement plus rapide si on utilise l'adresse de fin du segment. Précisons que l'adresse limite est une adresse logique, le segment commence toujours à l'adresse logique zéro.
Pour cela, la table des segments doit être modifiée. Au lieu de ne contenir que l'adresse de base, elle contient soit l'adresse maximale du segment, soit la taille du segment. En clair, le descripteur de segment est enrichi avec l'adresse limite. 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.
Techniquement, il y a une petite différence de vitesse entre utiliser la taille et l'adresse maximale. Vérifier les débordements avec la taille demande juste de comparer la taille avec l'adresse logique, avant relocation, ce qui peut être fait en parallèle de la relocation. Par contre, l'adresse limite est comparée à une adresse physique, ce qui demande de faire la relocation avant la vérification, ce qui prend un peu plus de temps. Mais l'impact sur les performances est des plus mineurs.
[[File:Registre limite.png|centre|vignette|upright=2|Registre limite]]
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> </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>+ </code><code style="background:#DDF">0001 0010 0011 0100</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </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. Au lieu d'utiliser un additionneur séparé pour le ''program counter'' et un autre pour le calcul de l'adresse physique, un seul additionneur est utilisé pour les deux. L'idée était de partager l'additionneur, qui servait à la fois à incrémenter le ''program counter'' et pour gérer la segmentation. 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|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> </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>+ </code><code style="background:#DDF">1111 1111 1111 1111</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </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 de nos PCs, à partir du 286 d'Intel. 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. A 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. A la place, les segments sont adressés de manière indirecte. A 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.
===La protection mémoire : les accès hors-segments===
Comme avec la relocation matérielle, le processeur utilise l'adresse ou la taille limite pour vérifier si l'accès mémoire ne déborde pas en-dehors du segment en cours. Pour cela, le processeur compare l'adresse logique accédée avec l'adresse limite, ou compare la taille limite avec le décalage. L'information est lue depuis la table des segments à chaque accès.
[[File:Vm7.svg|centre|vignette|upright=2|Traduction d'adresse avec vérification des accès hors-segment.]]
Par contre, une nouveauté fait son apparition avec la segmentation : la '''gestion des droits d'accès'''. 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. 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.
L'implémentation de la protection mémoire dépend du CPU considéré. Mais en général, elle se repose sur 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. Les trois étapes sont réalisées en au moins une micro-opération chacune, souvent plus.
Les CPU microcodés peuvent en théorie utiliser le microcode pour tester si telle ou telle erreur survient. Il suffit que le microcode intègre des micro-branchements pour cela. Par exemple, une écriture va tester le bit R/W du descripteur, qui indique si on peut écrire dans le segment. Le micro-branchement enverra vers une routine du microcode en cas d'erreur. Mais les performances sont alors rarement au rendez-vous. La raison est que les tests de protection mémoire demandent de tester beaucoup de conditions différentes. Par exemple, le CPU Intel 386 testait moins d'une dizaine de conditions pour certaines instructions.
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.
====L'implémentation de la protection mémoire sur le 386====
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 protection mémoire teste la valeur des bits P, S, X, E, R/W, et peut aussi modifier le bit A. 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 n'étaient pas pertinentes à un instant t. Par exemple, il est pertinent de vérifier si le bit R/W était cohérent avec le fait que l'instruction en cours d'exécution 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 : soit autorisait l'exécution de la lecture/écriture, soit levait une exception.
* Une adresse de 12 bits, pointant dans le microcode, sur un code levant une exception en cas d'erreur.
* 4 bits pouvant être testés par un branchement dans le microcode, qui demandaient :
** soit de modifier le bit A du descripteur de segment (pas d'erreur, on accède au segment) ;
** soit de tester s'il y a un accès hors-limite ;
** de signaler une instruction sur la pile.
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 ''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. A 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]]
A 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. A 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. A la place, l'entrée contient un '''bit ''global''''', qui précise que cette correspondance est valide pour tous les processus. Le bit global accélère rapidement la traduction d'adresse pour l'accès au noyau.
Un défaut de cette méthode est que le partage d'une page entre plusieurs processus est presque impossible. Impossible de partager une page avec seulement certains processus et pas d'autres : soit on partage une page avec tous les processus, soit on l'alloue avec un seul processus.
====L'usage de plusieurs tables des pages====
Une solution alternative, plus simple, utilise une table des pages par processus lancé sur l'ordinateur, une table des pages unique par espace d'adressage. À chaque changement de processus, le registre qui mémorise la position de la table des pages est modifié pour pointer sur la bonne. C'est le système d'exploitation qui se charge de cette mise à jour.
Avec cette méthode, il est possible de partager une ou plusieurs pages entre plusieurs processus, en configurant les tables des pages convenablement. Les pages partagées sont mappées dans l'espace d'adressage de plusieurs processus, mais pas forcément au même endroit, pas forcément dans les mêmes adresses logiques. On peut placer la page partagée à l'adresse logique 0x0FFF pour un processus, à l'adresse logique 0xFF00 pour un autre processus, etc. Par contre, les entrées de la table des pages pour ces adresses pointent vers la même adresse physique.
[[File:Vm5.png|centre|vignette|upright=2|Tables des pages de plusieurs processus.]]
===La taille des pages===
La taille des pages varie suivant le processeur et le système d'exploitation et tourne souvent autour de 4 kibioctets. Les processeurs actuels gèrent plusieurs tailles différentes pour les pages : 4 kibioctets par défaut, 2 mébioctets, voire 1 à 4 gibioctets pour les pages les plus larges. Les pages de 4 kibioctets sont les pages par défaut, les autres tailles de page sont appelées des ''pages larges''. La taille optimale pour les pages dépend de nombreux paramètres et il n'y a pas de taille qui convienne à tout le monde. Certaines applications gagnent à utiliser des pages larges, d'autres vont au contraire perdre drastiquement en performance en les utilisant.
Le désavantage principal des pages larges est qu'elles favorisent la fragmentation mémoire. Si un programme veut réserver une portion de mémoire, pour une structure de donnée quelconque, il doit réserver une portion dont la taille est multiple de la taille d'une page. Par exemple, un programme ayant besoin de 110 kibioctets allouera 28 pages de 4 kibioctets, soit 120 kibioctets : 2 kibioctets seront perdus. Par contre, avec des pages larges de 2 mébioctets, on aura une perte de 2048 - 110 = 1938 kibioctets. En somme, des morceaux de mémoire seront perdus, car les pages sont trop grandes pour les données qu'on veut y mettre. Le résultat est que le programme qui utilise les pages larges utilisent plus de mémoire et ce d'autant plus qu'il utilise des données de petite taille. Un autre désavantage est qu'elles se marient mal avec certaines techniques d'optimisations de type ''copy-on-write''.
Mais l'avantage est que la traduction des adresses est plus performante. Une taille des pages plus élevée signifie moins de pages, donc des tables des pages plus petites. Et des pages des tables plus petites n'ont pas besoin de beaucoup de niveaux de hiérarchie, voire peuvent se limiter à des tables des pages simples, ce qui rend la traduction d'adresse plus simple et plus rapide. De plus, les programmes ont une certaine localité spatiale, qui font qu'ils accèdent souvent à des données proches. La traduction d'adresse peut alors profiter de systèmes de mise en cache dont nous parlerons dans le prochain chapitre, et ces systèmes de cache marchent nettement mieux avec des pages larges.
Il faut noter que la taille des pages est presque toujours une puissance de deux. Cela a de nombreux avantages, mais n'est pas une nécessité. Par exemple, le tout premier processeur avec de la pagination, le super-ordinateur Atlas, avait des pages de 3 kibioctets. L'avantage principal est que la traduction de l'adresse physique en adresse logique est trivial avec une puissance de deux. Cela garantit que l'on peut diviser l'adresse en un numéro de page et un ''offset'' : la traduction demande juste de remplacer les bits de poids forts par le numéro de page voulu. Sans cela, la traduction d'adresse implique des divisions et des multiplications, qui sont des opérations assez couteuses.
===Les entrées de la table des pages===
Avant de poursuivre, faisons un rapide rappel sur les entrées de la table des pages. Nous venons de voir que la table des pages contient de nombreuses informations : un bit ''valid'' pour la mémoire virtuelle, des bits ''dirty'' et ''accessed'' utilisés par l'OS, des bits de protection mémoire, un bit ''global'' et un potentiellement un identifiant de processus, etc. Étudions rapidement le format de la table des pages sur un processeur x86 32 bits.
* Elle contient d'abord le numéro de page physique.
* Les bits AVL sont inutilisés et peuvent être configurés à loisir par l'OS.
* Le bit G est le bit ''global''.
* Le bit PS vaut 0 pour une page de 4 kibioctets, mais est mis à 1 pour une page de 4 mébioctets dans le cas où le processus utilise des pages larges.
* Le bit D est le bit ''dirty''.
* Le bit A est le bit ''accessed''.
* Le bit PCD indique que la page ne peut pas être cachée, dans le sens où le processeur ne peut copier son contenu dans le cache et doit toujours lire ou écrire cette page directement dans la RAM.
* Le bit PWT indique que les écritures doivent mettre à jour le cache et la page en RAM (dans le chapitre sur le cache, on verra qu'il force le cache à se comporter comme un cache ''write-through'' pour cette page).
* Le bit U/S précise si la page est accessible en mode noyau ou utilisateur.
* Le bit R/W indique si la page est accessible en écriture, toutes les pages sont par défaut accessibles en lecture.
* Le bit P est le bit ''valid''.
[[File:PDE.png|centre|vignette|upright=2.5|Table des pages des processeurs Intel 32 bits.]]
==Comparaison des différentes techniques d'abstraction mémoire==
Pour résumer, l'abstraction mémoire permet de gérer : la relocation, la protection mémoire, l'isolation des processus, la mémoire virtuelle, l'extension de l'espace d'adressage, le partage de mémoire, etc. Elles sont souvent implémentées en même temps. Ce qui fait qu'elles sont souvent confondues, alors que ce sont des concepts sont différents. Ces liens sont résumés dans le tableau ci-dessous.
{|class="wikitable"
|-
!
! colspan="5" | Avec abstraction mémoire
! rowspan="2" | Sans abstraction mémoire
|-
!
! Relocation matérielle
! Segmentation en mode réel (x86)
! Segmentation, général
! Architectures à capacités
! Pagination
|-
! Abstraction matérielle des processus
| colspan="4" | Oui, relocation matérielle
| Oui, liée à la traduction d'adresse
| Impossible
|-
! Mémoire virtuelle
| colspan="2" | Non, sauf émulation logicielle
| colspan="3" | Oui, gérée par le processeur et l'OS
| Non, sauf émulation logicielle
|-
! Extension de l'espace d'adressage
| colspan="2" | Oui : registre de base élargi
| colspan="2" | Oui : adresse de base élargie dans la table des segments
| ''Physical Adress Extension'' des processeurs 32 bits
| Commutation de banques
|-
! Protection mémoire
| Registre limite
| Aucune
| colspan="2" | Registre limite, droits d'accès aux segments
| Gestion des droits d'accès aux pages
| Possible, méthodes variées
|-
! Partage de mémoire
| colspan="2" | Non
| colspan="2" | Segment partagés
| Pages partagées
| Possible, méthodes variées
|}
===Les différents types de segmentation===
La segmentation regroupe plusieurs techniques franchement différentes, qui auraient gagné à être nommées différemment. La principale différence est l'usage de registres de relocation versus des registres de sélecteurs de segments. L'usage de registres de relocation est le fait de la relocation matérielle, mais aussi de la segmentation en mode réel des CPU x86. Par contre, l'usage de sélecteurs de segments est le fait des autres formes de segmentation, architectures à capacité inclues.
La différence entre les deux est le nombre de segments. L'usage de registres de relocation fait que le CPU ne gère qu'un petit nombre de segments de grande taille. La mémoire virtuelle est donc rarement implémentée vu que swapper des segments de grande taille est trop long, l'impact sur les performances est trop important. Sans compter que l'usage de registres de base se marie très mal avec la mémoire virtuelle. Vu qu'un segment peut être swappé ou déplacée n'importe quand, il faut invalider les registres de base au moment du swap/déplacement, ce qui n'est pas chose aisée. Aucun processeur ne gère cela, les méthodes pour n'existent tout simplement pas. L'usage de registres de base implique que la mémoire virtuelle est absente.
La protection mémoire est aussi plus limitée avec l'usage de registres de relocation. Elle se limite à des registres limite, mais la gestion des droits d'accès est limitée. En théorie, la segmentation en mode réel pourrait implémenter une version limitée de protection mémoire, avec une protection de l'espace exécutable. Mais ca n'a jamais été fait en pratique sur les processeurs x86.
Le partage de la mémoire est aussi difficile sur les architectures avec des registres de base. L'absence de table des segments fait que le partage d'un segment est basiquement impossible sans utiliser des méthodes complétement tordues, qui ne sont jamais implémentées en pratique.
===Segmentation versus pagination===
Par rapport à la pagination, la segmentation a des avantages et des inconvénients. Tous sont liés aux propriétés des segments et pages : les segments sont de grande taille et de taille variable, les pages sont petites et de taille fixe.
L'avantage principal de la segmentation est sa rapidité. Le fait que les segments sont de grande taille fait qu'on a pas besoin d'équivalent aux tables des pages inversée ou multiple, juste d'une table des segments toute simple. De plus, les échanges entre table des pages/segments et registres sont plus rares avec la segmentation. Par exemple, si un programme utilise un segment de 2 gigas, tous les accès dans le segment se feront avec une seule consultation de la table des segments. Alors qu'avec la pagination, il faudra une consultation de la table des pages chaque bloc de 4 kibioctet, au minimum.
Mais les désavantages sont nombreux. Le système d'exploitation doit agencer les segments en RAM, et c'est une tâche complexe. Le fait que les segments puisse changer de taille rend le tout encore plus complexe. Par exemple, si on colle les segments les uns à la suite des autres, changer la taille d'un segment demande de réorganiser tous les segments en RAM, ce qui demande énormément de copies RAM-RAM. Une autre possibilité est de laisser assez d'espace entre les segments, mais cet espace est alors gâché, dans le sens où on ne peut pas y placer un nouveau segment.
Swapper un segment est aussi très long, vu que les segments sont de grande taille, alors que swapper une page est très rapide.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'espace d'adressage du processeur
| prevText=L'espace d'adressage du processeur
| next=Les méthodes de synchronisation entre processeur et périphériques
| nextText=Les méthodes de synchronisation entre processeur et périphériques
}}
</noinclude>
9ewx35wfmj35vi0hvdonjnflv5cwbls
763692
763691
2026-04-14T22:23:27Z
Mewtow
31375
/* L'implémentation de la protection mémoire sur le 386 */
763692
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 envoyées à 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 sachez qu'il existe 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 les adresses de l'espace d'adressage sont 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'abstraction mémoire implémente plusieurs fonctionnalités complémentaires==
L'utilité de l'abstraction matérielle n'est pas évidente, mais sachez qu'elle est si utile que tous les processeurs modernes la prennent en charge. Elle sert notamment à implémenter la mémoire virtuelle, que nous aborderons dans ce qui suit. La plupart de ces fonctionnalités manipulent la relation entre adresses logiques et physique. Dans le cas le plus simple, une adresse logique correspond à une seule adresse physique. Mais beaucoup de fonctionnalités avancées ne respectent pas cette règle.
===L'abstraction matérielle des processus===
Les systèmes d'exploitation modernes sont dits multi-tâche, à savoir qu'ils sont capables d'exécuter plusieurs logiciels en même temps. Et ce même si un seul processeur est présent dans l'ordinateur : les logiciels sont alors exécutés à tour de rôle. Toutefois, cela amène un paquet de problèmes qu'il faut résoudre au mieux. Par exemple, les programmes exécutés doivent se partager la mémoire RAM, ce qui ne vient pas sans problèmes. Le problème principal est que les programmes ne doivent pas lire ou écrire dans les données d'un autre, sans quoi on se retrouverait rapidement avec des problèmes. Il faut donc introduire des mécanismes d{{'}}'''isolement des processus''', pour isoler les programmes les uns des autres.
Un de ces mécanismes est l{{'}}'''abstraction matérielle des processus''', une technique qui fait que chaque programme a son propre espace d'adressage. Chaque programme a l'impression d'avoir accès à tout l'espace d'adressage, de l'adresse 0 à l'adresse maximale gérée par le processeur. Évidemment, il s'agit d'une illusion maintenue justement grâce à la traduction d'adresse. Les espaces d'adressage contiennent des adresses logiques, les adresses de la RAM sont des adresses physiques, la nécessité de l'abstraction mémoire est évidente.
Implémenter l'abstraction mémoire peut se faire de plusieurs manières. Mais dans tous les cas, il faut que la correspondance adresse logique - physique change d'un programme à l'autre. Ce qui est normal, vu que les deux processus sont placés à des endroits différents en RAM physique. La conséquence est qu'avec l'abstraction mémoire, une adresse logique correspond à plusieurs adresses physiques. Une même adresse logique dans deux processus différents correspond à deux adresses phsiques différentes, une par processus. Une adresse logique dans un processus correspondra à l'adresse physique X, la même adresse dans un autre processus correspondra à l'adresse Y.
Les adresses physiques qui partagent la même adresse logique sont alors appelées des '''adresses homonymes'''. Le choix de la bonne adresse étant réalisé par un mécanisme matériel et dépend du programme en cours. Le mécanisme pour choisir la bonne adresse dépend du processeur, mais il y en a deux grands types :
* La première consiste à utiliser l'identifiant de processus CPU, vu au chapitre précédent. C'est, pour rappel, un numéro attribué à chaque processus par le processeur. L'identifiant du processus en cours d'exécution est mémorisé dans un registre du processeur. La traduction d'adresse utilise cet identifiant, en plus de l'adresse logique, pour déterminer l'adresse physique.
* La seconde solution mémorise les correspondances adresses logiques-physique dans des tables en mémoire RAM, qui sont différentes pour chaque programme. Les tables sont accédées à chaque accès mémoire, afin de déterminer l'adresse physique.
===Le partage de la mémoire===
L'isolation des processus est très importante sur les systèmes d'exploitation modernes. Cependant, il existe quelques situations où elle doit être contournée ou du moins mise en pause. Les situations sont multiples : gestion de bibliothèques partagées, communication entre processus, usage de ''threads'', etc. Elles impliquent toutes un '''partage de mémoire''', à savoir qu'une portion de mémoire RAM est partagée entre plusieurs programmes. Le partage de mémoire est une sorte de brèche de l'isolation des processus, mais qui est autorisée car elle est utile.
Un cas intéressant est celui des '''bibliothèques partagées'''. Les bibliothèques sont des collections de fonctions regroupées ensemble, dans une seule unité de code. Un programme qui utilise une bibliothèque peut appeler n’importe quelle fonction présente dans la bibliothèque. La bibliothèque peut être simplement inclue dans le programme lui-même, on parle alors de bibliothèques statiques. De telles bibliothèques fonctionnent très bien, mais avec un petit défaut pour les bibliothèques très utilisées : plusieurs programmes qui utilisent la même bibliothèque vont chacun l'inclure dans leur code, ce qui fera doublon.
Pour éviter cela, les OS modernes gèrent des bibliothèques partagées, à savoir qu'un seul exemplaire de la bibliothèque est partagé entre plusieurs programmes. Chaque programme peut exécuter une fonction de la bibliothèque quand il le souhaite, en effectuant un branchement adéquat. Mais cela implique que la bibliothèque soit présente dans l'espace d'adressage du programme en question. Une bibliothèque est donc présente dans plusieurs espaces d'adressage, alors qu'il n'y en a qu'un seul exemplaire en mémoire RAM.
[[File:Ogg vorbis libs and application dia.svg|centre|vignette|upright=2|Exemple de bibliothèques, avec Ogg vorbis.]]
D'autres situations demandent de partager de la mémoire entre deux programmes. Par exemple, les systèmes d'exploitation modernes gèrent nativement des systèmes de '''communication inter-processus''', très utilisés par les programmes modernes pour échanger des données. Et la plupart demandant de partager un bout de mémoire entre processus, même si c'est seulement temporairement. Typiquement, deux processus partagent un intervalle d'adresse où l'un écrit les données à l'autre, l'autre lisant les données envoyées.
Une dernière utilisation de la mémoire partagée est l{{'}}'''accès direct au noyau'''. Sur les systèmes d'exploitations moderne, dans l'espace d'adressage de chaque programme, les adresses hautes sont remplies avec une partie du noyau ! Évidemment, ces adresses sont accessibles uniquement en lecture, pas en écriture. Pas question de modifier le noyau de l'OS ! De plus, il s'agit d'une portion du noyau dont on sait que la consultation ne pose pas de problèmes de sécurité.
Le programme peut lire des données dans cette portion du noyau, mais aussi exécuter les fonctions du noyau qui sont dedans. L'idée est d'éviter des appels systèmes trop fréquents. Au lieu d'effectuer un véritable appel système, avec une interruption logicielle, le programme peut exécuter des appels systèmes simplifiés, de simples appels de fonctions couplés avec un changement de niveau de privilège (passage en espace noyau nécessaire).
[[File:AMD64-canonical--48-bit.png|vignette|Répartition des adresses entre noyau (jaune/orange) et programme (verte), sur les systèmes x86-64 bits, avec des adresses physiques de 48 bits.]]
L'espace d'adressage est donc séparé en deux portions : l'OS d'un côté, le programme de l'autre. La répartition des adresses entre noyau et programme varie suivant l'OS ou le processeur utilisé. Sur les PC x86 32 bits, Linux attribuait 3 gigas pour les programmes et 1 giga pour le noyau, Windows attribuait 2 gigas à chacun. Sur les systèmes x86 64 bits, l'espace d'adressage d'un programme est coupé en trois, comme illustré ci-contre : une partie basse de 2^48 octets, une partie haute de même taille, et un bloc d'adresses invalides entre les deux. Les adresses basses sont utilisées pour le programme, les adresses hautes pour le noyau, il n'y a rien entre les deux.
Avec le partage de mémoire, plusieurs adresses logiques correspondent à la même adresse physique. Tel processus verra la zone de mémoire partagée à l'adresse X, l'autre la verra à l'adresse Y. Mais il s'agira de la même portion de mémoire physique, avec une seule adresse physique. En clair, lorsque deux processus partagent une même zone de mémoire, la zone sera mappées à des adresses logiques différentes. Les adresses logiques sont alors appelées des '''adresses synonymes''', terme qui trahit le fait qu'elles correspondent à la même adresse physique.
===La mémoire virtuelle===
Toutes les adresses ne sont pas forcément occupées par de la mémoire RAM, s'il n'y a pas assez de RAM installée. Par exemple, un processeur 32 bits peut adresser 4 gibioctets de RAM, même si seulement 3 gibioctets sont installés dans l'ordinateur. L'espace d'adressage contient donc 1 gigas d'adresses inutilisées, et il faut éviter ce surplus d'adresses pose problème.
Sans mémoire virtuelle, seule la mémoire réellement installée est utilisable. Si un programme utilise trop de mémoire, il est censé se rendre compte qu'il n'a pas accès à tout l'espace d'adressage. Quand il demandera au système d'exploitation de lui réserver de la mémoire, le système d'exploitation le préviendra qu'il n'y a plus de mémoire libre. Par exemple, si un programme tente d'utiliser 4 gibioctets sur un ordinateur avec 3 gibioctets de mémoire, il ne pourra pas. Pareil s'il veut utiliser 2 gibioctets de mémoire sur un ordinateur avec 4 gibioctets, mais dont 3 gibioctets sont déjà utilisés par d'autres programmes. Dans les deux cas, l'illusion tombe à plat.
Les techniques de '''mémoire virtuelle''' font que l'espace d'adressage est utilisable au complet, même s'il n'y a pas assez de mémoire installée dans l'ordinateur ou que d'autres programmes utilisent de la RAM. Par exemple, sur un processeur 32 bits, le programme aura accès à 4 gibioctets de RAM, même si d'autres programmes utilisent la RAM, même s'il n'y a que 2 gibioctets de RAM d'installés dans l'ordinateur.
Pour cela, on utilise une partie des mémoires de masse (disques durs) d'un ordinateur en remplacement de la mémoire physique manquante. Le système d'exploitation crée sur le disque dur un fichier, appelé le ''swapfile'' ou '''fichier de ''swap''''', qui est utilisé comme mémoire RAM supplémentaire. Il mémorise le surplus de données et de programmes qui ne peut pas être mis en mémoire RAM.
[[File:Vm1.png|centre|vignette|upright=2.0|Mémoire virtuelle et fichier de Swap.]]
Une technique naïve de mémoire virtuelle serait la suivante. Avant de l'aborder, précisons qu'il s'agit d'une technique abordée à but pédagogique, mais qui n'est implémentée nulle part tellement elle est lente et inefficace. Un espace d'adressage de 4 gigas ne contient que 3 gigas de RAM, ce qui fait 1 giga d'adresses inutilisées. Les accès mémoire aux 3 gigas de RAM se font normalement, mais l'accès aux adresses inutilisées lève une exception matérielle "Memory Unavailable". La routine d'interruption de cette exception accède alors au ''swapfile'' et récupère les données associées à cette adresse. La mémoire virtuelle est alors émulée par le système d'exploitation.
Le défaut de cette méthode est que l'accès au giga manquant est toujours très lent, parce qu'il se fait depuis le disque dur. D'autres techniques de mémoire virtuelle logicielle font beaucoup mieux, mais nous allons les passer sous silence, vu qu'on peut faire mieux, avec l'aide du matériel.
L'idée est de charger les données dont le programme a besoin dans la RAM, et de déplacer les autres sur le disque dur. Par exemple, imaginons la situation suivante : un programme a besoin de 4 gigas de mémoire, mais ne dispose que de 2 gigas de mémoire installée. On peut imaginer découper l'espace d'adressage en 2 blocs de 2 gigas, qui sont chargés à la demande. Si le programme accède aux adresses basses, on charge les 2 gigas d'adresse basse en RAM. S'il accède aux adresses hautes, on charge les 2 gigas d'adresse haute dans la RAM après avoir copié les adresses basses sur le ''swapfile''.
On perd du temps dans les copies de données entre RAM et ''swapfile'', mais on gagne en performance vu que tous les accès mémoire se font en RAM. Du fait de la localité temporelle, le programme utilise les données chargées depuis le swapfile durant un bon moment avant de passer au bloc suivant. La RAM est alors utilisée comme une sorte de cache alors que les données sont placées dans une mémoire fictive représentée par l'espace d'adressage et qui correspond au disque dur.
Mais avec cette technique, la correspondance entre adresses du programme et adresses de la RAM change au cours du temps. Les adresses de la RAM correspondent d'abord aux adresses basses, puis aux adresses hautes, et ainsi de suite. On a donc besoin d'abstraction mémoire. Les correspondances entre adresse logique et physique peuvent varier avec le temps, ce qui permet de déplacer des données de la RAM vers le disque dur ou inversement. Une adresse logique peut correspondre à une adresse physique, ou bien à une donnée swappée sur le disque dur. C'est l'unité de traduction d'adresse qui se charge de faire la différence. Si une correspondance entre adresse logique et physique est trouvée, elle l'utilise pour traduire les adresses. Si aucune correspondance n'est trouvée, alors elle laisse la main au système d'exploitation pour charger la donnée en RAM. Une fois la donnée chargée en RAM, les correspondances entre adresse logique et physiques sont modifiées de manière à ce que l'adresse logique pointe vers la donnée chargée.
===L'extension d'adressage===
Une autre fonctionnalité rendue possible par l'abstraction mémoire est l{{'}}'''extension d'adressage'''. Elle permet d'utiliser plus de mémoire que l'espace d'adressage ne le permet. Par exemple, utiliser 7 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'extension d'adresse est l'exact inverse de la mémoire virtuelle. La mémoire virtuelle sert quand on a moins de mémoire que d'adresses, l'extension d'adresse sert quand on a plus de mémoire que d'adresses.
Il y a quelques chapitres, nous avions vu que c'est possible via la commutation de banques. Mais l'abstraction mémoire est une méthode alternative. Que ce soit avec la commutation de banques ou avec l'abstraction mémoire, les adresses envoyées à la mémoire doivent être plus longues que les adresses gérées par le processeur. La différence est que l'abstraction mémoire étend les adresses d'une manière différente.
Une implémentation possible de l'extension d'adressage fait usage de l'abstraction matérielle des processus. Chaque processus a son propre espace d'adressage, mais ceux-ci sont placés à des endroits différents dans la mémoire physique. Par exemple, sur un ordinateur avec 16 gigas de RAM, mais un espace d'adressage de 2 gigas, on peut remplir la RAM en lançant 8 processus différents et chaque processus aura accès à un bloc de 2 gigas de RAM, pas plus, il ne peut pas dépasser cette limite. Ainsi, chaque processus est limité par son espace d'adressage, mais on remplit la mémoire avec plusieurs processus, ce qui compense. Il s'agit là de l'implémentation la plus simple, qui a en plus l'avantage d'avoir la meilleure compatibilité logicielle. De simples changements dans le système d'exploitation suffisent à l'implémenter.
[[File:Extension de l'espace d'adressage.png|centre|vignette|upright=1.5|Extension de l'espace d'adressage]]
Un autre implémentation donne plusieurs espaces d'adressage différents à chaque processus, et a donc accès à autant de mémoire que permis par la somme de ces espaces d'adressage. Par exemple, sur un ordinateur avec 16 gigas de RAM et un espace d'adressage de 4 gigas, un programme peut utiliser toute la RAM en utilisant 4 espaces d'adressage distincts. On passe d'un espace d'adressage à l'autre en changeant la correspondance adresse logique-physique. L'inconvénient est que la compatibilité logicielle est assez mauvaise. Modifier l'OS ne suffit pas, les programmeurs doivent impérativement concevoir leurs programmes pour qu'ils utilisent explicitement plusieurs espaces d'adressage.
Les deux implémentations font usage des adresses logiques homonymes, mais à l'intérieur d'un même processus. Pour rappel, cela veut dire qu'une adresse logique correspond à des adresses physiques différentes. Rien d'étonnant vu qu'on utilise plusieurs espaces d'adressage, comme pour l'abstraction des processus, sauf que cette fois-ci, on a plusieurs espaces d'adressage par processus. Prenons l'exemple où on a 8 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'idée est qu'une adresse correspondra à une adresse dans les premiers 4 gigas, ou dans les seconds 4 gigas. L'adresse logique X correspondra d'abord à une adresse physique dans les premiers 4 gigas, puis à une adresse physique dans les seconds 4 gigas.
===La protection mémoire===
La '''protection mémoire''' regroupe des techniques très différentes les unes des autres, qui visent à améliorer la sécurité des programmes et des systèmes d'exploitation. Elles visent à empêcher de lire, d'écrire ou d'exécuter certaines portions de mémoire. Sans elle, les programmes peuvent techniquement lire ou écrire les données des autres, ce qui causent des situations non-prévues par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses.
La première technique de protection mémoire est l{{'}}'''isolation des processus''', qu'on a vue plus haut. Elle garantit que chaque programme n'a accès qu'à certaines portions dédiées de la mémoire et rend le reste de la mémoire inaccessible en lecture et en écriture. Le système d'exploitation attribue à chaque programme une ou plusieurs portions de mémoire rien que pour lui, auquel aucun autre programme ne peut accéder. Un tel programme, isolé des autres, s'appelle un '''processus''', d'où le nom de cet objectif. Toute tentative d'accès à une partie de la mémoire non autorisée déclenche une exception matérielle (rappelez-vous le chapitre sur les interruptions) qui est traitée par une routine du système d'exploitation. Généralement, le programme fautif est sauvagement arrêté et un message d'erreur est affiché à l'écran.
La '''protection de l'espace exécutable''' empêche d’exécuter quoique ce soit provenant de certaines zones de la mémoire. En effet, certaines portions de la mémoire sont censées contenir uniquement des données, sans aucun programme ou code exécutable. Cependant, des virus informatiques peuvent se cacher dedans et d’exécuter depuis celles-ci. Ou encore, des failles de sécurités peuvent permettre à un attaquant d'injecter du code exécutable malicieux dans des données, ce qui peut lui permettre de lire les données manipulées par un programme, prendre le contrôle de la machine, injecter des virus, ou autre. Pour éviter cela, le système d'exploitation peut marquer certaines zones mémoire comme n'étant pas exécutable. Toute tentative d’exécuter du code localisé dans ces zones entraîne la levée d'une exception ou d'une erreur et le système d'exploitation réagit en conséquence. Là encore, le processeur doit détecter les exécutions non autorisées.
D'autres méthodes de protection mémoire visent à limiter des actions dangereuses. Pour cela, le processeur et l'OS gèrent des '''droits d'accès''', qui interdisent certaines actions pour des programmes non-autorisés. Lorsqu'on exécute une opération interdite, le système d’exploitation et/ou le processeur réagissent en conséquence. La première technique de ce genre n'est autre que la séparation entre espace noyau et utilisateur, vue dans le chapitre sur les interruptions. Mais il y en a d'autres, comme nous le verrons dans ce chapitre.
==La MMU==
La traduction des adresses logiques en adresses physiques se fait par un circuit spécialisé appelé la '''''Memory Management Unit''''' (MMU), qui est souvent intégré directement dans l'interface mémoire. La MMU est souvent associée à une ou plusieurs mémoires caches, qui visent à accélérer la traduction d'adresses logiques en adresses physiques. En effet, nous verrons plus bas que la traduction d'adresse demande d'accéder à des tableaux, gérés par le système d'exploitation, qui sont en mémoire RAM. Aussi, les processeurs modernes incorporent des mémoires caches appelées des '''''Translation Lookaside Buffers''''', ou encore TLB. Nous nous pouvons pas parler des TLB pour le moment, car nous n'avons pas encore abordé le chapitre sur les mémoires caches, mais un chapitre entier sera dédié aux TLB d'ici peu.
[[File:MMU principle updated.png|centre|vignette|upright=2|MMU.]]
===Les MMU intégrées au processeur===
D'ordinaire, la MMU est intégrée au processeur. Et elle peut l'être de deux manières. La première en fait un circuit séparé, relié au bus d'adresse. La seconde fusionne la MMU avec l'unité de calcul d'adresse. La première solution est surtout utilisée avec une technique d'abstraction mémoire appelée la pagination, alors que l'autre l'est avec une autre méthode appelée la segmentation. La raison est que la traduction d'adresse avec la segmentation est assez simple : elle demande d'additionner le contenu d'un registre avec l'adresse logique, ce qui est le genre de calcul qu'une unité de calcul d'adresse sait déjà faire. La fusion est donc assez évidente.
Pour donner un exemple, l'Intel 8086 fusionnait l'unité de calcul d'adresse et la MMU. Précisément, il utilisait un même additionneur pour incrémenter le ''program counter'' et effectuer des calculs d'adresse liés à la segmentation. Il aurait été logique d'ajouter les pointeurs de pile avec, mais ce n'était pas possible. La raison est que le pointeur de pile ne peut pas être envoyé directement sur le bus d'adresse, vu qu'il doit passer par une phase de traduction en adresse physique liée à la segmentation.
[[File:80186 arch.png|centre|vignette|upright=2|Intel 8086, microarchitecture.]]
===Les MMU séparées du processeur, sur la carte mère===
Il a existé des processeurs avec une MMU externe, soudée sur la carte mère.
Par exemple, les processeurs Motorola 68000 et 68010 pouvaient être combinés avec une MMU de type Motorola 68451. Elle supportait des versions simplifiées de la segmentation et de la pagination. Au minimum, elle ajoutait un support de la protection mémoire contre certains accès non-autorisés. La gestion de la mémoire virtuelle proprement dit n'était possible que si le processeur utilisé était un Motorola 68010, en raison de la manière dont le 68000 gérait ses accès mémoire. La MMU 68451 gérait un espace d'adressage de 16 mébioctets, découpé en maximum 32 pages/segments. On pouvait dépasser cette limite de 32 segments/pages en combinant plusieurs 68451.
Le Motorola 68851 était une MMU qui était prévue pour fonctionner de paire avec le Motorola 68020. Elle gérait la pagination pour un espace d'adressage de 32 bits.
Les processeurs suivants, les 68030, 68040, et 68060, avaient une MMU interne au processeur.
==La relocation matérielle==
Pour rappel, les systèmes d'exploitation moderne permettent de lancer plusieurs programmes en même temps et les laissent se partager la mémoire. Dans le cas le plus simple, qui n'est pas celui des OS modernes, le système d'exploitation découpe la mémoire en blocs d'adresses contiguës qui sont appelés des '''segments''', ou encore des ''partitions mémoire''. Les segments correspondent à un bloc de mémoire RAM. C'est-à-dire qu'un segment de 259 mébioctets sera un segment continu de 259 mébioctets dans la mémoire physique comme dans la mémoire logique. Dans ce qui suit, un segment contient un programme en cours d'exécution, comme illustré ci-dessous.
[[File:CPT Memory Addressable.svg|centre|vignette|upright=2|Espace d'adressage segmenté.]]
Le système d'exploitation mémorise la position de chaque segment en mémoire, ainsi que d'autres informations annexes. Le tout est regroupé dans la '''table de segment''', un tableau dont chaque case est attribuée à un programme/segment. La table des segments est un tableau numéroté, chaque segment ayant un numéro qui précise sa position dans le tableau. Chaque case, chaque entrée, contient un '''descripteur de segment''' qui regroupe plusieurs informations sur le segment : son adresse de base, sa taille, diverses informations.
===La relocation avec la relocation matérielle : le registre de base===
Un segment peut être placé n'importe où en RAM physique et sa position en RAM change à chaque exécution. Le programme est chargé à une adresse, celle du début du segment, qui change à chaque chargement du programme. Et toutes les adresses utilisées par le programme doivent être corrigées lors du chargement du programme, généralement par l'OS. Cette correction s'appelle la '''relocation''', et elle consiste à ajouter l'adresse de début du segment à chaque adresse manipulée par le programme.
[[File:Relocation assistée par matériel.png|centre|vignette|upright=2.5|Relocation.]]
La relocation matérielle fait que la relocation est faite par le processeur, pas par l'OS. La relocation est intégrée dans le processeur par l'intégration d'un registre : le '''registre de base''', aussi appelé '''registre de relocation'''. Il mémorise l'adresse à laquelle commence le segment, la première adresse du programme. Pour effectuer la relocation, le processeur ajoute automatiquement l'adresse de base à chaque accès mémoire, en allant la chercher dans le registre de relocation.
[[File:Registre de base de segment.png|centre|vignette|upright=2|Registre de base de segment.]]
Le processeur s'occupe de la relocation des segments et le programme compilé n'en voit rien. Pour le dire autrement, les programmes manipulent des adresses logiques, qui sont traduites par le processeur en adresses physiques. La traduction se fait en ajoutant le contenu du registre de relocation à l'adresse logique. De plus, cette méthode fait que chaque programme a son propre espace d'adressage.
[[File:CPU created logical address presentation.png|centre|vignette|upright=2|Traduction d'adresse avec la relocation matérielle.]]
Le système d'exploitation mémorise les adresses de base pour chaque programme, dans la table des segments. Le registre de base est mis à jour automatiquement lors de chaque changement de segment. Pour cela, le registre de base est accessible via certaines instructions, accessibles en espace noyau, plus rarement en espace utilisateur. Le registre de segment est censé être adressé implicitement, vu qu'il est unique. Si ce n'est pas le cas, il est possible d'écrire dans ce registre de segment, qui est alors adressable.
===La protection mémoire avec la relocation matérielle : le registre limite===
Sans restrictions supplémentaires, la taille maximale d'un segment est égale à la taille complète de l'espace d'adressage. Sur les processeurs 32 bits, un segment a une taille maximale de 2^32 octets, soit 4 gibioctets. Mais il est possible de limiter la taille du segment à 2 gibioctets, 1 gibioctet, 64 Kibioctets, ou toute autre taille. La limite est définie lors de la création du segment, mais elle peut cependant évoluer au cours de l'exécution du programme, grâce à l'allocation mémoire. Le processeur vérifie à chaque accès mémoire que celui-ci se fait bien dans le segment, en comparant l'adresse accédée à l'adresse de base et l'adresse maximale, l'adresse limite.
Limiter la taille d'un segment demande soit de mémoriser sa taille, soit de mémoriser l'adresse limite (l'adresse de fin de segment, l'adresse limite à ne pas dépasser). Les deux sont possibles et marchent parfaitement, le choix entre les deux solutions est une pure question de préférence. A la rigueur, la vérification des débordements est légèrement plus rapide si on utilise l'adresse de fin du segment. Précisons que l'adresse limite est une adresse logique, le segment commence toujours à l'adresse logique zéro.
Pour cela, la table des segments doit être modifiée. Au lieu de ne contenir que l'adresse de base, elle contient soit l'adresse maximale du segment, soit la taille du segment. En clair, le descripteur de segment est enrichi avec l'adresse limite. 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.
Techniquement, il y a une petite différence de vitesse entre utiliser la taille et l'adresse maximale. Vérifier les débordements avec la taille demande juste de comparer la taille avec l'adresse logique, avant relocation, ce qui peut être fait en parallèle de la relocation. Par contre, l'adresse limite est comparée à une adresse physique, ce qui demande de faire la relocation avant la vérification, ce qui prend un peu plus de temps. Mais l'impact sur les performances est des plus mineurs.
[[File:Registre limite.png|centre|vignette|upright=2|Registre limite]]
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> </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>+ </code><code style="background:#DDF">0001 0010 0011 0100</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </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. Au lieu d'utiliser un additionneur séparé pour le ''program counter'' et un autre pour le calcul de l'adresse physique, un seul additionneur est utilisé pour les deux. L'idée était de partager l'additionneur, qui servait à la fois à incrémenter le ''program counter'' et pour gérer la segmentation. 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|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> </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>+ </code><code style="background:#DDF">1111 1111 1111 1111</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </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 de nos PCs, à partir du 286 d'Intel. 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. A 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. A la place, les segments sont adressés de manière indirecte. A 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.
===La protection mémoire : les accès hors-segments===
Comme avec la relocation matérielle, le processeur utilise l'adresse ou la taille limite pour vérifier si l'accès mémoire ne déborde pas en-dehors du segment en cours. Pour cela, le processeur compare l'adresse logique accédée avec l'adresse limite, ou compare la taille limite avec le décalage. L'information est lue depuis la table des segments à chaque accès.
[[File:Vm7.svg|centre|vignette|upright=2|Traduction d'adresse avec vérification des accès hors-segment.]]
Par contre, une nouveauté fait son apparition avec la segmentation : la '''gestion des droits d'accès'''. 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. 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.
L'implémentation de la protection mémoire dépend du CPU considéré. Mais en général, elle se repose sur 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. Les trois étapes sont réalisées en au moins une micro-opération chacune, souvent plus.
Les CPU microcodés peuvent en théorie utiliser le microcode pour tester si telle ou telle erreur survient. Il suffit que le microcode intègre des micro-branchements pour cela. Par exemple, une écriture va tester le bit R/W du descripteur, qui indique si on peut écrire dans le segment. Le micro-branchement enverra vers une routine du microcode en cas d'erreur. Mais les performances sont alors rarement au rendez-vous. La raison est que les tests de protection mémoire demandent de tester beaucoup de conditions différentes. Par exemple, le CPU Intel 386 testait moins d'une dizaine de conditions pour certaines instructions.
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.
====L'implémentation de la protection mémoire sur le 386====
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 protection mémoire teste la valeur des bits P, S, X, E, R/W, et peut aussi modifier le bit A. 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 : soit autorisait l'exécution de la lecture/écriture, soit levait une exception.
* Une adresse de 12 bits, pointant dans le microcode, sur un code levant une exception en cas d'erreur.
* 4 bits pouvant être testés par un branchement dans le microcode, qui demandaient :
** soit de modifier le bit A du descripteur de segment (pas d'erreur, on accède au segment) ;
** soit de tester s'il y a un accès hors-limite ;
** de signaler une instruction sur la pile.
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 ''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. A 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]]
A 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. A 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. A la place, l'entrée contient un '''bit ''global''''', qui précise que cette correspondance est valide pour tous les processus. Le bit global accélère rapidement la traduction d'adresse pour l'accès au noyau.
Un défaut de cette méthode est que le partage d'une page entre plusieurs processus est presque impossible. Impossible de partager une page avec seulement certains processus et pas d'autres : soit on partage une page avec tous les processus, soit on l'alloue avec un seul processus.
====L'usage de plusieurs tables des pages====
Une solution alternative, plus simple, utilise une table des pages par processus lancé sur l'ordinateur, une table des pages unique par espace d'adressage. À chaque changement de processus, le registre qui mémorise la position de la table des pages est modifié pour pointer sur la bonne. C'est le système d'exploitation qui se charge de cette mise à jour.
Avec cette méthode, il est possible de partager une ou plusieurs pages entre plusieurs processus, en configurant les tables des pages convenablement. Les pages partagées sont mappées dans l'espace d'adressage de plusieurs processus, mais pas forcément au même endroit, pas forcément dans les mêmes adresses logiques. On peut placer la page partagée à l'adresse logique 0x0FFF pour un processus, à l'adresse logique 0xFF00 pour un autre processus, etc. Par contre, les entrées de la table des pages pour ces adresses pointent vers la même adresse physique.
[[File:Vm5.png|centre|vignette|upright=2|Tables des pages de plusieurs processus.]]
===La taille des pages===
La taille des pages varie suivant le processeur et le système d'exploitation et tourne souvent autour de 4 kibioctets. Les processeurs actuels gèrent plusieurs tailles différentes pour les pages : 4 kibioctets par défaut, 2 mébioctets, voire 1 à 4 gibioctets pour les pages les plus larges. Les pages de 4 kibioctets sont les pages par défaut, les autres tailles de page sont appelées des ''pages larges''. La taille optimale pour les pages dépend de nombreux paramètres et il n'y a pas de taille qui convienne à tout le monde. Certaines applications gagnent à utiliser des pages larges, d'autres vont au contraire perdre drastiquement en performance en les utilisant.
Le désavantage principal des pages larges est qu'elles favorisent la fragmentation mémoire. Si un programme veut réserver une portion de mémoire, pour une structure de donnée quelconque, il doit réserver une portion dont la taille est multiple de la taille d'une page. Par exemple, un programme ayant besoin de 110 kibioctets allouera 28 pages de 4 kibioctets, soit 120 kibioctets : 2 kibioctets seront perdus. Par contre, avec des pages larges de 2 mébioctets, on aura une perte de 2048 - 110 = 1938 kibioctets. En somme, des morceaux de mémoire seront perdus, car les pages sont trop grandes pour les données qu'on veut y mettre. Le résultat est que le programme qui utilise les pages larges utilisent plus de mémoire et ce d'autant plus qu'il utilise des données de petite taille. Un autre désavantage est qu'elles se marient mal avec certaines techniques d'optimisations de type ''copy-on-write''.
Mais l'avantage est que la traduction des adresses est plus performante. Une taille des pages plus élevée signifie moins de pages, donc des tables des pages plus petites. Et des pages des tables plus petites n'ont pas besoin de beaucoup de niveaux de hiérarchie, voire peuvent se limiter à des tables des pages simples, ce qui rend la traduction d'adresse plus simple et plus rapide. De plus, les programmes ont une certaine localité spatiale, qui font qu'ils accèdent souvent à des données proches. La traduction d'adresse peut alors profiter de systèmes de mise en cache dont nous parlerons dans le prochain chapitre, et ces systèmes de cache marchent nettement mieux avec des pages larges.
Il faut noter que la taille des pages est presque toujours une puissance de deux. Cela a de nombreux avantages, mais n'est pas une nécessité. Par exemple, le tout premier processeur avec de la pagination, le super-ordinateur Atlas, avait des pages de 3 kibioctets. L'avantage principal est que la traduction de l'adresse physique en adresse logique est trivial avec une puissance de deux. Cela garantit que l'on peut diviser l'adresse en un numéro de page et un ''offset'' : la traduction demande juste de remplacer les bits de poids forts par le numéro de page voulu. Sans cela, la traduction d'adresse implique des divisions et des multiplications, qui sont des opérations assez couteuses.
===Les entrées de la table des pages===
Avant de poursuivre, faisons un rapide rappel sur les entrées de la table des pages. Nous venons de voir que la table des pages contient de nombreuses informations : un bit ''valid'' pour la mémoire virtuelle, des bits ''dirty'' et ''accessed'' utilisés par l'OS, des bits de protection mémoire, un bit ''global'' et un potentiellement un identifiant de processus, etc. Étudions rapidement le format de la table des pages sur un processeur x86 32 bits.
* Elle contient d'abord le numéro de page physique.
* Les bits AVL sont inutilisés et peuvent être configurés à loisir par l'OS.
* Le bit G est le bit ''global''.
* Le bit PS vaut 0 pour une page de 4 kibioctets, mais est mis à 1 pour une page de 4 mébioctets dans le cas où le processus utilise des pages larges.
* Le bit D est le bit ''dirty''.
* Le bit A est le bit ''accessed''.
* Le bit PCD indique que la page ne peut pas être cachée, dans le sens où le processeur ne peut copier son contenu dans le cache et doit toujours lire ou écrire cette page directement dans la RAM.
* Le bit PWT indique que les écritures doivent mettre à jour le cache et la page en RAM (dans le chapitre sur le cache, on verra qu'il force le cache à se comporter comme un cache ''write-through'' pour cette page).
* Le bit U/S précise si la page est accessible en mode noyau ou utilisateur.
* Le bit R/W indique si la page est accessible en écriture, toutes les pages sont par défaut accessibles en lecture.
* Le bit P est le bit ''valid''.
[[File:PDE.png|centre|vignette|upright=2.5|Table des pages des processeurs Intel 32 bits.]]
==Comparaison des différentes techniques d'abstraction mémoire==
Pour résumer, l'abstraction mémoire permet de gérer : la relocation, la protection mémoire, l'isolation des processus, la mémoire virtuelle, l'extension de l'espace d'adressage, le partage de mémoire, etc. Elles sont souvent implémentées en même temps. Ce qui fait qu'elles sont souvent confondues, alors que ce sont des concepts sont différents. Ces liens sont résumés dans le tableau ci-dessous.
{|class="wikitable"
|-
!
! colspan="5" | Avec abstraction mémoire
! rowspan="2" | Sans abstraction mémoire
|-
!
! Relocation matérielle
! Segmentation en mode réel (x86)
! Segmentation, général
! Architectures à capacités
! Pagination
|-
! Abstraction matérielle des processus
| colspan="4" | Oui, relocation matérielle
| Oui, liée à la traduction d'adresse
| Impossible
|-
! Mémoire virtuelle
| colspan="2" | Non, sauf émulation logicielle
| colspan="3" | Oui, gérée par le processeur et l'OS
| Non, sauf émulation logicielle
|-
! Extension de l'espace d'adressage
| colspan="2" | Oui : registre de base élargi
| colspan="2" | Oui : adresse de base élargie dans la table des segments
| ''Physical Adress Extension'' des processeurs 32 bits
| Commutation de banques
|-
! Protection mémoire
| Registre limite
| Aucune
| colspan="2" | Registre limite, droits d'accès aux segments
| Gestion des droits d'accès aux pages
| Possible, méthodes variées
|-
! Partage de mémoire
| colspan="2" | Non
| colspan="2" | Segment partagés
| Pages partagées
| Possible, méthodes variées
|}
===Les différents types de segmentation===
La segmentation regroupe plusieurs techniques franchement différentes, qui auraient gagné à être nommées différemment. La principale différence est l'usage de registres de relocation versus des registres de sélecteurs de segments. L'usage de registres de relocation est le fait de la relocation matérielle, mais aussi de la segmentation en mode réel des CPU x86. Par contre, l'usage de sélecteurs de segments est le fait des autres formes de segmentation, architectures à capacité inclues.
La différence entre les deux est le nombre de segments. L'usage de registres de relocation fait que le CPU ne gère qu'un petit nombre de segments de grande taille. La mémoire virtuelle est donc rarement implémentée vu que swapper des segments de grande taille est trop long, l'impact sur les performances est trop important. Sans compter que l'usage de registres de base se marie très mal avec la mémoire virtuelle. Vu qu'un segment peut être swappé ou déplacée n'importe quand, il faut invalider les registres de base au moment du swap/déplacement, ce qui n'est pas chose aisée. Aucun processeur ne gère cela, les méthodes pour n'existent tout simplement pas. L'usage de registres de base implique que la mémoire virtuelle est absente.
La protection mémoire est aussi plus limitée avec l'usage de registres de relocation. Elle se limite à des registres limite, mais la gestion des droits d'accès est limitée. En théorie, la segmentation en mode réel pourrait implémenter une version limitée de protection mémoire, avec une protection de l'espace exécutable. Mais ca n'a jamais été fait en pratique sur les processeurs x86.
Le partage de la mémoire est aussi difficile sur les architectures avec des registres de base. L'absence de table des segments fait que le partage d'un segment est basiquement impossible sans utiliser des méthodes complétement tordues, qui ne sont jamais implémentées en pratique.
===Segmentation versus pagination===
Par rapport à la pagination, la segmentation a des avantages et des inconvénients. Tous sont liés aux propriétés des segments et pages : les segments sont de grande taille et de taille variable, les pages sont petites et de taille fixe.
L'avantage principal de la segmentation est sa rapidité. Le fait que les segments sont de grande taille fait qu'on a pas besoin d'équivalent aux tables des pages inversée ou multiple, juste d'une table des segments toute simple. De plus, les échanges entre table des pages/segments et registres sont plus rares avec la segmentation. Par exemple, si un programme utilise un segment de 2 gigas, tous les accès dans le segment se feront avec une seule consultation de la table des segments. Alors qu'avec la pagination, il faudra une consultation de la table des pages chaque bloc de 4 kibioctet, au minimum.
Mais les désavantages sont nombreux. Le système d'exploitation doit agencer les segments en RAM, et c'est une tâche complexe. Le fait que les segments puisse changer de taille rend le tout encore plus complexe. Par exemple, si on colle les segments les uns à la suite des autres, changer la taille d'un segment demande de réorganiser tous les segments en RAM, ce qui demande énormément de copies RAM-RAM. Une autre possibilité est de laisser assez d'espace entre les segments, mais cet espace est alors gâché, dans le sens où on ne peut pas y placer un nouveau segment.
Swapper un segment est aussi très long, vu que les segments sont de grande taille, alors que swapper une page est très rapide.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'espace d'adressage du processeur
| prevText=L'espace d'adressage du processeur
| next=Les méthodes de synchronisation entre processeur et périphériques
| nextText=Les méthodes de synchronisation entre processeur et périphériques
}}
</noinclude>
nru580p0deoqw637lo2qy8tcu89sp6m
763693
763692
2026-04-14T22:28:25Z
Mewtow
31375
/* La relocation avec la segmentation */
763693
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 envoyées à 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 sachez qu'il existe 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 les adresses de l'espace d'adressage sont 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'abstraction mémoire implémente plusieurs fonctionnalités complémentaires==
L'utilité de l'abstraction matérielle n'est pas évidente, mais sachez qu'elle est si utile que tous les processeurs modernes la prennent en charge. Elle sert notamment à implémenter la mémoire virtuelle, que nous aborderons dans ce qui suit. La plupart de ces fonctionnalités manipulent la relation entre adresses logiques et physique. Dans le cas le plus simple, une adresse logique correspond à une seule adresse physique. Mais beaucoup de fonctionnalités avancées ne respectent pas cette règle.
===L'abstraction matérielle des processus===
Les systèmes d'exploitation modernes sont dits multi-tâche, à savoir qu'ils sont capables d'exécuter plusieurs logiciels en même temps. Et ce même si un seul processeur est présent dans l'ordinateur : les logiciels sont alors exécutés à tour de rôle. Toutefois, cela amène un paquet de problèmes qu'il faut résoudre au mieux. Par exemple, les programmes exécutés doivent se partager la mémoire RAM, ce qui ne vient pas sans problèmes. Le problème principal est que les programmes ne doivent pas lire ou écrire dans les données d'un autre, sans quoi on se retrouverait rapidement avec des problèmes. Il faut donc introduire des mécanismes d{{'}}'''isolement des processus''', pour isoler les programmes les uns des autres.
Un de ces mécanismes est l{{'}}'''abstraction matérielle des processus''', une technique qui fait que chaque programme a son propre espace d'adressage. Chaque programme a l'impression d'avoir accès à tout l'espace d'adressage, de l'adresse 0 à l'adresse maximale gérée par le processeur. Évidemment, il s'agit d'une illusion maintenue justement grâce à la traduction d'adresse. Les espaces d'adressage contiennent des adresses logiques, les adresses de la RAM sont des adresses physiques, la nécessité de l'abstraction mémoire est évidente.
Implémenter l'abstraction mémoire peut se faire de plusieurs manières. Mais dans tous les cas, il faut que la correspondance adresse logique - physique change d'un programme à l'autre. Ce qui est normal, vu que les deux processus sont placés à des endroits différents en RAM physique. La conséquence est qu'avec l'abstraction mémoire, une adresse logique correspond à plusieurs adresses physiques. Une même adresse logique dans deux processus différents correspond à deux adresses phsiques différentes, une par processus. Une adresse logique dans un processus correspondra à l'adresse physique X, la même adresse dans un autre processus correspondra à l'adresse Y.
Les adresses physiques qui partagent la même adresse logique sont alors appelées des '''adresses homonymes'''. Le choix de la bonne adresse étant réalisé par un mécanisme matériel et dépend du programme en cours. Le mécanisme pour choisir la bonne adresse dépend du processeur, mais il y en a deux grands types :
* La première consiste à utiliser l'identifiant de processus CPU, vu au chapitre précédent. C'est, pour rappel, un numéro attribué à chaque processus par le processeur. L'identifiant du processus en cours d'exécution est mémorisé dans un registre du processeur. La traduction d'adresse utilise cet identifiant, en plus de l'adresse logique, pour déterminer l'adresse physique.
* La seconde solution mémorise les correspondances adresses logiques-physique dans des tables en mémoire RAM, qui sont différentes pour chaque programme. Les tables sont accédées à chaque accès mémoire, afin de déterminer l'adresse physique.
===Le partage de la mémoire===
L'isolation des processus est très importante sur les systèmes d'exploitation modernes. Cependant, il existe quelques situations où elle doit être contournée ou du moins mise en pause. Les situations sont multiples : gestion de bibliothèques partagées, communication entre processus, usage de ''threads'', etc. Elles impliquent toutes un '''partage de mémoire''', à savoir qu'une portion de mémoire RAM est partagée entre plusieurs programmes. Le partage de mémoire est une sorte de brèche de l'isolation des processus, mais qui est autorisée car elle est utile.
Un cas intéressant est celui des '''bibliothèques partagées'''. Les bibliothèques sont des collections de fonctions regroupées ensemble, dans une seule unité de code. Un programme qui utilise une bibliothèque peut appeler n’importe quelle fonction présente dans la bibliothèque. La bibliothèque peut être simplement inclue dans le programme lui-même, on parle alors de bibliothèques statiques. De telles bibliothèques fonctionnent très bien, mais avec un petit défaut pour les bibliothèques très utilisées : plusieurs programmes qui utilisent la même bibliothèque vont chacun l'inclure dans leur code, ce qui fera doublon.
Pour éviter cela, les OS modernes gèrent des bibliothèques partagées, à savoir qu'un seul exemplaire de la bibliothèque est partagé entre plusieurs programmes. Chaque programme peut exécuter une fonction de la bibliothèque quand il le souhaite, en effectuant un branchement adéquat. Mais cela implique que la bibliothèque soit présente dans l'espace d'adressage du programme en question. Une bibliothèque est donc présente dans plusieurs espaces d'adressage, alors qu'il n'y en a qu'un seul exemplaire en mémoire RAM.
[[File:Ogg vorbis libs and application dia.svg|centre|vignette|upright=2|Exemple de bibliothèques, avec Ogg vorbis.]]
D'autres situations demandent de partager de la mémoire entre deux programmes. Par exemple, les systèmes d'exploitation modernes gèrent nativement des systèmes de '''communication inter-processus''', très utilisés par les programmes modernes pour échanger des données. Et la plupart demandant de partager un bout de mémoire entre processus, même si c'est seulement temporairement. Typiquement, deux processus partagent un intervalle d'adresse où l'un écrit les données à l'autre, l'autre lisant les données envoyées.
Une dernière utilisation de la mémoire partagée est l{{'}}'''accès direct au noyau'''. Sur les systèmes d'exploitations moderne, dans l'espace d'adressage de chaque programme, les adresses hautes sont remplies avec une partie du noyau ! Évidemment, ces adresses sont accessibles uniquement en lecture, pas en écriture. Pas question de modifier le noyau de l'OS ! De plus, il s'agit d'une portion du noyau dont on sait que la consultation ne pose pas de problèmes de sécurité.
Le programme peut lire des données dans cette portion du noyau, mais aussi exécuter les fonctions du noyau qui sont dedans. L'idée est d'éviter des appels systèmes trop fréquents. Au lieu d'effectuer un véritable appel système, avec une interruption logicielle, le programme peut exécuter des appels systèmes simplifiés, de simples appels de fonctions couplés avec un changement de niveau de privilège (passage en espace noyau nécessaire).
[[File:AMD64-canonical--48-bit.png|vignette|Répartition des adresses entre noyau (jaune/orange) et programme (verte), sur les systèmes x86-64 bits, avec des adresses physiques de 48 bits.]]
L'espace d'adressage est donc séparé en deux portions : l'OS d'un côté, le programme de l'autre. La répartition des adresses entre noyau et programme varie suivant l'OS ou le processeur utilisé. Sur les PC x86 32 bits, Linux attribuait 3 gigas pour les programmes et 1 giga pour le noyau, Windows attribuait 2 gigas à chacun. Sur les systèmes x86 64 bits, l'espace d'adressage d'un programme est coupé en trois, comme illustré ci-contre : une partie basse de 2^48 octets, une partie haute de même taille, et un bloc d'adresses invalides entre les deux. Les adresses basses sont utilisées pour le programme, les adresses hautes pour le noyau, il n'y a rien entre les deux.
Avec le partage de mémoire, plusieurs adresses logiques correspondent à la même adresse physique. Tel processus verra la zone de mémoire partagée à l'adresse X, l'autre la verra à l'adresse Y. Mais il s'agira de la même portion de mémoire physique, avec une seule adresse physique. En clair, lorsque deux processus partagent une même zone de mémoire, la zone sera mappées à des adresses logiques différentes. Les adresses logiques sont alors appelées des '''adresses synonymes''', terme qui trahit le fait qu'elles correspondent à la même adresse physique.
===La mémoire virtuelle===
Toutes les adresses ne sont pas forcément occupées par de la mémoire RAM, s'il n'y a pas assez de RAM installée. Par exemple, un processeur 32 bits peut adresser 4 gibioctets de RAM, même si seulement 3 gibioctets sont installés dans l'ordinateur. L'espace d'adressage contient donc 1 gigas d'adresses inutilisées, et il faut éviter ce surplus d'adresses pose problème.
Sans mémoire virtuelle, seule la mémoire réellement installée est utilisable. Si un programme utilise trop de mémoire, il est censé se rendre compte qu'il n'a pas accès à tout l'espace d'adressage. Quand il demandera au système d'exploitation de lui réserver de la mémoire, le système d'exploitation le préviendra qu'il n'y a plus de mémoire libre. Par exemple, si un programme tente d'utiliser 4 gibioctets sur un ordinateur avec 3 gibioctets de mémoire, il ne pourra pas. Pareil s'il veut utiliser 2 gibioctets de mémoire sur un ordinateur avec 4 gibioctets, mais dont 3 gibioctets sont déjà utilisés par d'autres programmes. Dans les deux cas, l'illusion tombe à plat.
Les techniques de '''mémoire virtuelle''' font que l'espace d'adressage est utilisable au complet, même s'il n'y a pas assez de mémoire installée dans l'ordinateur ou que d'autres programmes utilisent de la RAM. Par exemple, sur un processeur 32 bits, le programme aura accès à 4 gibioctets de RAM, même si d'autres programmes utilisent la RAM, même s'il n'y a que 2 gibioctets de RAM d'installés dans l'ordinateur.
Pour cela, on utilise une partie des mémoires de masse (disques durs) d'un ordinateur en remplacement de la mémoire physique manquante. Le système d'exploitation crée sur le disque dur un fichier, appelé le ''swapfile'' ou '''fichier de ''swap''''', qui est utilisé comme mémoire RAM supplémentaire. Il mémorise le surplus de données et de programmes qui ne peut pas être mis en mémoire RAM.
[[File:Vm1.png|centre|vignette|upright=2.0|Mémoire virtuelle et fichier de Swap.]]
Une technique naïve de mémoire virtuelle serait la suivante. Avant de l'aborder, précisons qu'il s'agit d'une technique abordée à but pédagogique, mais qui n'est implémentée nulle part tellement elle est lente et inefficace. Un espace d'adressage de 4 gigas ne contient que 3 gigas de RAM, ce qui fait 1 giga d'adresses inutilisées. Les accès mémoire aux 3 gigas de RAM se font normalement, mais l'accès aux adresses inutilisées lève une exception matérielle "Memory Unavailable". La routine d'interruption de cette exception accède alors au ''swapfile'' et récupère les données associées à cette adresse. La mémoire virtuelle est alors émulée par le système d'exploitation.
Le défaut de cette méthode est que l'accès au giga manquant est toujours très lent, parce qu'il se fait depuis le disque dur. D'autres techniques de mémoire virtuelle logicielle font beaucoup mieux, mais nous allons les passer sous silence, vu qu'on peut faire mieux, avec l'aide du matériel.
L'idée est de charger les données dont le programme a besoin dans la RAM, et de déplacer les autres sur le disque dur. Par exemple, imaginons la situation suivante : un programme a besoin de 4 gigas de mémoire, mais ne dispose que de 2 gigas de mémoire installée. On peut imaginer découper l'espace d'adressage en 2 blocs de 2 gigas, qui sont chargés à la demande. Si le programme accède aux adresses basses, on charge les 2 gigas d'adresse basse en RAM. S'il accède aux adresses hautes, on charge les 2 gigas d'adresse haute dans la RAM après avoir copié les adresses basses sur le ''swapfile''.
On perd du temps dans les copies de données entre RAM et ''swapfile'', mais on gagne en performance vu que tous les accès mémoire se font en RAM. Du fait de la localité temporelle, le programme utilise les données chargées depuis le swapfile durant un bon moment avant de passer au bloc suivant. La RAM est alors utilisée comme une sorte de cache alors que les données sont placées dans une mémoire fictive représentée par l'espace d'adressage et qui correspond au disque dur.
Mais avec cette technique, la correspondance entre adresses du programme et adresses de la RAM change au cours du temps. Les adresses de la RAM correspondent d'abord aux adresses basses, puis aux adresses hautes, et ainsi de suite. On a donc besoin d'abstraction mémoire. Les correspondances entre adresse logique et physique peuvent varier avec le temps, ce qui permet de déplacer des données de la RAM vers le disque dur ou inversement. Une adresse logique peut correspondre à une adresse physique, ou bien à une donnée swappée sur le disque dur. C'est l'unité de traduction d'adresse qui se charge de faire la différence. Si une correspondance entre adresse logique et physique est trouvée, elle l'utilise pour traduire les adresses. Si aucune correspondance n'est trouvée, alors elle laisse la main au système d'exploitation pour charger la donnée en RAM. Une fois la donnée chargée en RAM, les correspondances entre adresse logique et physiques sont modifiées de manière à ce que l'adresse logique pointe vers la donnée chargée.
===L'extension d'adressage===
Une autre fonctionnalité rendue possible par l'abstraction mémoire est l{{'}}'''extension d'adressage'''. Elle permet d'utiliser plus de mémoire que l'espace d'adressage ne le permet. Par exemple, utiliser 7 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'extension d'adresse est l'exact inverse de la mémoire virtuelle. La mémoire virtuelle sert quand on a moins de mémoire que d'adresses, l'extension d'adresse sert quand on a plus de mémoire que d'adresses.
Il y a quelques chapitres, nous avions vu que c'est possible via la commutation de banques. Mais l'abstraction mémoire est une méthode alternative. Que ce soit avec la commutation de banques ou avec l'abstraction mémoire, les adresses envoyées à la mémoire doivent être plus longues que les adresses gérées par le processeur. La différence est que l'abstraction mémoire étend les adresses d'une manière différente.
Une implémentation possible de l'extension d'adressage fait usage de l'abstraction matérielle des processus. Chaque processus a son propre espace d'adressage, mais ceux-ci sont placés à des endroits différents dans la mémoire physique. Par exemple, sur un ordinateur avec 16 gigas de RAM, mais un espace d'adressage de 2 gigas, on peut remplir la RAM en lançant 8 processus différents et chaque processus aura accès à un bloc de 2 gigas de RAM, pas plus, il ne peut pas dépasser cette limite. Ainsi, chaque processus est limité par son espace d'adressage, mais on remplit la mémoire avec plusieurs processus, ce qui compense. Il s'agit là de l'implémentation la plus simple, qui a en plus l'avantage d'avoir la meilleure compatibilité logicielle. De simples changements dans le système d'exploitation suffisent à l'implémenter.
[[File:Extension de l'espace d'adressage.png|centre|vignette|upright=1.5|Extension de l'espace d'adressage]]
Un autre implémentation donne plusieurs espaces d'adressage différents à chaque processus, et a donc accès à autant de mémoire que permis par la somme de ces espaces d'adressage. Par exemple, sur un ordinateur avec 16 gigas de RAM et un espace d'adressage de 4 gigas, un programme peut utiliser toute la RAM en utilisant 4 espaces d'adressage distincts. On passe d'un espace d'adressage à l'autre en changeant la correspondance adresse logique-physique. L'inconvénient est que la compatibilité logicielle est assez mauvaise. Modifier l'OS ne suffit pas, les programmeurs doivent impérativement concevoir leurs programmes pour qu'ils utilisent explicitement plusieurs espaces d'adressage.
Les deux implémentations font usage des adresses logiques homonymes, mais à l'intérieur d'un même processus. Pour rappel, cela veut dire qu'une adresse logique correspond à des adresses physiques différentes. Rien d'étonnant vu qu'on utilise plusieurs espaces d'adressage, comme pour l'abstraction des processus, sauf que cette fois-ci, on a plusieurs espaces d'adressage par processus. Prenons l'exemple où on a 8 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'idée est qu'une adresse correspondra à une adresse dans les premiers 4 gigas, ou dans les seconds 4 gigas. L'adresse logique X correspondra d'abord à une adresse physique dans les premiers 4 gigas, puis à une adresse physique dans les seconds 4 gigas.
===La protection mémoire===
La '''protection mémoire''' regroupe des techniques très différentes les unes des autres, qui visent à améliorer la sécurité des programmes et des systèmes d'exploitation. Elles visent à empêcher de lire, d'écrire ou d'exécuter certaines portions de mémoire. Sans elle, les programmes peuvent techniquement lire ou écrire les données des autres, ce qui causent des situations non-prévues par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses.
La première technique de protection mémoire est l{{'}}'''isolation des processus''', qu'on a vue plus haut. Elle garantit que chaque programme n'a accès qu'à certaines portions dédiées de la mémoire et rend le reste de la mémoire inaccessible en lecture et en écriture. Le système d'exploitation attribue à chaque programme une ou plusieurs portions de mémoire rien que pour lui, auquel aucun autre programme ne peut accéder. Un tel programme, isolé des autres, s'appelle un '''processus''', d'où le nom de cet objectif. Toute tentative d'accès à une partie de la mémoire non autorisée déclenche une exception matérielle (rappelez-vous le chapitre sur les interruptions) qui est traitée par une routine du système d'exploitation. Généralement, le programme fautif est sauvagement arrêté et un message d'erreur est affiché à l'écran.
La '''protection de l'espace exécutable''' empêche d’exécuter quoique ce soit provenant de certaines zones de la mémoire. En effet, certaines portions de la mémoire sont censées contenir uniquement des données, sans aucun programme ou code exécutable. Cependant, des virus informatiques peuvent se cacher dedans et d’exécuter depuis celles-ci. Ou encore, des failles de sécurités peuvent permettre à un attaquant d'injecter du code exécutable malicieux dans des données, ce qui peut lui permettre de lire les données manipulées par un programme, prendre le contrôle de la machine, injecter des virus, ou autre. Pour éviter cela, le système d'exploitation peut marquer certaines zones mémoire comme n'étant pas exécutable. Toute tentative d’exécuter du code localisé dans ces zones entraîne la levée d'une exception ou d'une erreur et le système d'exploitation réagit en conséquence. Là encore, le processeur doit détecter les exécutions non autorisées.
D'autres méthodes de protection mémoire visent à limiter des actions dangereuses. Pour cela, le processeur et l'OS gèrent des '''droits d'accès''', qui interdisent certaines actions pour des programmes non-autorisés. Lorsqu'on exécute une opération interdite, le système d’exploitation et/ou le processeur réagissent en conséquence. La première technique de ce genre n'est autre que la séparation entre espace noyau et utilisateur, vue dans le chapitre sur les interruptions. Mais il y en a d'autres, comme nous le verrons dans ce chapitre.
==La MMU==
La traduction des adresses logiques en adresses physiques se fait par un circuit spécialisé appelé la '''''Memory Management Unit''''' (MMU), qui est souvent intégré directement dans l'interface mémoire. La MMU est souvent associée à une ou plusieurs mémoires caches, qui visent à accélérer la traduction d'adresses logiques en adresses physiques. En effet, nous verrons plus bas que la traduction d'adresse demande d'accéder à des tableaux, gérés par le système d'exploitation, qui sont en mémoire RAM. Aussi, les processeurs modernes incorporent des mémoires caches appelées des '''''Translation Lookaside Buffers''''', ou encore TLB. Nous nous pouvons pas parler des TLB pour le moment, car nous n'avons pas encore abordé le chapitre sur les mémoires caches, mais un chapitre entier sera dédié aux TLB d'ici peu.
[[File:MMU principle updated.png|centre|vignette|upright=2|MMU.]]
===Les MMU intégrées au processeur===
D'ordinaire, la MMU est intégrée au processeur. Et elle peut l'être de deux manières. La première en fait un circuit séparé, relié au bus d'adresse. La seconde fusionne la MMU avec l'unité de calcul d'adresse. La première solution est surtout utilisée avec une technique d'abstraction mémoire appelée la pagination, alors que l'autre l'est avec une autre méthode appelée la segmentation. La raison est que la traduction d'adresse avec la segmentation est assez simple : elle demande d'additionner le contenu d'un registre avec l'adresse logique, ce qui est le genre de calcul qu'une unité de calcul d'adresse sait déjà faire. La fusion est donc assez évidente.
Pour donner un exemple, l'Intel 8086 fusionnait l'unité de calcul d'adresse et la MMU. Précisément, il utilisait un même additionneur pour incrémenter le ''program counter'' et effectuer des calculs d'adresse liés à la segmentation. Il aurait été logique d'ajouter les pointeurs de pile avec, mais ce n'était pas possible. La raison est que le pointeur de pile ne peut pas être envoyé directement sur le bus d'adresse, vu qu'il doit passer par une phase de traduction en adresse physique liée à la segmentation.
[[File:80186 arch.png|centre|vignette|upright=2|Intel 8086, microarchitecture.]]
===Les MMU séparées du processeur, sur la carte mère===
Il a existé des processeurs avec une MMU externe, soudée sur la carte mère.
Par exemple, les processeurs Motorola 68000 et 68010 pouvaient être combinés avec une MMU de type Motorola 68451. Elle supportait des versions simplifiées de la segmentation et de la pagination. Au minimum, elle ajoutait un support de la protection mémoire contre certains accès non-autorisés. La gestion de la mémoire virtuelle proprement dit n'était possible que si le processeur utilisé était un Motorola 68010, en raison de la manière dont le 68000 gérait ses accès mémoire. La MMU 68451 gérait un espace d'adressage de 16 mébioctets, découpé en maximum 32 pages/segments. On pouvait dépasser cette limite de 32 segments/pages en combinant plusieurs 68451.
Le Motorola 68851 était une MMU qui était prévue pour fonctionner de paire avec le Motorola 68020. Elle gérait la pagination pour un espace d'adressage de 32 bits.
Les processeurs suivants, les 68030, 68040, et 68060, avaient une MMU interne au processeur.
==La relocation matérielle==
Pour rappel, les systèmes d'exploitation moderne permettent de lancer plusieurs programmes en même temps et les laissent se partager la mémoire. Dans le cas le plus simple, qui n'est pas celui des OS modernes, le système d'exploitation découpe la mémoire en blocs d'adresses contiguës qui sont appelés des '''segments''', ou encore des ''partitions mémoire''. Les segments correspondent à un bloc de mémoire RAM. C'est-à-dire qu'un segment de 259 mébioctets sera un segment continu de 259 mébioctets dans la mémoire physique comme dans la mémoire logique. Dans ce qui suit, un segment contient un programme en cours d'exécution, comme illustré ci-dessous.
[[File:CPT Memory Addressable.svg|centre|vignette|upright=2|Espace d'adressage segmenté.]]
Le système d'exploitation mémorise la position de chaque segment en mémoire, ainsi que d'autres informations annexes. Le tout est regroupé dans la '''table de segment''', un tableau dont chaque case est attribuée à un programme/segment. La table des segments est un tableau numéroté, chaque segment ayant un numéro qui précise sa position dans le tableau. Chaque case, chaque entrée, contient un '''descripteur de segment''' qui regroupe plusieurs informations sur le segment : son adresse de base, sa taille, diverses informations.
===La relocation avec la relocation matérielle : le registre de base===
Un segment peut être placé n'importe où en RAM physique et sa position en RAM change à chaque exécution. Le programme est chargé à une adresse, celle du début du segment, qui change à chaque chargement du programme. Et toutes les adresses utilisées par le programme doivent être corrigées lors du chargement du programme, généralement par l'OS. Cette correction s'appelle la '''relocation''', et elle consiste à ajouter l'adresse de début du segment à chaque adresse manipulée par le programme.
[[File:Relocation assistée par matériel.png|centre|vignette|upright=2.5|Relocation.]]
La relocation matérielle fait que la relocation est faite par le processeur, pas par l'OS. La relocation est intégrée dans le processeur par l'intégration d'un registre : le '''registre de base''', aussi appelé '''registre de relocation'''. Il mémorise l'adresse à laquelle commence le segment, la première adresse du programme. Pour effectuer la relocation, le processeur ajoute automatiquement l'adresse de base à chaque accès mémoire, en allant la chercher dans le registre de relocation.
[[File:Registre de base de segment.png|centre|vignette|upright=2|Registre de base de segment.]]
Le processeur s'occupe de la relocation des segments et le programme compilé n'en voit rien. Pour le dire autrement, les programmes manipulent des adresses logiques, qui sont traduites par le processeur en adresses physiques. La traduction se fait en ajoutant le contenu du registre de relocation à l'adresse logique. De plus, cette méthode fait que chaque programme a son propre espace d'adressage.
[[File:CPU created logical address presentation.png|centre|vignette|upright=2|Traduction d'adresse avec la relocation matérielle.]]
Le système d'exploitation mémorise les adresses de base pour chaque programme, dans la table des segments. Le registre de base est mis à jour automatiquement lors de chaque changement de segment. Pour cela, le registre de base est accessible via certaines instructions, accessibles en espace noyau, plus rarement en espace utilisateur. Le registre de segment est censé être adressé implicitement, vu qu'il est unique. Si ce n'est pas le cas, il est possible d'écrire dans ce registre de segment, qui est alors adressable.
===La protection mémoire avec la relocation matérielle : le registre limite===
Sans restrictions supplémentaires, la taille maximale d'un segment est égale à la taille complète de l'espace d'adressage. Sur les processeurs 32 bits, un segment a une taille maximale de 2^32 octets, soit 4 gibioctets. Mais il est possible de limiter la taille du segment à 2 gibioctets, 1 gibioctet, 64 Kibioctets, ou toute autre taille. La limite est définie lors de la création du segment, mais elle peut cependant évoluer au cours de l'exécution du programme, grâce à l'allocation mémoire. Le processeur vérifie à chaque accès mémoire que celui-ci se fait bien dans le segment, en comparant l'adresse accédée à l'adresse de base et l'adresse maximale, l'adresse limite.
Limiter la taille d'un segment demande soit de mémoriser sa taille, soit de mémoriser l'adresse limite (l'adresse de fin de segment, l'adresse limite à ne pas dépasser). Les deux sont possibles et marchent parfaitement, le choix entre les deux solutions est une pure question de préférence. A la rigueur, la vérification des débordements est légèrement plus rapide si on utilise l'adresse de fin du segment. Précisons que l'adresse limite est une adresse logique, le segment commence toujours à l'adresse logique zéro.
Pour cela, la table des segments doit être modifiée. Au lieu de ne contenir que l'adresse de base, elle contient soit l'adresse maximale du segment, soit la taille du segment. En clair, le descripteur de segment est enrichi avec l'adresse limite. 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.
Techniquement, il y a une petite différence de vitesse entre utiliser la taille et l'adresse maximale. Vérifier les débordements avec la taille demande juste de comparer la taille avec l'adresse logique, avant relocation, ce qui peut être fait en parallèle de la relocation. Par contre, l'adresse limite est comparée à une adresse physique, ce qui demande de faire la relocation avant la vérification, ce qui prend un peu plus de temps. Mais l'impact sur les performances est des plus mineurs.
[[File:Registre limite.png|centre|vignette|upright=2|Registre limite]]
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> </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>+ </code><code style="background:#DDF">0001 0010 0011 0100</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </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. Au lieu d'utiliser un additionneur séparé pour le ''program counter'' et un autre pour le calcul de l'adresse physique, un seul additionneur est utilisé pour les deux. L'idée était de partager l'additionneur, qui servait à la fois à incrémenter le ''program counter'' et pour gérer la segmentation. 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|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> </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>+ </code><code style="background:#DDF">1111 1111 1111 1111</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </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 de nos PCs, à partir du 286 d'Intel. 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. A 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. A la place, les segments sont adressés de manière indirecte. A 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 utilise l'adresse ou la taille limite pour vérifier si l'accès mémoire ne déborde pas en-dehors du segment en cours. Pour cela, le processeur compare l'adresse logique accédée avec l'adresse limite, ou compare la taille limite avec le décalage. L'information est lue depuis la table des segments à chaque accès.
[[File:Vm7.svg|centre|vignette|upright=2|Traduction d'adresse avec vérification des accès hors-segment.]]
Par contre, une nouveauté fait son apparition avec la segmentation : la '''gestion des droits d'accès'''. 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. 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.
L'implémentation de la protection mémoire dépend du CPU considéré. Mais en général, elle se repose sur 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. Les trois étapes sont réalisées en au moins une micro-opération chacune, souvent plus.
Les CPU microcodés peuvent en théorie utiliser le microcode pour tester si telle ou telle erreur survient. Il suffit que le microcode intègre des micro-branchements pour cela. Par exemple, une écriture va tester le bit R/W du descripteur, qui indique si on peut écrire dans le segment. Le micro-branchement enverra vers une routine du microcode en cas d'erreur. Mais les performances sont alors rarement au rendez-vous. La raison est que les tests de protection mémoire demandent de tester beaucoup de conditions différentes. Par exemple, le CPU Intel 386 testait moins d'une dizaine de conditions pour certaines instructions.
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.
====L'implémentation de la protection mémoire sur le 386====
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 protection mémoire teste la valeur des bits P, S, X, E, R/W, et peut aussi modifier le bit A. 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 : soit autorisait l'exécution de la lecture/écriture, soit levait une exception.
* Une adresse de 12 bits, pointant dans le microcode, sur un code levant une exception en cas d'erreur.
* 4 bits pouvant être testés par un branchement dans le microcode, qui demandaient :
** soit de modifier le bit A du descripteur de segment (pas d'erreur, on accède au segment) ;
** soit de tester s'il y a un accès hors-limite ;
** de signaler une instruction sur la pile.
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 ''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. A 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]]
A 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. A 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. A la place, l'entrée contient un '''bit ''global''''', qui précise que cette correspondance est valide pour tous les processus. Le bit global accélère rapidement la traduction d'adresse pour l'accès au noyau.
Un défaut de cette méthode est que le partage d'une page entre plusieurs processus est presque impossible. Impossible de partager une page avec seulement certains processus et pas d'autres : soit on partage une page avec tous les processus, soit on l'alloue avec un seul processus.
====L'usage de plusieurs tables des pages====
Une solution alternative, plus simple, utilise une table des pages par processus lancé sur l'ordinateur, une table des pages unique par espace d'adressage. À chaque changement de processus, le registre qui mémorise la position de la table des pages est modifié pour pointer sur la bonne. C'est le système d'exploitation qui se charge de cette mise à jour.
Avec cette méthode, il est possible de partager une ou plusieurs pages entre plusieurs processus, en configurant les tables des pages convenablement. Les pages partagées sont mappées dans l'espace d'adressage de plusieurs processus, mais pas forcément au même endroit, pas forcément dans les mêmes adresses logiques. On peut placer la page partagée à l'adresse logique 0x0FFF pour un processus, à l'adresse logique 0xFF00 pour un autre processus, etc. Par contre, les entrées de la table des pages pour ces adresses pointent vers la même adresse physique.
[[File:Vm5.png|centre|vignette|upright=2|Tables des pages de plusieurs processus.]]
===La taille des pages===
La taille des pages varie suivant le processeur et le système d'exploitation et tourne souvent autour de 4 kibioctets. Les processeurs actuels gèrent plusieurs tailles différentes pour les pages : 4 kibioctets par défaut, 2 mébioctets, voire 1 à 4 gibioctets pour les pages les plus larges. Les pages de 4 kibioctets sont les pages par défaut, les autres tailles de page sont appelées des ''pages larges''. La taille optimale pour les pages dépend de nombreux paramètres et il n'y a pas de taille qui convienne à tout le monde. Certaines applications gagnent à utiliser des pages larges, d'autres vont au contraire perdre drastiquement en performance en les utilisant.
Le désavantage principal des pages larges est qu'elles favorisent la fragmentation mémoire. Si un programme veut réserver une portion de mémoire, pour une structure de donnée quelconque, il doit réserver une portion dont la taille est multiple de la taille d'une page. Par exemple, un programme ayant besoin de 110 kibioctets allouera 28 pages de 4 kibioctets, soit 120 kibioctets : 2 kibioctets seront perdus. Par contre, avec des pages larges de 2 mébioctets, on aura une perte de 2048 - 110 = 1938 kibioctets. En somme, des morceaux de mémoire seront perdus, car les pages sont trop grandes pour les données qu'on veut y mettre. Le résultat est que le programme qui utilise les pages larges utilisent plus de mémoire et ce d'autant plus qu'il utilise des données de petite taille. Un autre désavantage est qu'elles se marient mal avec certaines techniques d'optimisations de type ''copy-on-write''.
Mais l'avantage est que la traduction des adresses est plus performante. Une taille des pages plus élevée signifie moins de pages, donc des tables des pages plus petites. Et des pages des tables plus petites n'ont pas besoin de beaucoup de niveaux de hiérarchie, voire peuvent se limiter à des tables des pages simples, ce qui rend la traduction d'adresse plus simple et plus rapide. De plus, les programmes ont une certaine localité spatiale, qui font qu'ils accèdent souvent à des données proches. La traduction d'adresse peut alors profiter de systèmes de mise en cache dont nous parlerons dans le prochain chapitre, et ces systèmes de cache marchent nettement mieux avec des pages larges.
Il faut noter que la taille des pages est presque toujours une puissance de deux. Cela a de nombreux avantages, mais n'est pas une nécessité. Par exemple, le tout premier processeur avec de la pagination, le super-ordinateur Atlas, avait des pages de 3 kibioctets. L'avantage principal est que la traduction de l'adresse physique en adresse logique est trivial avec une puissance de deux. Cela garantit que l'on peut diviser l'adresse en un numéro de page et un ''offset'' : la traduction demande juste de remplacer les bits de poids forts par le numéro de page voulu. Sans cela, la traduction d'adresse implique des divisions et des multiplications, qui sont des opérations assez couteuses.
===Les entrées de la table des pages===
Avant de poursuivre, faisons un rapide rappel sur les entrées de la table des pages. Nous venons de voir que la table des pages contient de nombreuses informations : un bit ''valid'' pour la mémoire virtuelle, des bits ''dirty'' et ''accessed'' utilisés par l'OS, des bits de protection mémoire, un bit ''global'' et un potentiellement un identifiant de processus, etc. Étudions rapidement le format de la table des pages sur un processeur x86 32 bits.
* Elle contient d'abord le numéro de page physique.
* Les bits AVL sont inutilisés et peuvent être configurés à loisir par l'OS.
* Le bit G est le bit ''global''.
* Le bit PS vaut 0 pour une page de 4 kibioctets, mais est mis à 1 pour une page de 4 mébioctets dans le cas où le processus utilise des pages larges.
* Le bit D est le bit ''dirty''.
* Le bit A est le bit ''accessed''.
* Le bit PCD indique que la page ne peut pas être cachée, dans le sens où le processeur ne peut copier son contenu dans le cache et doit toujours lire ou écrire cette page directement dans la RAM.
* Le bit PWT indique que les écritures doivent mettre à jour le cache et la page en RAM (dans le chapitre sur le cache, on verra qu'il force le cache à se comporter comme un cache ''write-through'' pour cette page).
* Le bit U/S précise si la page est accessible en mode noyau ou utilisateur.
* Le bit R/W indique si la page est accessible en écriture, toutes les pages sont par défaut accessibles en lecture.
* Le bit P est le bit ''valid''.
[[File:PDE.png|centre|vignette|upright=2.5|Table des pages des processeurs Intel 32 bits.]]
==Comparaison des différentes techniques d'abstraction mémoire==
Pour résumer, l'abstraction mémoire permet de gérer : la relocation, la protection mémoire, l'isolation des processus, la mémoire virtuelle, l'extension de l'espace d'adressage, le partage de mémoire, etc. Elles sont souvent implémentées en même temps. Ce qui fait qu'elles sont souvent confondues, alors que ce sont des concepts sont différents. Ces liens sont résumés dans le tableau ci-dessous.
{|class="wikitable"
|-
!
! colspan="5" | Avec abstraction mémoire
! rowspan="2" | Sans abstraction mémoire
|-
!
! Relocation matérielle
! Segmentation en mode réel (x86)
! Segmentation, général
! Architectures à capacités
! Pagination
|-
! Abstraction matérielle des processus
| colspan="4" | Oui, relocation matérielle
| Oui, liée à la traduction d'adresse
| Impossible
|-
! Mémoire virtuelle
| colspan="2" | Non, sauf émulation logicielle
| colspan="3" | Oui, gérée par le processeur et l'OS
| Non, sauf émulation logicielle
|-
! Extension de l'espace d'adressage
| colspan="2" | Oui : registre de base élargi
| colspan="2" | Oui : adresse de base élargie dans la table des segments
| ''Physical Adress Extension'' des processeurs 32 bits
| Commutation de banques
|-
! Protection mémoire
| Registre limite
| Aucune
| colspan="2" | Registre limite, droits d'accès aux segments
| Gestion des droits d'accès aux pages
| Possible, méthodes variées
|-
! Partage de mémoire
| colspan="2" | Non
| colspan="2" | Segment partagés
| Pages partagées
| Possible, méthodes variées
|}
===Les différents types de segmentation===
La segmentation regroupe plusieurs techniques franchement différentes, qui auraient gagné à être nommées différemment. La principale différence est l'usage de registres de relocation versus des registres de sélecteurs de segments. L'usage de registres de relocation est le fait de la relocation matérielle, mais aussi de la segmentation en mode réel des CPU x86. Par contre, l'usage de sélecteurs de segments est le fait des autres formes de segmentation, architectures à capacité inclues.
La différence entre les deux est le nombre de segments. L'usage de registres de relocation fait que le CPU ne gère qu'un petit nombre de segments de grande taille. La mémoire virtuelle est donc rarement implémentée vu que swapper des segments de grande taille est trop long, l'impact sur les performances est trop important. Sans compter que l'usage de registres de base se marie très mal avec la mémoire virtuelle. Vu qu'un segment peut être swappé ou déplacée n'importe quand, il faut invalider les registres de base au moment du swap/déplacement, ce qui n'est pas chose aisée. Aucun processeur ne gère cela, les méthodes pour n'existent tout simplement pas. L'usage de registres de base implique que la mémoire virtuelle est absente.
La protection mémoire est aussi plus limitée avec l'usage de registres de relocation. Elle se limite à des registres limite, mais la gestion des droits d'accès est limitée. En théorie, la segmentation en mode réel pourrait implémenter une version limitée de protection mémoire, avec une protection de l'espace exécutable. Mais ca n'a jamais été fait en pratique sur les processeurs x86.
Le partage de la mémoire est aussi difficile sur les architectures avec des registres de base. L'absence de table des segments fait que le partage d'un segment est basiquement impossible sans utiliser des méthodes complétement tordues, qui ne sont jamais implémentées en pratique.
===Segmentation versus pagination===
Par rapport à la pagination, la segmentation a des avantages et des inconvénients. Tous sont liés aux propriétés des segments et pages : les segments sont de grande taille et de taille variable, les pages sont petites et de taille fixe.
L'avantage principal de la segmentation est sa rapidité. Le fait que les segments sont de grande taille fait qu'on a pas besoin d'équivalent aux tables des pages inversée ou multiple, juste d'une table des segments toute simple. De plus, les échanges entre table des pages/segments et registres sont plus rares avec la segmentation. Par exemple, si un programme utilise un segment de 2 gigas, tous les accès dans le segment se feront avec une seule consultation de la table des segments. Alors qu'avec la pagination, il faudra une consultation de la table des pages chaque bloc de 4 kibioctet, au minimum.
Mais les désavantages sont nombreux. Le système d'exploitation doit agencer les segments en RAM, et c'est une tâche complexe. Le fait que les segments puisse changer de taille rend le tout encore plus complexe. Par exemple, si on colle les segments les uns à la suite des autres, changer la taille d'un segment demande de réorganiser tous les segments en RAM, ce qui demande énormément de copies RAM-RAM. Une autre possibilité est de laisser assez d'espace entre les segments, mais cet espace est alors gâché, dans le sens où on ne peut pas y placer un nouveau segment.
Swapper un segment est aussi très long, vu que les segments sont de grande taille, alors que swapper une page est très rapide.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'espace d'adressage du processeur
| prevText=L'espace d'adressage du processeur
| next=Les méthodes de synchronisation entre processeur et périphériques
| nextText=Les méthodes de synchronisation entre processeur et périphériques
}}
</noinclude>
squpaj5q3tbnonrbl2kvyv1b6ynvfpu
763694
763693
2026-04-14T22:39:24Z
Mewtow
31375
/* La protection mémoire : les accès hors-segments */
763694
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 envoyées à 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 sachez qu'il existe 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 les adresses de l'espace d'adressage sont 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'abstraction mémoire implémente plusieurs fonctionnalités complémentaires==
L'utilité de l'abstraction matérielle n'est pas évidente, mais sachez qu'elle est si utile que tous les processeurs modernes la prennent en charge. Elle sert notamment à implémenter la mémoire virtuelle, que nous aborderons dans ce qui suit. La plupart de ces fonctionnalités manipulent la relation entre adresses logiques et physique. Dans le cas le plus simple, une adresse logique correspond à une seule adresse physique. Mais beaucoup de fonctionnalités avancées ne respectent pas cette règle.
===L'abstraction matérielle des processus===
Les systèmes d'exploitation modernes sont dits multi-tâche, à savoir qu'ils sont capables d'exécuter plusieurs logiciels en même temps. Et ce même si un seul processeur est présent dans l'ordinateur : les logiciels sont alors exécutés à tour de rôle. Toutefois, cela amène un paquet de problèmes qu'il faut résoudre au mieux. Par exemple, les programmes exécutés doivent se partager la mémoire RAM, ce qui ne vient pas sans problèmes. Le problème principal est que les programmes ne doivent pas lire ou écrire dans les données d'un autre, sans quoi on se retrouverait rapidement avec des problèmes. Il faut donc introduire des mécanismes d{{'}}'''isolement des processus''', pour isoler les programmes les uns des autres.
Un de ces mécanismes est l{{'}}'''abstraction matérielle des processus''', une technique qui fait que chaque programme a son propre espace d'adressage. Chaque programme a l'impression d'avoir accès à tout l'espace d'adressage, de l'adresse 0 à l'adresse maximale gérée par le processeur. Évidemment, il s'agit d'une illusion maintenue justement grâce à la traduction d'adresse. Les espaces d'adressage contiennent des adresses logiques, les adresses de la RAM sont des adresses physiques, la nécessité de l'abstraction mémoire est évidente.
Implémenter l'abstraction mémoire peut se faire de plusieurs manières. Mais dans tous les cas, il faut que la correspondance adresse logique - physique change d'un programme à l'autre. Ce qui est normal, vu que les deux processus sont placés à des endroits différents en RAM physique. La conséquence est qu'avec l'abstraction mémoire, une adresse logique correspond à plusieurs adresses physiques. Une même adresse logique dans deux processus différents correspond à deux adresses phsiques différentes, une par processus. Une adresse logique dans un processus correspondra à l'adresse physique X, la même adresse dans un autre processus correspondra à l'adresse Y.
Les adresses physiques qui partagent la même adresse logique sont alors appelées des '''adresses homonymes'''. Le choix de la bonne adresse étant réalisé par un mécanisme matériel et dépend du programme en cours. Le mécanisme pour choisir la bonne adresse dépend du processeur, mais il y en a deux grands types :
* La première consiste à utiliser l'identifiant de processus CPU, vu au chapitre précédent. C'est, pour rappel, un numéro attribué à chaque processus par le processeur. L'identifiant du processus en cours d'exécution est mémorisé dans un registre du processeur. La traduction d'adresse utilise cet identifiant, en plus de l'adresse logique, pour déterminer l'adresse physique.
* La seconde solution mémorise les correspondances adresses logiques-physique dans des tables en mémoire RAM, qui sont différentes pour chaque programme. Les tables sont accédées à chaque accès mémoire, afin de déterminer l'adresse physique.
===Le partage de la mémoire===
L'isolation des processus est très importante sur les systèmes d'exploitation modernes. Cependant, il existe quelques situations où elle doit être contournée ou du moins mise en pause. Les situations sont multiples : gestion de bibliothèques partagées, communication entre processus, usage de ''threads'', etc. Elles impliquent toutes un '''partage de mémoire''', à savoir qu'une portion de mémoire RAM est partagée entre plusieurs programmes. Le partage de mémoire est une sorte de brèche de l'isolation des processus, mais qui est autorisée car elle est utile.
Un cas intéressant est celui des '''bibliothèques partagées'''. Les bibliothèques sont des collections de fonctions regroupées ensemble, dans une seule unité de code. Un programme qui utilise une bibliothèque peut appeler n’importe quelle fonction présente dans la bibliothèque. La bibliothèque peut être simplement inclue dans le programme lui-même, on parle alors de bibliothèques statiques. De telles bibliothèques fonctionnent très bien, mais avec un petit défaut pour les bibliothèques très utilisées : plusieurs programmes qui utilisent la même bibliothèque vont chacun l'inclure dans leur code, ce qui fera doublon.
Pour éviter cela, les OS modernes gèrent des bibliothèques partagées, à savoir qu'un seul exemplaire de la bibliothèque est partagé entre plusieurs programmes. Chaque programme peut exécuter une fonction de la bibliothèque quand il le souhaite, en effectuant un branchement adéquat. Mais cela implique que la bibliothèque soit présente dans l'espace d'adressage du programme en question. Une bibliothèque est donc présente dans plusieurs espaces d'adressage, alors qu'il n'y en a qu'un seul exemplaire en mémoire RAM.
[[File:Ogg vorbis libs and application dia.svg|centre|vignette|upright=2|Exemple de bibliothèques, avec Ogg vorbis.]]
D'autres situations demandent de partager de la mémoire entre deux programmes. Par exemple, les systèmes d'exploitation modernes gèrent nativement des systèmes de '''communication inter-processus''', très utilisés par les programmes modernes pour échanger des données. Et la plupart demandant de partager un bout de mémoire entre processus, même si c'est seulement temporairement. Typiquement, deux processus partagent un intervalle d'adresse où l'un écrit les données à l'autre, l'autre lisant les données envoyées.
Une dernière utilisation de la mémoire partagée est l{{'}}'''accès direct au noyau'''. Sur les systèmes d'exploitations moderne, dans l'espace d'adressage de chaque programme, les adresses hautes sont remplies avec une partie du noyau ! Évidemment, ces adresses sont accessibles uniquement en lecture, pas en écriture. Pas question de modifier le noyau de l'OS ! De plus, il s'agit d'une portion du noyau dont on sait que la consultation ne pose pas de problèmes de sécurité.
Le programme peut lire des données dans cette portion du noyau, mais aussi exécuter les fonctions du noyau qui sont dedans. L'idée est d'éviter des appels systèmes trop fréquents. Au lieu d'effectuer un véritable appel système, avec une interruption logicielle, le programme peut exécuter des appels systèmes simplifiés, de simples appels de fonctions couplés avec un changement de niveau de privilège (passage en espace noyau nécessaire).
[[File:AMD64-canonical--48-bit.png|vignette|Répartition des adresses entre noyau (jaune/orange) et programme (verte), sur les systèmes x86-64 bits, avec des adresses physiques de 48 bits.]]
L'espace d'adressage est donc séparé en deux portions : l'OS d'un côté, le programme de l'autre. La répartition des adresses entre noyau et programme varie suivant l'OS ou le processeur utilisé. Sur les PC x86 32 bits, Linux attribuait 3 gigas pour les programmes et 1 giga pour le noyau, Windows attribuait 2 gigas à chacun. Sur les systèmes x86 64 bits, l'espace d'adressage d'un programme est coupé en trois, comme illustré ci-contre : une partie basse de 2^48 octets, une partie haute de même taille, et un bloc d'adresses invalides entre les deux. Les adresses basses sont utilisées pour le programme, les adresses hautes pour le noyau, il n'y a rien entre les deux.
Avec le partage de mémoire, plusieurs adresses logiques correspondent à la même adresse physique. Tel processus verra la zone de mémoire partagée à l'adresse X, l'autre la verra à l'adresse Y. Mais il s'agira de la même portion de mémoire physique, avec une seule adresse physique. En clair, lorsque deux processus partagent une même zone de mémoire, la zone sera mappées à des adresses logiques différentes. Les adresses logiques sont alors appelées des '''adresses synonymes''', terme qui trahit le fait qu'elles correspondent à la même adresse physique.
===La mémoire virtuelle===
Toutes les adresses ne sont pas forcément occupées par de la mémoire RAM, s'il n'y a pas assez de RAM installée. Par exemple, un processeur 32 bits peut adresser 4 gibioctets de RAM, même si seulement 3 gibioctets sont installés dans l'ordinateur. L'espace d'adressage contient donc 1 gigas d'adresses inutilisées, et il faut éviter ce surplus d'adresses pose problème.
Sans mémoire virtuelle, seule la mémoire réellement installée est utilisable. Si un programme utilise trop de mémoire, il est censé se rendre compte qu'il n'a pas accès à tout l'espace d'adressage. Quand il demandera au système d'exploitation de lui réserver de la mémoire, le système d'exploitation le préviendra qu'il n'y a plus de mémoire libre. Par exemple, si un programme tente d'utiliser 4 gibioctets sur un ordinateur avec 3 gibioctets de mémoire, il ne pourra pas. Pareil s'il veut utiliser 2 gibioctets de mémoire sur un ordinateur avec 4 gibioctets, mais dont 3 gibioctets sont déjà utilisés par d'autres programmes. Dans les deux cas, l'illusion tombe à plat.
Les techniques de '''mémoire virtuelle''' font que l'espace d'adressage est utilisable au complet, même s'il n'y a pas assez de mémoire installée dans l'ordinateur ou que d'autres programmes utilisent de la RAM. Par exemple, sur un processeur 32 bits, le programme aura accès à 4 gibioctets de RAM, même si d'autres programmes utilisent la RAM, même s'il n'y a que 2 gibioctets de RAM d'installés dans l'ordinateur.
Pour cela, on utilise une partie des mémoires de masse (disques durs) d'un ordinateur en remplacement de la mémoire physique manquante. Le système d'exploitation crée sur le disque dur un fichier, appelé le ''swapfile'' ou '''fichier de ''swap''''', qui est utilisé comme mémoire RAM supplémentaire. Il mémorise le surplus de données et de programmes qui ne peut pas être mis en mémoire RAM.
[[File:Vm1.png|centre|vignette|upright=2.0|Mémoire virtuelle et fichier de Swap.]]
Une technique naïve de mémoire virtuelle serait la suivante. Avant de l'aborder, précisons qu'il s'agit d'une technique abordée à but pédagogique, mais qui n'est implémentée nulle part tellement elle est lente et inefficace. Un espace d'adressage de 4 gigas ne contient que 3 gigas de RAM, ce qui fait 1 giga d'adresses inutilisées. Les accès mémoire aux 3 gigas de RAM se font normalement, mais l'accès aux adresses inutilisées lève une exception matérielle "Memory Unavailable". La routine d'interruption de cette exception accède alors au ''swapfile'' et récupère les données associées à cette adresse. La mémoire virtuelle est alors émulée par le système d'exploitation.
Le défaut de cette méthode est que l'accès au giga manquant est toujours très lent, parce qu'il se fait depuis le disque dur. D'autres techniques de mémoire virtuelle logicielle font beaucoup mieux, mais nous allons les passer sous silence, vu qu'on peut faire mieux, avec l'aide du matériel.
L'idée est de charger les données dont le programme a besoin dans la RAM, et de déplacer les autres sur le disque dur. Par exemple, imaginons la situation suivante : un programme a besoin de 4 gigas de mémoire, mais ne dispose que de 2 gigas de mémoire installée. On peut imaginer découper l'espace d'adressage en 2 blocs de 2 gigas, qui sont chargés à la demande. Si le programme accède aux adresses basses, on charge les 2 gigas d'adresse basse en RAM. S'il accède aux adresses hautes, on charge les 2 gigas d'adresse haute dans la RAM après avoir copié les adresses basses sur le ''swapfile''.
On perd du temps dans les copies de données entre RAM et ''swapfile'', mais on gagne en performance vu que tous les accès mémoire se font en RAM. Du fait de la localité temporelle, le programme utilise les données chargées depuis le swapfile durant un bon moment avant de passer au bloc suivant. La RAM est alors utilisée comme une sorte de cache alors que les données sont placées dans une mémoire fictive représentée par l'espace d'adressage et qui correspond au disque dur.
Mais avec cette technique, la correspondance entre adresses du programme et adresses de la RAM change au cours du temps. Les adresses de la RAM correspondent d'abord aux adresses basses, puis aux adresses hautes, et ainsi de suite. On a donc besoin d'abstraction mémoire. Les correspondances entre adresse logique et physique peuvent varier avec le temps, ce qui permet de déplacer des données de la RAM vers le disque dur ou inversement. Une adresse logique peut correspondre à une adresse physique, ou bien à une donnée swappée sur le disque dur. C'est l'unité de traduction d'adresse qui se charge de faire la différence. Si une correspondance entre adresse logique et physique est trouvée, elle l'utilise pour traduire les adresses. Si aucune correspondance n'est trouvée, alors elle laisse la main au système d'exploitation pour charger la donnée en RAM. Une fois la donnée chargée en RAM, les correspondances entre adresse logique et physiques sont modifiées de manière à ce que l'adresse logique pointe vers la donnée chargée.
===L'extension d'adressage===
Une autre fonctionnalité rendue possible par l'abstraction mémoire est l{{'}}'''extension d'adressage'''. Elle permet d'utiliser plus de mémoire que l'espace d'adressage ne le permet. Par exemple, utiliser 7 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'extension d'adresse est l'exact inverse de la mémoire virtuelle. La mémoire virtuelle sert quand on a moins de mémoire que d'adresses, l'extension d'adresse sert quand on a plus de mémoire que d'adresses.
Il y a quelques chapitres, nous avions vu que c'est possible via la commutation de banques. Mais l'abstraction mémoire est une méthode alternative. Que ce soit avec la commutation de banques ou avec l'abstraction mémoire, les adresses envoyées à la mémoire doivent être plus longues que les adresses gérées par le processeur. La différence est que l'abstraction mémoire étend les adresses d'une manière différente.
Une implémentation possible de l'extension d'adressage fait usage de l'abstraction matérielle des processus. Chaque processus a son propre espace d'adressage, mais ceux-ci sont placés à des endroits différents dans la mémoire physique. Par exemple, sur un ordinateur avec 16 gigas de RAM, mais un espace d'adressage de 2 gigas, on peut remplir la RAM en lançant 8 processus différents et chaque processus aura accès à un bloc de 2 gigas de RAM, pas plus, il ne peut pas dépasser cette limite. Ainsi, chaque processus est limité par son espace d'adressage, mais on remplit la mémoire avec plusieurs processus, ce qui compense. Il s'agit là de l'implémentation la plus simple, qui a en plus l'avantage d'avoir la meilleure compatibilité logicielle. De simples changements dans le système d'exploitation suffisent à l'implémenter.
[[File:Extension de l'espace d'adressage.png|centre|vignette|upright=1.5|Extension de l'espace d'adressage]]
Un autre implémentation donne plusieurs espaces d'adressage différents à chaque processus, et a donc accès à autant de mémoire que permis par la somme de ces espaces d'adressage. Par exemple, sur un ordinateur avec 16 gigas de RAM et un espace d'adressage de 4 gigas, un programme peut utiliser toute la RAM en utilisant 4 espaces d'adressage distincts. On passe d'un espace d'adressage à l'autre en changeant la correspondance adresse logique-physique. L'inconvénient est que la compatibilité logicielle est assez mauvaise. Modifier l'OS ne suffit pas, les programmeurs doivent impérativement concevoir leurs programmes pour qu'ils utilisent explicitement plusieurs espaces d'adressage.
Les deux implémentations font usage des adresses logiques homonymes, mais à l'intérieur d'un même processus. Pour rappel, cela veut dire qu'une adresse logique correspond à des adresses physiques différentes. Rien d'étonnant vu qu'on utilise plusieurs espaces d'adressage, comme pour l'abstraction des processus, sauf que cette fois-ci, on a plusieurs espaces d'adressage par processus. Prenons l'exemple où on a 8 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'idée est qu'une adresse correspondra à une adresse dans les premiers 4 gigas, ou dans les seconds 4 gigas. L'adresse logique X correspondra d'abord à une adresse physique dans les premiers 4 gigas, puis à une adresse physique dans les seconds 4 gigas.
===La protection mémoire===
La '''protection mémoire''' regroupe des techniques très différentes les unes des autres, qui visent à améliorer la sécurité des programmes et des systèmes d'exploitation. Elles visent à empêcher de lire, d'écrire ou d'exécuter certaines portions de mémoire. Sans elle, les programmes peuvent techniquement lire ou écrire les données des autres, ce qui causent des situations non-prévues par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses.
La première technique de protection mémoire est l{{'}}'''isolation des processus''', qu'on a vue plus haut. Elle garantit que chaque programme n'a accès qu'à certaines portions dédiées de la mémoire et rend le reste de la mémoire inaccessible en lecture et en écriture. Le système d'exploitation attribue à chaque programme une ou plusieurs portions de mémoire rien que pour lui, auquel aucun autre programme ne peut accéder. Un tel programme, isolé des autres, s'appelle un '''processus''', d'où le nom de cet objectif. Toute tentative d'accès à une partie de la mémoire non autorisée déclenche une exception matérielle (rappelez-vous le chapitre sur les interruptions) qui est traitée par une routine du système d'exploitation. Généralement, le programme fautif est sauvagement arrêté et un message d'erreur est affiché à l'écran.
La '''protection de l'espace exécutable''' empêche d’exécuter quoique ce soit provenant de certaines zones de la mémoire. En effet, certaines portions de la mémoire sont censées contenir uniquement des données, sans aucun programme ou code exécutable. Cependant, des virus informatiques peuvent se cacher dedans et d’exécuter depuis celles-ci. Ou encore, des failles de sécurités peuvent permettre à un attaquant d'injecter du code exécutable malicieux dans des données, ce qui peut lui permettre de lire les données manipulées par un programme, prendre le contrôle de la machine, injecter des virus, ou autre. Pour éviter cela, le système d'exploitation peut marquer certaines zones mémoire comme n'étant pas exécutable. Toute tentative d’exécuter du code localisé dans ces zones entraîne la levée d'une exception ou d'une erreur et le système d'exploitation réagit en conséquence. Là encore, le processeur doit détecter les exécutions non autorisées.
D'autres méthodes de protection mémoire visent à limiter des actions dangereuses. Pour cela, le processeur et l'OS gèrent des '''droits d'accès''', qui interdisent certaines actions pour des programmes non-autorisés. Lorsqu'on exécute une opération interdite, le système d’exploitation et/ou le processeur réagissent en conséquence. La première technique de ce genre n'est autre que la séparation entre espace noyau et utilisateur, vue dans le chapitre sur les interruptions. Mais il y en a d'autres, comme nous le verrons dans ce chapitre.
==La MMU==
La traduction des adresses logiques en adresses physiques se fait par un circuit spécialisé appelé la '''''Memory Management Unit''''' (MMU), qui est souvent intégré directement dans l'interface mémoire. La MMU est souvent associée à une ou plusieurs mémoires caches, qui visent à accélérer la traduction d'adresses logiques en adresses physiques. En effet, nous verrons plus bas que la traduction d'adresse demande d'accéder à des tableaux, gérés par le système d'exploitation, qui sont en mémoire RAM. Aussi, les processeurs modernes incorporent des mémoires caches appelées des '''''Translation Lookaside Buffers''''', ou encore TLB. Nous nous pouvons pas parler des TLB pour le moment, car nous n'avons pas encore abordé le chapitre sur les mémoires caches, mais un chapitre entier sera dédié aux TLB d'ici peu.
[[File:MMU principle updated.png|centre|vignette|upright=2|MMU.]]
===Les MMU intégrées au processeur===
D'ordinaire, la MMU est intégrée au processeur. Et elle peut l'être de deux manières. La première en fait un circuit séparé, relié au bus d'adresse. La seconde fusionne la MMU avec l'unité de calcul d'adresse. La première solution est surtout utilisée avec une technique d'abstraction mémoire appelée la pagination, alors que l'autre l'est avec une autre méthode appelée la segmentation. La raison est que la traduction d'adresse avec la segmentation est assez simple : elle demande d'additionner le contenu d'un registre avec l'adresse logique, ce qui est le genre de calcul qu'une unité de calcul d'adresse sait déjà faire. La fusion est donc assez évidente.
Pour donner un exemple, l'Intel 8086 fusionnait l'unité de calcul d'adresse et la MMU. Précisément, il utilisait un même additionneur pour incrémenter le ''program counter'' et effectuer des calculs d'adresse liés à la segmentation. Il aurait été logique d'ajouter les pointeurs de pile avec, mais ce n'était pas possible. La raison est que le pointeur de pile ne peut pas être envoyé directement sur le bus d'adresse, vu qu'il doit passer par une phase de traduction en adresse physique liée à la segmentation.
[[File:80186 arch.png|centre|vignette|upright=2|Intel 8086, microarchitecture.]]
===Les MMU séparées du processeur, sur la carte mère===
Il a existé des processeurs avec une MMU externe, soudée sur la carte mère.
Par exemple, les processeurs Motorola 68000 et 68010 pouvaient être combinés avec une MMU de type Motorola 68451. Elle supportait des versions simplifiées de la segmentation et de la pagination. Au minimum, elle ajoutait un support de la protection mémoire contre certains accès non-autorisés. La gestion de la mémoire virtuelle proprement dit n'était possible que si le processeur utilisé était un Motorola 68010, en raison de la manière dont le 68000 gérait ses accès mémoire. La MMU 68451 gérait un espace d'adressage de 16 mébioctets, découpé en maximum 32 pages/segments. On pouvait dépasser cette limite de 32 segments/pages en combinant plusieurs 68451.
Le Motorola 68851 était une MMU qui était prévue pour fonctionner de paire avec le Motorola 68020. Elle gérait la pagination pour un espace d'adressage de 32 bits.
Les processeurs suivants, les 68030, 68040, et 68060, avaient une MMU interne au processeur.
==La relocation matérielle==
Pour rappel, les systèmes d'exploitation moderne permettent de lancer plusieurs programmes en même temps et les laissent se partager la mémoire. Dans le cas le plus simple, qui n'est pas celui des OS modernes, le système d'exploitation découpe la mémoire en blocs d'adresses contiguës qui sont appelés des '''segments''', ou encore des ''partitions mémoire''. Les segments correspondent à un bloc de mémoire RAM. C'est-à-dire qu'un segment de 259 mébioctets sera un segment continu de 259 mébioctets dans la mémoire physique comme dans la mémoire logique. Dans ce qui suit, un segment contient un programme en cours d'exécution, comme illustré ci-dessous.
[[File:CPT Memory Addressable.svg|centre|vignette|upright=2|Espace d'adressage segmenté.]]
Le système d'exploitation mémorise la position de chaque segment en mémoire, ainsi que d'autres informations annexes. Le tout est regroupé dans la '''table de segment''', un tableau dont chaque case est attribuée à un programme/segment. La table des segments est un tableau numéroté, chaque segment ayant un numéro qui précise sa position dans le tableau. Chaque case, chaque entrée, contient un '''descripteur de segment''' qui regroupe plusieurs informations sur le segment : son adresse de base, sa taille, diverses informations.
===La relocation avec la relocation matérielle : le registre de base===
Un segment peut être placé n'importe où en RAM physique et sa position en RAM change à chaque exécution. Le programme est chargé à une adresse, celle du début du segment, qui change à chaque chargement du programme. Et toutes les adresses utilisées par le programme doivent être corrigées lors du chargement du programme, généralement par l'OS. Cette correction s'appelle la '''relocation''', et elle consiste à ajouter l'adresse de début du segment à chaque adresse manipulée par le programme.
[[File:Relocation assistée par matériel.png|centre|vignette|upright=2.5|Relocation.]]
La relocation matérielle fait que la relocation est faite par le processeur, pas par l'OS. La relocation est intégrée dans le processeur par l'intégration d'un registre : le '''registre de base''', aussi appelé '''registre de relocation'''. Il mémorise l'adresse à laquelle commence le segment, la première adresse du programme. Pour effectuer la relocation, le processeur ajoute automatiquement l'adresse de base à chaque accès mémoire, en allant la chercher dans le registre de relocation.
[[File:Registre de base de segment.png|centre|vignette|upright=2|Registre de base de segment.]]
Le processeur s'occupe de la relocation des segments et le programme compilé n'en voit rien. Pour le dire autrement, les programmes manipulent des adresses logiques, qui sont traduites par le processeur en adresses physiques. La traduction se fait en ajoutant le contenu du registre de relocation à l'adresse logique. De plus, cette méthode fait que chaque programme a son propre espace d'adressage.
[[File:CPU created logical address presentation.png|centre|vignette|upright=2|Traduction d'adresse avec la relocation matérielle.]]
Le système d'exploitation mémorise les adresses de base pour chaque programme, dans la table des segments. Le registre de base est mis à jour automatiquement lors de chaque changement de segment. Pour cela, le registre de base est accessible via certaines instructions, accessibles en espace noyau, plus rarement en espace utilisateur. Le registre de segment est censé être adressé implicitement, vu qu'il est unique. Si ce n'est pas le cas, il est possible d'écrire dans ce registre de segment, qui est alors adressable.
===La protection mémoire avec la relocation matérielle : le registre limite===
Sans restrictions supplémentaires, la taille maximale d'un segment est égale à la taille complète de l'espace d'adressage. Sur les processeurs 32 bits, un segment a une taille maximale de 2^32 octets, soit 4 gibioctets. Mais il est possible de limiter la taille du segment à 2 gibioctets, 1 gibioctet, 64 Kibioctets, ou toute autre taille. La limite est définie lors de la création du segment, mais elle peut cependant évoluer au cours de l'exécution du programme, grâce à l'allocation mémoire. Le processeur vérifie à chaque accès mémoire que celui-ci se fait bien dans le segment, en comparant l'adresse accédée à l'adresse de base et l'adresse maximale, l'adresse limite.
Limiter la taille d'un segment demande soit de mémoriser sa taille, soit de mémoriser l'adresse limite (l'adresse de fin de segment, l'adresse limite à ne pas dépasser). Les deux sont possibles et marchent parfaitement, le choix entre les deux solutions est une pure question de préférence. A la rigueur, la vérification des débordements est légèrement plus rapide si on utilise l'adresse de fin du segment. Précisons que l'adresse limite est une adresse logique, le segment commence toujours à l'adresse logique zéro.
Pour cela, la table des segments doit être modifiée. Au lieu de ne contenir que l'adresse de base, elle contient soit l'adresse maximale du segment, soit la taille du segment. En clair, le descripteur de segment est enrichi avec l'adresse limite. 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.
Techniquement, il y a une petite différence de vitesse entre utiliser la taille et l'adresse maximale. Vérifier les débordements avec la taille demande juste de comparer la taille avec l'adresse logique, avant relocation, ce qui peut être fait en parallèle de la relocation. Par contre, l'adresse limite est comparée à une adresse physique, ce qui demande de faire la relocation avant la vérification, ce qui prend un peu plus de temps. Mais l'impact sur les performances est des plus mineurs.
[[File:Registre limite.png|centre|vignette|upright=2|Registre limite]]
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> </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>+ </code><code style="background:#DDF">0001 0010 0011 0100</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </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. Au lieu d'utiliser un additionneur séparé pour le ''program counter'' et un autre pour le calcul de l'adresse physique, un seul additionneur est utilisé pour les deux. L'idée était de partager l'additionneur, qui servait à la fois à incrémenter le ''program counter'' et pour gérer la segmentation. 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|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> </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>+ </code><code style="background:#DDF">1111 1111 1111 1111</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </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 de nos PCs, à partir du 286 d'Intel. 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. A 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. A la place, les segments sont adressés de manière indirecte. A 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 utilise l'adresse ou la taille limite pour vérifier si l'accès mémoire ne déborde pas en-dehors du segment en cours. Pour cela, le processeur compare l'adresse logique accédée avec l'adresse limite, ou compare la taille limite avec le décalage. L'information est lue depuis la table des segments à chaque accès.
[[File:Vm7.svg|centre|vignette|upright=2|Traduction d'adresse avec vérification des accès hors-segment.]]
Par contre, 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.
====L'implémentation de la protection mémoire sur le 386====
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 protection mémoire teste la valeur des bits P, S, X, E, R/W, et peut aussi modifier le bit A. 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 : soit autorisait l'exécution de la lecture/écriture, soit levait une exception.
* Une adresse de 12 bits, pointant dans le microcode, sur un code levant une exception en cas d'erreur.
* 4 bits pouvant être testés par un branchement dans le microcode, qui demandaient :
** soit de modifier le bit A du descripteur de segment (pas d'erreur, on accède au segment) ;
** soit de tester s'il y a un accès hors-limite ;
** de signaler une instruction sur la pile.
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 ''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. A 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]]
A 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. A 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. A la place, l'entrée contient un '''bit ''global''''', qui précise que cette correspondance est valide pour tous les processus. Le bit global accélère rapidement la traduction d'adresse pour l'accès au noyau.
Un défaut de cette méthode est que le partage d'une page entre plusieurs processus est presque impossible. Impossible de partager une page avec seulement certains processus et pas d'autres : soit on partage une page avec tous les processus, soit on l'alloue avec un seul processus.
====L'usage de plusieurs tables des pages====
Une solution alternative, plus simple, utilise une table des pages par processus lancé sur l'ordinateur, une table des pages unique par espace d'adressage. À chaque changement de processus, le registre qui mémorise la position de la table des pages est modifié pour pointer sur la bonne. C'est le système d'exploitation qui se charge de cette mise à jour.
Avec cette méthode, il est possible de partager une ou plusieurs pages entre plusieurs processus, en configurant les tables des pages convenablement. Les pages partagées sont mappées dans l'espace d'adressage de plusieurs processus, mais pas forcément au même endroit, pas forcément dans les mêmes adresses logiques. On peut placer la page partagée à l'adresse logique 0x0FFF pour un processus, à l'adresse logique 0xFF00 pour un autre processus, etc. Par contre, les entrées de la table des pages pour ces adresses pointent vers la même adresse physique.
[[File:Vm5.png|centre|vignette|upright=2|Tables des pages de plusieurs processus.]]
===La taille des pages===
La taille des pages varie suivant le processeur et le système d'exploitation et tourne souvent autour de 4 kibioctets. Les processeurs actuels gèrent plusieurs tailles différentes pour les pages : 4 kibioctets par défaut, 2 mébioctets, voire 1 à 4 gibioctets pour les pages les plus larges. Les pages de 4 kibioctets sont les pages par défaut, les autres tailles de page sont appelées des ''pages larges''. La taille optimale pour les pages dépend de nombreux paramètres et il n'y a pas de taille qui convienne à tout le monde. Certaines applications gagnent à utiliser des pages larges, d'autres vont au contraire perdre drastiquement en performance en les utilisant.
Le désavantage principal des pages larges est qu'elles favorisent la fragmentation mémoire. Si un programme veut réserver une portion de mémoire, pour une structure de donnée quelconque, il doit réserver une portion dont la taille est multiple de la taille d'une page. Par exemple, un programme ayant besoin de 110 kibioctets allouera 28 pages de 4 kibioctets, soit 120 kibioctets : 2 kibioctets seront perdus. Par contre, avec des pages larges de 2 mébioctets, on aura une perte de 2048 - 110 = 1938 kibioctets. En somme, des morceaux de mémoire seront perdus, car les pages sont trop grandes pour les données qu'on veut y mettre. Le résultat est que le programme qui utilise les pages larges utilisent plus de mémoire et ce d'autant plus qu'il utilise des données de petite taille. Un autre désavantage est qu'elles se marient mal avec certaines techniques d'optimisations de type ''copy-on-write''.
Mais l'avantage est que la traduction des adresses est plus performante. Une taille des pages plus élevée signifie moins de pages, donc des tables des pages plus petites. Et des pages des tables plus petites n'ont pas besoin de beaucoup de niveaux de hiérarchie, voire peuvent se limiter à des tables des pages simples, ce qui rend la traduction d'adresse plus simple et plus rapide. De plus, les programmes ont une certaine localité spatiale, qui font qu'ils accèdent souvent à des données proches. La traduction d'adresse peut alors profiter de systèmes de mise en cache dont nous parlerons dans le prochain chapitre, et ces systèmes de cache marchent nettement mieux avec des pages larges.
Il faut noter que la taille des pages est presque toujours une puissance de deux. Cela a de nombreux avantages, mais n'est pas une nécessité. Par exemple, le tout premier processeur avec de la pagination, le super-ordinateur Atlas, avait des pages de 3 kibioctets. L'avantage principal est que la traduction de l'adresse physique en adresse logique est trivial avec une puissance de deux. Cela garantit que l'on peut diviser l'adresse en un numéro de page et un ''offset'' : la traduction demande juste de remplacer les bits de poids forts par le numéro de page voulu. Sans cela, la traduction d'adresse implique des divisions et des multiplications, qui sont des opérations assez couteuses.
===Les entrées de la table des pages===
Avant de poursuivre, faisons un rapide rappel sur les entrées de la table des pages. Nous venons de voir que la table des pages contient de nombreuses informations : un bit ''valid'' pour la mémoire virtuelle, des bits ''dirty'' et ''accessed'' utilisés par l'OS, des bits de protection mémoire, un bit ''global'' et un potentiellement un identifiant de processus, etc. Étudions rapidement le format de la table des pages sur un processeur x86 32 bits.
* Elle contient d'abord le numéro de page physique.
* Les bits AVL sont inutilisés et peuvent être configurés à loisir par l'OS.
* Le bit G est le bit ''global''.
* Le bit PS vaut 0 pour une page de 4 kibioctets, mais est mis à 1 pour une page de 4 mébioctets dans le cas où le processus utilise des pages larges.
* Le bit D est le bit ''dirty''.
* Le bit A est le bit ''accessed''.
* Le bit PCD indique que la page ne peut pas être cachée, dans le sens où le processeur ne peut copier son contenu dans le cache et doit toujours lire ou écrire cette page directement dans la RAM.
* Le bit PWT indique que les écritures doivent mettre à jour le cache et la page en RAM (dans le chapitre sur le cache, on verra qu'il force le cache à se comporter comme un cache ''write-through'' pour cette page).
* Le bit U/S précise si la page est accessible en mode noyau ou utilisateur.
* Le bit R/W indique si la page est accessible en écriture, toutes les pages sont par défaut accessibles en lecture.
* Le bit P est le bit ''valid''.
[[File:PDE.png|centre|vignette|upright=2.5|Table des pages des processeurs Intel 32 bits.]]
==Comparaison des différentes techniques d'abstraction mémoire==
Pour résumer, l'abstraction mémoire permet de gérer : la relocation, la protection mémoire, l'isolation des processus, la mémoire virtuelle, l'extension de l'espace d'adressage, le partage de mémoire, etc. Elles sont souvent implémentées en même temps. Ce qui fait qu'elles sont souvent confondues, alors que ce sont des concepts sont différents. Ces liens sont résumés dans le tableau ci-dessous.
{|class="wikitable"
|-
!
! colspan="5" | Avec abstraction mémoire
! rowspan="2" | Sans abstraction mémoire
|-
!
! Relocation matérielle
! Segmentation en mode réel (x86)
! Segmentation, général
! Architectures à capacités
! Pagination
|-
! Abstraction matérielle des processus
| colspan="4" | Oui, relocation matérielle
| Oui, liée à la traduction d'adresse
| Impossible
|-
! Mémoire virtuelle
| colspan="2" | Non, sauf émulation logicielle
| colspan="3" | Oui, gérée par le processeur et l'OS
| Non, sauf émulation logicielle
|-
! Extension de l'espace d'adressage
| colspan="2" | Oui : registre de base élargi
| colspan="2" | Oui : adresse de base élargie dans la table des segments
| ''Physical Adress Extension'' des processeurs 32 bits
| Commutation de banques
|-
! Protection mémoire
| Registre limite
| Aucune
| colspan="2" | Registre limite, droits d'accès aux segments
| Gestion des droits d'accès aux pages
| Possible, méthodes variées
|-
! Partage de mémoire
| colspan="2" | Non
| colspan="2" | Segment partagés
| Pages partagées
| Possible, méthodes variées
|}
===Les différents types de segmentation===
La segmentation regroupe plusieurs techniques franchement différentes, qui auraient gagné à être nommées différemment. La principale différence est l'usage de registres de relocation versus des registres de sélecteurs de segments. L'usage de registres de relocation est le fait de la relocation matérielle, mais aussi de la segmentation en mode réel des CPU x86. Par contre, l'usage de sélecteurs de segments est le fait des autres formes de segmentation, architectures à capacité inclues.
La différence entre les deux est le nombre de segments. L'usage de registres de relocation fait que le CPU ne gère qu'un petit nombre de segments de grande taille. La mémoire virtuelle est donc rarement implémentée vu que swapper des segments de grande taille est trop long, l'impact sur les performances est trop important. Sans compter que l'usage de registres de base se marie très mal avec la mémoire virtuelle. Vu qu'un segment peut être swappé ou déplacée n'importe quand, il faut invalider les registres de base au moment du swap/déplacement, ce qui n'est pas chose aisée. Aucun processeur ne gère cela, les méthodes pour n'existent tout simplement pas. L'usage de registres de base implique que la mémoire virtuelle est absente.
La protection mémoire est aussi plus limitée avec l'usage de registres de relocation. Elle se limite à des registres limite, mais la gestion des droits d'accès est limitée. En théorie, la segmentation en mode réel pourrait implémenter une version limitée de protection mémoire, avec une protection de l'espace exécutable. Mais ca n'a jamais été fait en pratique sur les processeurs x86.
Le partage de la mémoire est aussi difficile sur les architectures avec des registres de base. L'absence de table des segments fait que le partage d'un segment est basiquement impossible sans utiliser des méthodes complétement tordues, qui ne sont jamais implémentées en pratique.
===Segmentation versus pagination===
Par rapport à la pagination, la segmentation a des avantages et des inconvénients. Tous sont liés aux propriétés des segments et pages : les segments sont de grande taille et de taille variable, les pages sont petites et de taille fixe.
L'avantage principal de la segmentation est sa rapidité. Le fait que les segments sont de grande taille fait qu'on a pas besoin d'équivalent aux tables des pages inversée ou multiple, juste d'une table des segments toute simple. De plus, les échanges entre table des pages/segments et registres sont plus rares avec la segmentation. Par exemple, si un programme utilise un segment de 2 gigas, tous les accès dans le segment se feront avec une seule consultation de la table des segments. Alors qu'avec la pagination, il faudra une consultation de la table des pages chaque bloc de 4 kibioctet, au minimum.
Mais les désavantages sont nombreux. Le système d'exploitation doit agencer les segments en RAM, et c'est une tâche complexe. Le fait que les segments puisse changer de taille rend le tout encore plus complexe. Par exemple, si on colle les segments les uns à la suite des autres, changer la taille d'un segment demande de réorganiser tous les segments en RAM, ce qui demande énormément de copies RAM-RAM. Une autre possibilité est de laisser assez d'espace entre les segments, mais cet espace est alors gâché, dans le sens où on ne peut pas y placer un nouveau segment.
Swapper un segment est aussi très long, vu que les segments sont de grande taille, alors que swapper une page est très rapide.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'espace d'adressage du processeur
| prevText=L'espace d'adressage du processeur
| next=Les méthodes de synchronisation entre processeur et périphériques
| nextText=Les méthodes de synchronisation entre processeur et périphériques
}}
</noinclude>
71aw3knh7q4a7yca968hjgpof4s0ord
763695
763694
2026-04-14T22:45:02Z
Mewtow
31375
/* L'implémentation de la protection mémoire sur le 386 */
763695
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 envoyées à 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 sachez qu'il existe 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 les adresses de l'espace d'adressage sont 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'abstraction mémoire implémente plusieurs fonctionnalités complémentaires==
L'utilité de l'abstraction matérielle n'est pas évidente, mais sachez qu'elle est si utile que tous les processeurs modernes la prennent en charge. Elle sert notamment à implémenter la mémoire virtuelle, que nous aborderons dans ce qui suit. La plupart de ces fonctionnalités manipulent la relation entre adresses logiques et physique. Dans le cas le plus simple, une adresse logique correspond à une seule adresse physique. Mais beaucoup de fonctionnalités avancées ne respectent pas cette règle.
===L'abstraction matérielle des processus===
Les systèmes d'exploitation modernes sont dits multi-tâche, à savoir qu'ils sont capables d'exécuter plusieurs logiciels en même temps. Et ce même si un seul processeur est présent dans l'ordinateur : les logiciels sont alors exécutés à tour de rôle. Toutefois, cela amène un paquet de problèmes qu'il faut résoudre au mieux. Par exemple, les programmes exécutés doivent se partager la mémoire RAM, ce qui ne vient pas sans problèmes. Le problème principal est que les programmes ne doivent pas lire ou écrire dans les données d'un autre, sans quoi on se retrouverait rapidement avec des problèmes. Il faut donc introduire des mécanismes d{{'}}'''isolement des processus''', pour isoler les programmes les uns des autres.
Un de ces mécanismes est l{{'}}'''abstraction matérielle des processus''', une technique qui fait que chaque programme a son propre espace d'adressage. Chaque programme a l'impression d'avoir accès à tout l'espace d'adressage, de l'adresse 0 à l'adresse maximale gérée par le processeur. Évidemment, il s'agit d'une illusion maintenue justement grâce à la traduction d'adresse. Les espaces d'adressage contiennent des adresses logiques, les adresses de la RAM sont des adresses physiques, la nécessité de l'abstraction mémoire est évidente.
Implémenter l'abstraction mémoire peut se faire de plusieurs manières. Mais dans tous les cas, il faut que la correspondance adresse logique - physique change d'un programme à l'autre. Ce qui est normal, vu que les deux processus sont placés à des endroits différents en RAM physique. La conséquence est qu'avec l'abstraction mémoire, une adresse logique correspond à plusieurs adresses physiques. Une même adresse logique dans deux processus différents correspond à deux adresses phsiques différentes, une par processus. Une adresse logique dans un processus correspondra à l'adresse physique X, la même adresse dans un autre processus correspondra à l'adresse Y.
Les adresses physiques qui partagent la même adresse logique sont alors appelées des '''adresses homonymes'''. Le choix de la bonne adresse étant réalisé par un mécanisme matériel et dépend du programme en cours. Le mécanisme pour choisir la bonne adresse dépend du processeur, mais il y en a deux grands types :
* La première consiste à utiliser l'identifiant de processus CPU, vu au chapitre précédent. C'est, pour rappel, un numéro attribué à chaque processus par le processeur. L'identifiant du processus en cours d'exécution est mémorisé dans un registre du processeur. La traduction d'adresse utilise cet identifiant, en plus de l'adresse logique, pour déterminer l'adresse physique.
* La seconde solution mémorise les correspondances adresses logiques-physique dans des tables en mémoire RAM, qui sont différentes pour chaque programme. Les tables sont accédées à chaque accès mémoire, afin de déterminer l'adresse physique.
===Le partage de la mémoire===
L'isolation des processus est très importante sur les systèmes d'exploitation modernes. Cependant, il existe quelques situations où elle doit être contournée ou du moins mise en pause. Les situations sont multiples : gestion de bibliothèques partagées, communication entre processus, usage de ''threads'', etc. Elles impliquent toutes un '''partage de mémoire''', à savoir qu'une portion de mémoire RAM est partagée entre plusieurs programmes. Le partage de mémoire est une sorte de brèche de l'isolation des processus, mais qui est autorisée car elle est utile.
Un cas intéressant est celui des '''bibliothèques partagées'''. Les bibliothèques sont des collections de fonctions regroupées ensemble, dans une seule unité de code. Un programme qui utilise une bibliothèque peut appeler n’importe quelle fonction présente dans la bibliothèque. La bibliothèque peut être simplement inclue dans le programme lui-même, on parle alors de bibliothèques statiques. De telles bibliothèques fonctionnent très bien, mais avec un petit défaut pour les bibliothèques très utilisées : plusieurs programmes qui utilisent la même bibliothèque vont chacun l'inclure dans leur code, ce qui fera doublon.
Pour éviter cela, les OS modernes gèrent des bibliothèques partagées, à savoir qu'un seul exemplaire de la bibliothèque est partagé entre plusieurs programmes. Chaque programme peut exécuter une fonction de la bibliothèque quand il le souhaite, en effectuant un branchement adéquat. Mais cela implique que la bibliothèque soit présente dans l'espace d'adressage du programme en question. Une bibliothèque est donc présente dans plusieurs espaces d'adressage, alors qu'il n'y en a qu'un seul exemplaire en mémoire RAM.
[[File:Ogg vorbis libs and application dia.svg|centre|vignette|upright=2|Exemple de bibliothèques, avec Ogg vorbis.]]
D'autres situations demandent de partager de la mémoire entre deux programmes. Par exemple, les systèmes d'exploitation modernes gèrent nativement des systèmes de '''communication inter-processus''', très utilisés par les programmes modernes pour échanger des données. Et la plupart demandant de partager un bout de mémoire entre processus, même si c'est seulement temporairement. Typiquement, deux processus partagent un intervalle d'adresse où l'un écrit les données à l'autre, l'autre lisant les données envoyées.
Une dernière utilisation de la mémoire partagée est l{{'}}'''accès direct au noyau'''. Sur les systèmes d'exploitations moderne, dans l'espace d'adressage de chaque programme, les adresses hautes sont remplies avec une partie du noyau ! Évidemment, ces adresses sont accessibles uniquement en lecture, pas en écriture. Pas question de modifier le noyau de l'OS ! De plus, il s'agit d'une portion du noyau dont on sait que la consultation ne pose pas de problèmes de sécurité.
Le programme peut lire des données dans cette portion du noyau, mais aussi exécuter les fonctions du noyau qui sont dedans. L'idée est d'éviter des appels systèmes trop fréquents. Au lieu d'effectuer un véritable appel système, avec une interruption logicielle, le programme peut exécuter des appels systèmes simplifiés, de simples appels de fonctions couplés avec un changement de niveau de privilège (passage en espace noyau nécessaire).
[[File:AMD64-canonical--48-bit.png|vignette|Répartition des adresses entre noyau (jaune/orange) et programme (verte), sur les systèmes x86-64 bits, avec des adresses physiques de 48 bits.]]
L'espace d'adressage est donc séparé en deux portions : l'OS d'un côté, le programme de l'autre. La répartition des adresses entre noyau et programme varie suivant l'OS ou le processeur utilisé. Sur les PC x86 32 bits, Linux attribuait 3 gigas pour les programmes et 1 giga pour le noyau, Windows attribuait 2 gigas à chacun. Sur les systèmes x86 64 bits, l'espace d'adressage d'un programme est coupé en trois, comme illustré ci-contre : une partie basse de 2^48 octets, une partie haute de même taille, et un bloc d'adresses invalides entre les deux. Les adresses basses sont utilisées pour le programme, les adresses hautes pour le noyau, il n'y a rien entre les deux.
Avec le partage de mémoire, plusieurs adresses logiques correspondent à la même adresse physique. Tel processus verra la zone de mémoire partagée à l'adresse X, l'autre la verra à l'adresse Y. Mais il s'agira de la même portion de mémoire physique, avec une seule adresse physique. En clair, lorsque deux processus partagent une même zone de mémoire, la zone sera mappées à des adresses logiques différentes. Les adresses logiques sont alors appelées des '''adresses synonymes''', terme qui trahit le fait qu'elles correspondent à la même adresse physique.
===La mémoire virtuelle===
Toutes les adresses ne sont pas forcément occupées par de la mémoire RAM, s'il n'y a pas assez de RAM installée. Par exemple, un processeur 32 bits peut adresser 4 gibioctets de RAM, même si seulement 3 gibioctets sont installés dans l'ordinateur. L'espace d'adressage contient donc 1 gigas d'adresses inutilisées, et il faut éviter ce surplus d'adresses pose problème.
Sans mémoire virtuelle, seule la mémoire réellement installée est utilisable. Si un programme utilise trop de mémoire, il est censé se rendre compte qu'il n'a pas accès à tout l'espace d'adressage. Quand il demandera au système d'exploitation de lui réserver de la mémoire, le système d'exploitation le préviendra qu'il n'y a plus de mémoire libre. Par exemple, si un programme tente d'utiliser 4 gibioctets sur un ordinateur avec 3 gibioctets de mémoire, il ne pourra pas. Pareil s'il veut utiliser 2 gibioctets de mémoire sur un ordinateur avec 4 gibioctets, mais dont 3 gibioctets sont déjà utilisés par d'autres programmes. Dans les deux cas, l'illusion tombe à plat.
Les techniques de '''mémoire virtuelle''' font que l'espace d'adressage est utilisable au complet, même s'il n'y a pas assez de mémoire installée dans l'ordinateur ou que d'autres programmes utilisent de la RAM. Par exemple, sur un processeur 32 bits, le programme aura accès à 4 gibioctets de RAM, même si d'autres programmes utilisent la RAM, même s'il n'y a que 2 gibioctets de RAM d'installés dans l'ordinateur.
Pour cela, on utilise une partie des mémoires de masse (disques durs) d'un ordinateur en remplacement de la mémoire physique manquante. Le système d'exploitation crée sur le disque dur un fichier, appelé le ''swapfile'' ou '''fichier de ''swap''''', qui est utilisé comme mémoire RAM supplémentaire. Il mémorise le surplus de données et de programmes qui ne peut pas être mis en mémoire RAM.
[[File:Vm1.png|centre|vignette|upright=2.0|Mémoire virtuelle et fichier de Swap.]]
Une technique naïve de mémoire virtuelle serait la suivante. Avant de l'aborder, précisons qu'il s'agit d'une technique abordée à but pédagogique, mais qui n'est implémentée nulle part tellement elle est lente et inefficace. Un espace d'adressage de 4 gigas ne contient que 3 gigas de RAM, ce qui fait 1 giga d'adresses inutilisées. Les accès mémoire aux 3 gigas de RAM se font normalement, mais l'accès aux adresses inutilisées lève une exception matérielle "Memory Unavailable". La routine d'interruption de cette exception accède alors au ''swapfile'' et récupère les données associées à cette adresse. La mémoire virtuelle est alors émulée par le système d'exploitation.
Le défaut de cette méthode est que l'accès au giga manquant est toujours très lent, parce qu'il se fait depuis le disque dur. D'autres techniques de mémoire virtuelle logicielle font beaucoup mieux, mais nous allons les passer sous silence, vu qu'on peut faire mieux, avec l'aide du matériel.
L'idée est de charger les données dont le programme a besoin dans la RAM, et de déplacer les autres sur le disque dur. Par exemple, imaginons la situation suivante : un programme a besoin de 4 gigas de mémoire, mais ne dispose que de 2 gigas de mémoire installée. On peut imaginer découper l'espace d'adressage en 2 blocs de 2 gigas, qui sont chargés à la demande. Si le programme accède aux adresses basses, on charge les 2 gigas d'adresse basse en RAM. S'il accède aux adresses hautes, on charge les 2 gigas d'adresse haute dans la RAM après avoir copié les adresses basses sur le ''swapfile''.
On perd du temps dans les copies de données entre RAM et ''swapfile'', mais on gagne en performance vu que tous les accès mémoire se font en RAM. Du fait de la localité temporelle, le programme utilise les données chargées depuis le swapfile durant un bon moment avant de passer au bloc suivant. La RAM est alors utilisée comme une sorte de cache alors que les données sont placées dans une mémoire fictive représentée par l'espace d'adressage et qui correspond au disque dur.
Mais avec cette technique, la correspondance entre adresses du programme et adresses de la RAM change au cours du temps. Les adresses de la RAM correspondent d'abord aux adresses basses, puis aux adresses hautes, et ainsi de suite. On a donc besoin d'abstraction mémoire. Les correspondances entre adresse logique et physique peuvent varier avec le temps, ce qui permet de déplacer des données de la RAM vers le disque dur ou inversement. Une adresse logique peut correspondre à une adresse physique, ou bien à une donnée swappée sur le disque dur. C'est l'unité de traduction d'adresse qui se charge de faire la différence. Si une correspondance entre adresse logique et physique est trouvée, elle l'utilise pour traduire les adresses. Si aucune correspondance n'est trouvée, alors elle laisse la main au système d'exploitation pour charger la donnée en RAM. Une fois la donnée chargée en RAM, les correspondances entre adresse logique et physiques sont modifiées de manière à ce que l'adresse logique pointe vers la donnée chargée.
===L'extension d'adressage===
Une autre fonctionnalité rendue possible par l'abstraction mémoire est l{{'}}'''extension d'adressage'''. Elle permet d'utiliser plus de mémoire que l'espace d'adressage ne le permet. Par exemple, utiliser 7 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'extension d'adresse est l'exact inverse de la mémoire virtuelle. La mémoire virtuelle sert quand on a moins de mémoire que d'adresses, l'extension d'adresse sert quand on a plus de mémoire que d'adresses.
Il y a quelques chapitres, nous avions vu que c'est possible via la commutation de banques. Mais l'abstraction mémoire est une méthode alternative. Que ce soit avec la commutation de banques ou avec l'abstraction mémoire, les adresses envoyées à la mémoire doivent être plus longues que les adresses gérées par le processeur. La différence est que l'abstraction mémoire étend les adresses d'une manière différente.
Une implémentation possible de l'extension d'adressage fait usage de l'abstraction matérielle des processus. Chaque processus a son propre espace d'adressage, mais ceux-ci sont placés à des endroits différents dans la mémoire physique. Par exemple, sur un ordinateur avec 16 gigas de RAM, mais un espace d'adressage de 2 gigas, on peut remplir la RAM en lançant 8 processus différents et chaque processus aura accès à un bloc de 2 gigas de RAM, pas plus, il ne peut pas dépasser cette limite. Ainsi, chaque processus est limité par son espace d'adressage, mais on remplit la mémoire avec plusieurs processus, ce qui compense. Il s'agit là de l'implémentation la plus simple, qui a en plus l'avantage d'avoir la meilleure compatibilité logicielle. De simples changements dans le système d'exploitation suffisent à l'implémenter.
[[File:Extension de l'espace d'adressage.png|centre|vignette|upright=1.5|Extension de l'espace d'adressage]]
Un autre implémentation donne plusieurs espaces d'adressage différents à chaque processus, et a donc accès à autant de mémoire que permis par la somme de ces espaces d'adressage. Par exemple, sur un ordinateur avec 16 gigas de RAM et un espace d'adressage de 4 gigas, un programme peut utiliser toute la RAM en utilisant 4 espaces d'adressage distincts. On passe d'un espace d'adressage à l'autre en changeant la correspondance adresse logique-physique. L'inconvénient est que la compatibilité logicielle est assez mauvaise. Modifier l'OS ne suffit pas, les programmeurs doivent impérativement concevoir leurs programmes pour qu'ils utilisent explicitement plusieurs espaces d'adressage.
Les deux implémentations font usage des adresses logiques homonymes, mais à l'intérieur d'un même processus. Pour rappel, cela veut dire qu'une adresse logique correspond à des adresses physiques différentes. Rien d'étonnant vu qu'on utilise plusieurs espaces d'adressage, comme pour l'abstraction des processus, sauf que cette fois-ci, on a plusieurs espaces d'adressage par processus. Prenons l'exemple où on a 8 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'idée est qu'une adresse correspondra à une adresse dans les premiers 4 gigas, ou dans les seconds 4 gigas. L'adresse logique X correspondra d'abord à une adresse physique dans les premiers 4 gigas, puis à une adresse physique dans les seconds 4 gigas.
===La protection mémoire===
La '''protection mémoire''' regroupe des techniques très différentes les unes des autres, qui visent à améliorer la sécurité des programmes et des systèmes d'exploitation. Elles visent à empêcher de lire, d'écrire ou d'exécuter certaines portions de mémoire. Sans elle, les programmes peuvent techniquement lire ou écrire les données des autres, ce qui causent des situations non-prévues par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses.
La première technique de protection mémoire est l{{'}}'''isolation des processus''', qu'on a vue plus haut. Elle garantit que chaque programme n'a accès qu'à certaines portions dédiées de la mémoire et rend le reste de la mémoire inaccessible en lecture et en écriture. Le système d'exploitation attribue à chaque programme une ou plusieurs portions de mémoire rien que pour lui, auquel aucun autre programme ne peut accéder. Un tel programme, isolé des autres, s'appelle un '''processus''', d'où le nom de cet objectif. Toute tentative d'accès à une partie de la mémoire non autorisée déclenche une exception matérielle (rappelez-vous le chapitre sur les interruptions) qui est traitée par une routine du système d'exploitation. Généralement, le programme fautif est sauvagement arrêté et un message d'erreur est affiché à l'écran.
La '''protection de l'espace exécutable''' empêche d’exécuter quoique ce soit provenant de certaines zones de la mémoire. En effet, certaines portions de la mémoire sont censées contenir uniquement des données, sans aucun programme ou code exécutable. Cependant, des virus informatiques peuvent se cacher dedans et d’exécuter depuis celles-ci. Ou encore, des failles de sécurités peuvent permettre à un attaquant d'injecter du code exécutable malicieux dans des données, ce qui peut lui permettre de lire les données manipulées par un programme, prendre le contrôle de la machine, injecter des virus, ou autre. Pour éviter cela, le système d'exploitation peut marquer certaines zones mémoire comme n'étant pas exécutable. Toute tentative d’exécuter du code localisé dans ces zones entraîne la levée d'une exception ou d'une erreur et le système d'exploitation réagit en conséquence. Là encore, le processeur doit détecter les exécutions non autorisées.
D'autres méthodes de protection mémoire visent à limiter des actions dangereuses. Pour cela, le processeur et l'OS gèrent des '''droits d'accès''', qui interdisent certaines actions pour des programmes non-autorisés. Lorsqu'on exécute une opération interdite, le système d’exploitation et/ou le processeur réagissent en conséquence. La première technique de ce genre n'est autre que la séparation entre espace noyau et utilisateur, vue dans le chapitre sur les interruptions. Mais il y en a d'autres, comme nous le verrons dans ce chapitre.
==La MMU==
La traduction des adresses logiques en adresses physiques se fait par un circuit spécialisé appelé la '''''Memory Management Unit''''' (MMU), qui est souvent intégré directement dans l'interface mémoire. La MMU est souvent associée à une ou plusieurs mémoires caches, qui visent à accélérer la traduction d'adresses logiques en adresses physiques. En effet, nous verrons plus bas que la traduction d'adresse demande d'accéder à des tableaux, gérés par le système d'exploitation, qui sont en mémoire RAM. Aussi, les processeurs modernes incorporent des mémoires caches appelées des '''''Translation Lookaside Buffers''''', ou encore TLB. Nous nous pouvons pas parler des TLB pour le moment, car nous n'avons pas encore abordé le chapitre sur les mémoires caches, mais un chapitre entier sera dédié aux TLB d'ici peu.
[[File:MMU principle updated.png|centre|vignette|upright=2|MMU.]]
===Les MMU intégrées au processeur===
D'ordinaire, la MMU est intégrée au processeur. Et elle peut l'être de deux manières. La première en fait un circuit séparé, relié au bus d'adresse. La seconde fusionne la MMU avec l'unité de calcul d'adresse. La première solution est surtout utilisée avec une technique d'abstraction mémoire appelée la pagination, alors que l'autre l'est avec une autre méthode appelée la segmentation. La raison est que la traduction d'adresse avec la segmentation est assez simple : elle demande d'additionner le contenu d'un registre avec l'adresse logique, ce qui est le genre de calcul qu'une unité de calcul d'adresse sait déjà faire. La fusion est donc assez évidente.
Pour donner un exemple, l'Intel 8086 fusionnait l'unité de calcul d'adresse et la MMU. Précisément, il utilisait un même additionneur pour incrémenter le ''program counter'' et effectuer des calculs d'adresse liés à la segmentation. Il aurait été logique d'ajouter les pointeurs de pile avec, mais ce n'était pas possible. La raison est que le pointeur de pile ne peut pas être envoyé directement sur le bus d'adresse, vu qu'il doit passer par une phase de traduction en adresse physique liée à la segmentation.
[[File:80186 arch.png|centre|vignette|upright=2|Intel 8086, microarchitecture.]]
===Les MMU séparées du processeur, sur la carte mère===
Il a existé des processeurs avec une MMU externe, soudée sur la carte mère.
Par exemple, les processeurs Motorola 68000 et 68010 pouvaient être combinés avec une MMU de type Motorola 68451. Elle supportait des versions simplifiées de la segmentation et de la pagination. Au minimum, elle ajoutait un support de la protection mémoire contre certains accès non-autorisés. La gestion de la mémoire virtuelle proprement dit n'était possible que si le processeur utilisé était un Motorola 68010, en raison de la manière dont le 68000 gérait ses accès mémoire. La MMU 68451 gérait un espace d'adressage de 16 mébioctets, découpé en maximum 32 pages/segments. On pouvait dépasser cette limite de 32 segments/pages en combinant plusieurs 68451.
Le Motorola 68851 était une MMU qui était prévue pour fonctionner de paire avec le Motorola 68020. Elle gérait la pagination pour un espace d'adressage de 32 bits.
Les processeurs suivants, les 68030, 68040, et 68060, avaient une MMU interne au processeur.
==La relocation matérielle==
Pour rappel, les systèmes d'exploitation moderne permettent de lancer plusieurs programmes en même temps et les laissent se partager la mémoire. Dans le cas le plus simple, qui n'est pas celui des OS modernes, le système d'exploitation découpe la mémoire en blocs d'adresses contiguës qui sont appelés des '''segments''', ou encore des ''partitions mémoire''. Les segments correspondent à un bloc de mémoire RAM. C'est-à-dire qu'un segment de 259 mébioctets sera un segment continu de 259 mébioctets dans la mémoire physique comme dans la mémoire logique. Dans ce qui suit, un segment contient un programme en cours d'exécution, comme illustré ci-dessous.
[[File:CPT Memory Addressable.svg|centre|vignette|upright=2|Espace d'adressage segmenté.]]
Le système d'exploitation mémorise la position de chaque segment en mémoire, ainsi que d'autres informations annexes. Le tout est regroupé dans la '''table de segment''', un tableau dont chaque case est attribuée à un programme/segment. La table des segments est un tableau numéroté, chaque segment ayant un numéro qui précise sa position dans le tableau. Chaque case, chaque entrée, contient un '''descripteur de segment''' qui regroupe plusieurs informations sur le segment : son adresse de base, sa taille, diverses informations.
===La relocation avec la relocation matérielle : le registre de base===
Un segment peut être placé n'importe où en RAM physique et sa position en RAM change à chaque exécution. Le programme est chargé à une adresse, celle du début du segment, qui change à chaque chargement du programme. Et toutes les adresses utilisées par le programme doivent être corrigées lors du chargement du programme, généralement par l'OS. Cette correction s'appelle la '''relocation''', et elle consiste à ajouter l'adresse de début du segment à chaque adresse manipulée par le programme.
[[File:Relocation assistée par matériel.png|centre|vignette|upright=2.5|Relocation.]]
La relocation matérielle fait que la relocation est faite par le processeur, pas par l'OS. La relocation est intégrée dans le processeur par l'intégration d'un registre : le '''registre de base''', aussi appelé '''registre de relocation'''. Il mémorise l'adresse à laquelle commence le segment, la première adresse du programme. Pour effectuer la relocation, le processeur ajoute automatiquement l'adresse de base à chaque accès mémoire, en allant la chercher dans le registre de relocation.
[[File:Registre de base de segment.png|centre|vignette|upright=2|Registre de base de segment.]]
Le processeur s'occupe de la relocation des segments et le programme compilé n'en voit rien. Pour le dire autrement, les programmes manipulent des adresses logiques, qui sont traduites par le processeur en adresses physiques. La traduction se fait en ajoutant le contenu du registre de relocation à l'adresse logique. De plus, cette méthode fait que chaque programme a son propre espace d'adressage.
[[File:CPU created logical address presentation.png|centre|vignette|upright=2|Traduction d'adresse avec la relocation matérielle.]]
Le système d'exploitation mémorise les adresses de base pour chaque programme, dans la table des segments. Le registre de base est mis à jour automatiquement lors de chaque changement de segment. Pour cela, le registre de base est accessible via certaines instructions, accessibles en espace noyau, plus rarement en espace utilisateur. Le registre de segment est censé être adressé implicitement, vu qu'il est unique. Si ce n'est pas le cas, il est possible d'écrire dans ce registre de segment, qui est alors adressable.
===La protection mémoire avec la relocation matérielle : le registre limite===
Sans restrictions supplémentaires, la taille maximale d'un segment est égale à la taille complète de l'espace d'adressage. Sur les processeurs 32 bits, un segment a une taille maximale de 2^32 octets, soit 4 gibioctets. Mais il est possible de limiter la taille du segment à 2 gibioctets, 1 gibioctet, 64 Kibioctets, ou toute autre taille. La limite est définie lors de la création du segment, mais elle peut cependant évoluer au cours de l'exécution du programme, grâce à l'allocation mémoire. Le processeur vérifie à chaque accès mémoire que celui-ci se fait bien dans le segment, en comparant l'adresse accédée à l'adresse de base et l'adresse maximale, l'adresse limite.
Limiter la taille d'un segment demande soit de mémoriser sa taille, soit de mémoriser l'adresse limite (l'adresse de fin de segment, l'adresse limite à ne pas dépasser). Les deux sont possibles et marchent parfaitement, le choix entre les deux solutions est une pure question de préférence. A la rigueur, la vérification des débordements est légèrement plus rapide si on utilise l'adresse de fin du segment. Précisons que l'adresse limite est une adresse logique, le segment commence toujours à l'adresse logique zéro.
Pour cela, la table des segments doit être modifiée. Au lieu de ne contenir que l'adresse de base, elle contient soit l'adresse maximale du segment, soit la taille du segment. En clair, le descripteur de segment est enrichi avec l'adresse limite. 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.
Techniquement, il y a une petite différence de vitesse entre utiliser la taille et l'adresse maximale. Vérifier les débordements avec la taille demande juste de comparer la taille avec l'adresse logique, avant relocation, ce qui peut être fait en parallèle de la relocation. Par contre, l'adresse limite est comparée à une adresse physique, ce qui demande de faire la relocation avant la vérification, ce qui prend un peu plus de temps. Mais l'impact sur les performances est des plus mineurs.
[[File:Registre limite.png|centre|vignette|upright=2|Registre limite]]
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> </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>+ </code><code style="background:#DDF">0001 0010 0011 0100</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </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. Au lieu d'utiliser un additionneur séparé pour le ''program counter'' et un autre pour le calcul de l'adresse physique, un seul additionneur est utilisé pour les deux. L'idée était de partager l'additionneur, qui servait à la fois à incrémenter le ''program counter'' et pour gérer la segmentation. 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|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> </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>+ </code><code style="background:#DDF">1111 1111 1111 1111</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </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 de nos PCs, à partir du 286 d'Intel. 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. A 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. A la place, les segments sont adressés de manière indirecte. A 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 utilise l'adresse ou la taille limite pour vérifier si l'accès mémoire ne déborde pas en-dehors du segment en cours. Pour cela, le processeur compare l'adresse logique accédée avec l'adresse limite, ou compare la taille limite avec le décalage. L'information est lue depuis la table des segments à chaque accès.
[[File:Vm7.svg|centre|vignette|upright=2|Traduction d'adresse avec vérification des accès hors-segment.]]
Par contre, 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.
====L'implémentation de la protection mémoire sur le 386====
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 protection mémoire teste la valeur des bits P, S, X, E, R/W, et peut aussi modifier le bit A. 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. A 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]]
A 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. A 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. A la place, l'entrée contient un '''bit ''global''''', qui précise que cette correspondance est valide pour tous les processus. Le bit global accélère rapidement la traduction d'adresse pour l'accès au noyau.
Un défaut de cette méthode est que le partage d'une page entre plusieurs processus est presque impossible. Impossible de partager une page avec seulement certains processus et pas d'autres : soit on partage une page avec tous les processus, soit on l'alloue avec un seul processus.
====L'usage de plusieurs tables des pages====
Une solution alternative, plus simple, utilise une table des pages par processus lancé sur l'ordinateur, une table des pages unique par espace d'adressage. À chaque changement de processus, le registre qui mémorise la position de la table des pages est modifié pour pointer sur la bonne. C'est le système d'exploitation qui se charge de cette mise à jour.
Avec cette méthode, il est possible de partager une ou plusieurs pages entre plusieurs processus, en configurant les tables des pages convenablement. Les pages partagées sont mappées dans l'espace d'adressage de plusieurs processus, mais pas forcément au même endroit, pas forcément dans les mêmes adresses logiques. On peut placer la page partagée à l'adresse logique 0x0FFF pour un processus, à l'adresse logique 0xFF00 pour un autre processus, etc. Par contre, les entrées de la table des pages pour ces adresses pointent vers la même adresse physique.
[[File:Vm5.png|centre|vignette|upright=2|Tables des pages de plusieurs processus.]]
===La taille des pages===
La taille des pages varie suivant le processeur et le système d'exploitation et tourne souvent autour de 4 kibioctets. Les processeurs actuels gèrent plusieurs tailles différentes pour les pages : 4 kibioctets par défaut, 2 mébioctets, voire 1 à 4 gibioctets pour les pages les plus larges. Les pages de 4 kibioctets sont les pages par défaut, les autres tailles de page sont appelées des ''pages larges''. La taille optimale pour les pages dépend de nombreux paramètres et il n'y a pas de taille qui convienne à tout le monde. Certaines applications gagnent à utiliser des pages larges, d'autres vont au contraire perdre drastiquement en performance en les utilisant.
Le désavantage principal des pages larges est qu'elles favorisent la fragmentation mémoire. Si un programme veut réserver une portion de mémoire, pour une structure de donnée quelconque, il doit réserver une portion dont la taille est multiple de la taille d'une page. Par exemple, un programme ayant besoin de 110 kibioctets allouera 28 pages de 4 kibioctets, soit 120 kibioctets : 2 kibioctets seront perdus. Par contre, avec des pages larges de 2 mébioctets, on aura une perte de 2048 - 110 = 1938 kibioctets. En somme, des morceaux de mémoire seront perdus, car les pages sont trop grandes pour les données qu'on veut y mettre. Le résultat est que le programme qui utilise les pages larges utilisent plus de mémoire et ce d'autant plus qu'il utilise des données de petite taille. Un autre désavantage est qu'elles se marient mal avec certaines techniques d'optimisations de type ''copy-on-write''.
Mais l'avantage est que la traduction des adresses est plus performante. Une taille des pages plus élevée signifie moins de pages, donc des tables des pages plus petites. Et des pages des tables plus petites n'ont pas besoin de beaucoup de niveaux de hiérarchie, voire peuvent se limiter à des tables des pages simples, ce qui rend la traduction d'adresse plus simple et plus rapide. De plus, les programmes ont une certaine localité spatiale, qui font qu'ils accèdent souvent à des données proches. La traduction d'adresse peut alors profiter de systèmes de mise en cache dont nous parlerons dans le prochain chapitre, et ces systèmes de cache marchent nettement mieux avec des pages larges.
Il faut noter que la taille des pages est presque toujours une puissance de deux. Cela a de nombreux avantages, mais n'est pas une nécessité. Par exemple, le tout premier processeur avec de la pagination, le super-ordinateur Atlas, avait des pages de 3 kibioctets. L'avantage principal est que la traduction de l'adresse physique en adresse logique est trivial avec une puissance de deux. Cela garantit que l'on peut diviser l'adresse en un numéro de page et un ''offset'' : la traduction demande juste de remplacer les bits de poids forts par le numéro de page voulu. Sans cela, la traduction d'adresse implique des divisions et des multiplications, qui sont des opérations assez couteuses.
===Les entrées de la table des pages===
Avant de poursuivre, faisons un rapide rappel sur les entrées de la table des pages. Nous venons de voir que la table des pages contient de nombreuses informations : un bit ''valid'' pour la mémoire virtuelle, des bits ''dirty'' et ''accessed'' utilisés par l'OS, des bits de protection mémoire, un bit ''global'' et un potentiellement un identifiant de processus, etc. Étudions rapidement le format de la table des pages sur un processeur x86 32 bits.
* Elle contient d'abord le numéro de page physique.
* Les bits AVL sont inutilisés et peuvent être configurés à loisir par l'OS.
* Le bit G est le bit ''global''.
* Le bit PS vaut 0 pour une page de 4 kibioctets, mais est mis à 1 pour une page de 4 mébioctets dans le cas où le processus utilise des pages larges.
* Le bit D est le bit ''dirty''.
* Le bit A est le bit ''accessed''.
* Le bit PCD indique que la page ne peut pas être cachée, dans le sens où le processeur ne peut copier son contenu dans le cache et doit toujours lire ou écrire cette page directement dans la RAM.
* Le bit PWT indique que les écritures doivent mettre à jour le cache et la page en RAM (dans le chapitre sur le cache, on verra qu'il force le cache à se comporter comme un cache ''write-through'' pour cette page).
* Le bit U/S précise si la page est accessible en mode noyau ou utilisateur.
* Le bit R/W indique si la page est accessible en écriture, toutes les pages sont par défaut accessibles en lecture.
* Le bit P est le bit ''valid''.
[[File:PDE.png|centre|vignette|upright=2.5|Table des pages des processeurs Intel 32 bits.]]
==Comparaison des différentes techniques d'abstraction mémoire==
Pour résumer, l'abstraction mémoire permet de gérer : la relocation, la protection mémoire, l'isolation des processus, la mémoire virtuelle, l'extension de l'espace d'adressage, le partage de mémoire, etc. Elles sont souvent implémentées en même temps. Ce qui fait qu'elles sont souvent confondues, alors que ce sont des concepts sont différents. Ces liens sont résumés dans le tableau ci-dessous.
{|class="wikitable"
|-
!
! colspan="5" | Avec abstraction mémoire
! rowspan="2" | Sans abstraction mémoire
|-
!
! Relocation matérielle
! Segmentation en mode réel (x86)
! Segmentation, général
! Architectures à capacités
! Pagination
|-
! Abstraction matérielle des processus
| colspan="4" | Oui, relocation matérielle
| Oui, liée à la traduction d'adresse
| Impossible
|-
! Mémoire virtuelle
| colspan="2" | Non, sauf émulation logicielle
| colspan="3" | Oui, gérée par le processeur et l'OS
| Non, sauf émulation logicielle
|-
! Extension de l'espace d'adressage
| colspan="2" | Oui : registre de base élargi
| colspan="2" | Oui : adresse de base élargie dans la table des segments
| ''Physical Adress Extension'' des processeurs 32 bits
| Commutation de banques
|-
! Protection mémoire
| Registre limite
| Aucune
| colspan="2" | Registre limite, droits d'accès aux segments
| Gestion des droits d'accès aux pages
| Possible, méthodes variées
|-
! Partage de mémoire
| colspan="2" | Non
| colspan="2" | Segment partagés
| Pages partagées
| Possible, méthodes variées
|}
===Les différents types de segmentation===
La segmentation regroupe plusieurs techniques franchement différentes, qui auraient gagné à être nommées différemment. La principale différence est l'usage de registres de relocation versus des registres de sélecteurs de segments. L'usage de registres de relocation est le fait de la relocation matérielle, mais aussi de la segmentation en mode réel des CPU x86. Par contre, l'usage de sélecteurs de segments est le fait des autres formes de segmentation, architectures à capacité inclues.
La différence entre les deux est le nombre de segments. L'usage de registres de relocation fait que le CPU ne gère qu'un petit nombre de segments de grande taille. La mémoire virtuelle est donc rarement implémentée vu que swapper des segments de grande taille est trop long, l'impact sur les performances est trop important. Sans compter que l'usage de registres de base se marie très mal avec la mémoire virtuelle. Vu qu'un segment peut être swappé ou déplacée n'importe quand, il faut invalider les registres de base au moment du swap/déplacement, ce qui n'est pas chose aisée. Aucun processeur ne gère cela, les méthodes pour n'existent tout simplement pas. L'usage de registres de base implique que la mémoire virtuelle est absente.
La protection mémoire est aussi plus limitée avec l'usage de registres de relocation. Elle se limite à des registres limite, mais la gestion des droits d'accès est limitée. En théorie, la segmentation en mode réel pourrait implémenter une version limitée de protection mémoire, avec une protection de l'espace exécutable. Mais ca n'a jamais été fait en pratique sur les processeurs x86.
Le partage de la mémoire est aussi difficile sur les architectures avec des registres de base. L'absence de table des segments fait que le partage d'un segment est basiquement impossible sans utiliser des méthodes complétement tordues, qui ne sont jamais implémentées en pratique.
===Segmentation versus pagination===
Par rapport à la pagination, la segmentation a des avantages et des inconvénients. Tous sont liés aux propriétés des segments et pages : les segments sont de grande taille et de taille variable, les pages sont petites et de taille fixe.
L'avantage principal de la segmentation est sa rapidité. Le fait que les segments sont de grande taille fait qu'on a pas besoin d'équivalent aux tables des pages inversée ou multiple, juste d'une table des segments toute simple. De plus, les échanges entre table des pages/segments et registres sont plus rares avec la segmentation. Par exemple, si un programme utilise un segment de 2 gigas, tous les accès dans le segment se feront avec une seule consultation de la table des segments. Alors qu'avec la pagination, il faudra une consultation de la table des pages chaque bloc de 4 kibioctet, au minimum.
Mais les désavantages sont nombreux. Le système d'exploitation doit agencer les segments en RAM, et c'est une tâche complexe. Le fait que les segments puisse changer de taille rend le tout encore plus complexe. Par exemple, si on colle les segments les uns à la suite des autres, changer la taille d'un segment demande de réorganiser tous les segments en RAM, ce qui demande énormément de copies RAM-RAM. Une autre possibilité est de laisser assez d'espace entre les segments, mais cet espace est alors gâché, dans le sens où on ne peut pas y placer un nouveau segment.
Swapper un segment est aussi très long, vu que les segments sont de grande taille, alors que swapper une page est très rapide.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'espace d'adressage du processeur
| prevText=L'espace d'adressage du processeur
| next=Les méthodes de synchronisation entre processeur et périphériques
| nextText=Les méthodes de synchronisation entre processeur et périphériques
}}
</noinclude>
dgib3revj1baxllow692dotw7chn6ou
763696
763695
2026-04-14T22:52:18Z
Mewtow
31375
/* L'implémentation de la protection mémoire sur le 386 */
763696
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 envoyées à 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 sachez qu'il existe 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 les adresses de l'espace d'adressage sont 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'abstraction mémoire implémente plusieurs fonctionnalités complémentaires==
L'utilité de l'abstraction matérielle n'est pas évidente, mais sachez qu'elle est si utile que tous les processeurs modernes la prennent en charge. Elle sert notamment à implémenter la mémoire virtuelle, que nous aborderons dans ce qui suit. La plupart de ces fonctionnalités manipulent la relation entre adresses logiques et physique. Dans le cas le plus simple, une adresse logique correspond à une seule adresse physique. Mais beaucoup de fonctionnalités avancées ne respectent pas cette règle.
===L'abstraction matérielle des processus===
Les systèmes d'exploitation modernes sont dits multi-tâche, à savoir qu'ils sont capables d'exécuter plusieurs logiciels en même temps. Et ce même si un seul processeur est présent dans l'ordinateur : les logiciels sont alors exécutés à tour de rôle. Toutefois, cela amène un paquet de problèmes qu'il faut résoudre au mieux. Par exemple, les programmes exécutés doivent se partager la mémoire RAM, ce qui ne vient pas sans problèmes. Le problème principal est que les programmes ne doivent pas lire ou écrire dans les données d'un autre, sans quoi on se retrouverait rapidement avec des problèmes. Il faut donc introduire des mécanismes d{{'}}'''isolement des processus''', pour isoler les programmes les uns des autres.
Un de ces mécanismes est l{{'}}'''abstraction matérielle des processus''', une technique qui fait que chaque programme a son propre espace d'adressage. Chaque programme a l'impression d'avoir accès à tout l'espace d'adressage, de l'adresse 0 à l'adresse maximale gérée par le processeur. Évidemment, il s'agit d'une illusion maintenue justement grâce à la traduction d'adresse. Les espaces d'adressage contiennent des adresses logiques, les adresses de la RAM sont des adresses physiques, la nécessité de l'abstraction mémoire est évidente.
Implémenter l'abstraction mémoire peut se faire de plusieurs manières. Mais dans tous les cas, il faut que la correspondance adresse logique - physique change d'un programme à l'autre. Ce qui est normal, vu que les deux processus sont placés à des endroits différents en RAM physique. La conséquence est qu'avec l'abstraction mémoire, une adresse logique correspond à plusieurs adresses physiques. Une même adresse logique dans deux processus différents correspond à deux adresses phsiques différentes, une par processus. Une adresse logique dans un processus correspondra à l'adresse physique X, la même adresse dans un autre processus correspondra à l'adresse Y.
Les adresses physiques qui partagent la même adresse logique sont alors appelées des '''adresses homonymes'''. Le choix de la bonne adresse étant réalisé par un mécanisme matériel et dépend du programme en cours. Le mécanisme pour choisir la bonne adresse dépend du processeur, mais il y en a deux grands types :
* La première consiste à utiliser l'identifiant de processus CPU, vu au chapitre précédent. C'est, pour rappel, un numéro attribué à chaque processus par le processeur. L'identifiant du processus en cours d'exécution est mémorisé dans un registre du processeur. La traduction d'adresse utilise cet identifiant, en plus de l'adresse logique, pour déterminer l'adresse physique.
* La seconde solution mémorise les correspondances adresses logiques-physique dans des tables en mémoire RAM, qui sont différentes pour chaque programme. Les tables sont accédées à chaque accès mémoire, afin de déterminer l'adresse physique.
===Le partage de la mémoire===
L'isolation des processus est très importante sur les systèmes d'exploitation modernes. Cependant, il existe quelques situations où elle doit être contournée ou du moins mise en pause. Les situations sont multiples : gestion de bibliothèques partagées, communication entre processus, usage de ''threads'', etc. Elles impliquent toutes un '''partage de mémoire''', à savoir qu'une portion de mémoire RAM est partagée entre plusieurs programmes. Le partage de mémoire est une sorte de brèche de l'isolation des processus, mais qui est autorisée car elle est utile.
Un cas intéressant est celui des '''bibliothèques partagées'''. Les bibliothèques sont des collections de fonctions regroupées ensemble, dans une seule unité de code. Un programme qui utilise une bibliothèque peut appeler n’importe quelle fonction présente dans la bibliothèque. La bibliothèque peut être simplement inclue dans le programme lui-même, on parle alors de bibliothèques statiques. De telles bibliothèques fonctionnent très bien, mais avec un petit défaut pour les bibliothèques très utilisées : plusieurs programmes qui utilisent la même bibliothèque vont chacun l'inclure dans leur code, ce qui fera doublon.
Pour éviter cela, les OS modernes gèrent des bibliothèques partagées, à savoir qu'un seul exemplaire de la bibliothèque est partagé entre plusieurs programmes. Chaque programme peut exécuter une fonction de la bibliothèque quand il le souhaite, en effectuant un branchement adéquat. Mais cela implique que la bibliothèque soit présente dans l'espace d'adressage du programme en question. Une bibliothèque est donc présente dans plusieurs espaces d'adressage, alors qu'il n'y en a qu'un seul exemplaire en mémoire RAM.
[[File:Ogg vorbis libs and application dia.svg|centre|vignette|upright=2|Exemple de bibliothèques, avec Ogg vorbis.]]
D'autres situations demandent de partager de la mémoire entre deux programmes. Par exemple, les systèmes d'exploitation modernes gèrent nativement des systèmes de '''communication inter-processus''', très utilisés par les programmes modernes pour échanger des données. Et la plupart demandant de partager un bout de mémoire entre processus, même si c'est seulement temporairement. Typiquement, deux processus partagent un intervalle d'adresse où l'un écrit les données à l'autre, l'autre lisant les données envoyées.
Une dernière utilisation de la mémoire partagée est l{{'}}'''accès direct au noyau'''. Sur les systèmes d'exploitations moderne, dans l'espace d'adressage de chaque programme, les adresses hautes sont remplies avec une partie du noyau ! Évidemment, ces adresses sont accessibles uniquement en lecture, pas en écriture. Pas question de modifier le noyau de l'OS ! De plus, il s'agit d'une portion du noyau dont on sait que la consultation ne pose pas de problèmes de sécurité.
Le programme peut lire des données dans cette portion du noyau, mais aussi exécuter les fonctions du noyau qui sont dedans. L'idée est d'éviter des appels systèmes trop fréquents. Au lieu d'effectuer un véritable appel système, avec une interruption logicielle, le programme peut exécuter des appels systèmes simplifiés, de simples appels de fonctions couplés avec un changement de niveau de privilège (passage en espace noyau nécessaire).
[[File:AMD64-canonical--48-bit.png|vignette|Répartition des adresses entre noyau (jaune/orange) et programme (verte), sur les systèmes x86-64 bits, avec des adresses physiques de 48 bits.]]
L'espace d'adressage est donc séparé en deux portions : l'OS d'un côté, le programme de l'autre. La répartition des adresses entre noyau et programme varie suivant l'OS ou le processeur utilisé. Sur les PC x86 32 bits, Linux attribuait 3 gigas pour les programmes et 1 giga pour le noyau, Windows attribuait 2 gigas à chacun. Sur les systèmes x86 64 bits, l'espace d'adressage d'un programme est coupé en trois, comme illustré ci-contre : une partie basse de 2^48 octets, une partie haute de même taille, et un bloc d'adresses invalides entre les deux. Les adresses basses sont utilisées pour le programme, les adresses hautes pour le noyau, il n'y a rien entre les deux.
Avec le partage de mémoire, plusieurs adresses logiques correspondent à la même adresse physique. Tel processus verra la zone de mémoire partagée à l'adresse X, l'autre la verra à l'adresse Y. Mais il s'agira de la même portion de mémoire physique, avec une seule adresse physique. En clair, lorsque deux processus partagent une même zone de mémoire, la zone sera mappées à des adresses logiques différentes. Les adresses logiques sont alors appelées des '''adresses synonymes''', terme qui trahit le fait qu'elles correspondent à la même adresse physique.
===La mémoire virtuelle===
Toutes les adresses ne sont pas forcément occupées par de la mémoire RAM, s'il n'y a pas assez de RAM installée. Par exemple, un processeur 32 bits peut adresser 4 gibioctets de RAM, même si seulement 3 gibioctets sont installés dans l'ordinateur. L'espace d'adressage contient donc 1 gigas d'adresses inutilisées, et il faut éviter ce surplus d'adresses pose problème.
Sans mémoire virtuelle, seule la mémoire réellement installée est utilisable. Si un programme utilise trop de mémoire, il est censé se rendre compte qu'il n'a pas accès à tout l'espace d'adressage. Quand il demandera au système d'exploitation de lui réserver de la mémoire, le système d'exploitation le préviendra qu'il n'y a plus de mémoire libre. Par exemple, si un programme tente d'utiliser 4 gibioctets sur un ordinateur avec 3 gibioctets de mémoire, il ne pourra pas. Pareil s'il veut utiliser 2 gibioctets de mémoire sur un ordinateur avec 4 gibioctets, mais dont 3 gibioctets sont déjà utilisés par d'autres programmes. Dans les deux cas, l'illusion tombe à plat.
Les techniques de '''mémoire virtuelle''' font que l'espace d'adressage est utilisable au complet, même s'il n'y a pas assez de mémoire installée dans l'ordinateur ou que d'autres programmes utilisent de la RAM. Par exemple, sur un processeur 32 bits, le programme aura accès à 4 gibioctets de RAM, même si d'autres programmes utilisent la RAM, même s'il n'y a que 2 gibioctets de RAM d'installés dans l'ordinateur.
Pour cela, on utilise une partie des mémoires de masse (disques durs) d'un ordinateur en remplacement de la mémoire physique manquante. Le système d'exploitation crée sur le disque dur un fichier, appelé le ''swapfile'' ou '''fichier de ''swap''''', qui est utilisé comme mémoire RAM supplémentaire. Il mémorise le surplus de données et de programmes qui ne peut pas être mis en mémoire RAM.
[[File:Vm1.png|centre|vignette|upright=2.0|Mémoire virtuelle et fichier de Swap.]]
Une technique naïve de mémoire virtuelle serait la suivante. Avant de l'aborder, précisons qu'il s'agit d'une technique abordée à but pédagogique, mais qui n'est implémentée nulle part tellement elle est lente et inefficace. Un espace d'adressage de 4 gigas ne contient que 3 gigas de RAM, ce qui fait 1 giga d'adresses inutilisées. Les accès mémoire aux 3 gigas de RAM se font normalement, mais l'accès aux adresses inutilisées lève une exception matérielle "Memory Unavailable". La routine d'interruption de cette exception accède alors au ''swapfile'' et récupère les données associées à cette adresse. La mémoire virtuelle est alors émulée par le système d'exploitation.
Le défaut de cette méthode est que l'accès au giga manquant est toujours très lent, parce qu'il se fait depuis le disque dur. D'autres techniques de mémoire virtuelle logicielle font beaucoup mieux, mais nous allons les passer sous silence, vu qu'on peut faire mieux, avec l'aide du matériel.
L'idée est de charger les données dont le programme a besoin dans la RAM, et de déplacer les autres sur le disque dur. Par exemple, imaginons la situation suivante : un programme a besoin de 4 gigas de mémoire, mais ne dispose que de 2 gigas de mémoire installée. On peut imaginer découper l'espace d'adressage en 2 blocs de 2 gigas, qui sont chargés à la demande. Si le programme accède aux adresses basses, on charge les 2 gigas d'adresse basse en RAM. S'il accède aux adresses hautes, on charge les 2 gigas d'adresse haute dans la RAM après avoir copié les adresses basses sur le ''swapfile''.
On perd du temps dans les copies de données entre RAM et ''swapfile'', mais on gagne en performance vu que tous les accès mémoire se font en RAM. Du fait de la localité temporelle, le programme utilise les données chargées depuis le swapfile durant un bon moment avant de passer au bloc suivant. La RAM est alors utilisée comme une sorte de cache alors que les données sont placées dans une mémoire fictive représentée par l'espace d'adressage et qui correspond au disque dur.
Mais avec cette technique, la correspondance entre adresses du programme et adresses de la RAM change au cours du temps. Les adresses de la RAM correspondent d'abord aux adresses basses, puis aux adresses hautes, et ainsi de suite. On a donc besoin d'abstraction mémoire. Les correspondances entre adresse logique et physique peuvent varier avec le temps, ce qui permet de déplacer des données de la RAM vers le disque dur ou inversement. Une adresse logique peut correspondre à une adresse physique, ou bien à une donnée swappée sur le disque dur. C'est l'unité de traduction d'adresse qui se charge de faire la différence. Si une correspondance entre adresse logique et physique est trouvée, elle l'utilise pour traduire les adresses. Si aucune correspondance n'est trouvée, alors elle laisse la main au système d'exploitation pour charger la donnée en RAM. Une fois la donnée chargée en RAM, les correspondances entre adresse logique et physiques sont modifiées de manière à ce que l'adresse logique pointe vers la donnée chargée.
===L'extension d'adressage===
Une autre fonctionnalité rendue possible par l'abstraction mémoire est l{{'}}'''extension d'adressage'''. Elle permet d'utiliser plus de mémoire que l'espace d'adressage ne le permet. Par exemple, utiliser 7 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'extension d'adresse est l'exact inverse de la mémoire virtuelle. La mémoire virtuelle sert quand on a moins de mémoire que d'adresses, l'extension d'adresse sert quand on a plus de mémoire que d'adresses.
Il y a quelques chapitres, nous avions vu que c'est possible via la commutation de banques. Mais l'abstraction mémoire est une méthode alternative. Que ce soit avec la commutation de banques ou avec l'abstraction mémoire, les adresses envoyées à la mémoire doivent être plus longues que les adresses gérées par le processeur. La différence est que l'abstraction mémoire étend les adresses d'une manière différente.
Une implémentation possible de l'extension d'adressage fait usage de l'abstraction matérielle des processus. Chaque processus a son propre espace d'adressage, mais ceux-ci sont placés à des endroits différents dans la mémoire physique. Par exemple, sur un ordinateur avec 16 gigas de RAM, mais un espace d'adressage de 2 gigas, on peut remplir la RAM en lançant 8 processus différents et chaque processus aura accès à un bloc de 2 gigas de RAM, pas plus, il ne peut pas dépasser cette limite. Ainsi, chaque processus est limité par son espace d'adressage, mais on remplit la mémoire avec plusieurs processus, ce qui compense. Il s'agit là de l'implémentation la plus simple, qui a en plus l'avantage d'avoir la meilleure compatibilité logicielle. De simples changements dans le système d'exploitation suffisent à l'implémenter.
[[File:Extension de l'espace d'adressage.png|centre|vignette|upright=1.5|Extension de l'espace d'adressage]]
Un autre implémentation donne plusieurs espaces d'adressage différents à chaque processus, et a donc accès à autant de mémoire que permis par la somme de ces espaces d'adressage. Par exemple, sur un ordinateur avec 16 gigas de RAM et un espace d'adressage de 4 gigas, un programme peut utiliser toute la RAM en utilisant 4 espaces d'adressage distincts. On passe d'un espace d'adressage à l'autre en changeant la correspondance adresse logique-physique. L'inconvénient est que la compatibilité logicielle est assez mauvaise. Modifier l'OS ne suffit pas, les programmeurs doivent impérativement concevoir leurs programmes pour qu'ils utilisent explicitement plusieurs espaces d'adressage.
Les deux implémentations font usage des adresses logiques homonymes, mais à l'intérieur d'un même processus. Pour rappel, cela veut dire qu'une adresse logique correspond à des adresses physiques différentes. Rien d'étonnant vu qu'on utilise plusieurs espaces d'adressage, comme pour l'abstraction des processus, sauf que cette fois-ci, on a plusieurs espaces d'adressage par processus. Prenons l'exemple où on a 8 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'idée est qu'une adresse correspondra à une adresse dans les premiers 4 gigas, ou dans les seconds 4 gigas. L'adresse logique X correspondra d'abord à une adresse physique dans les premiers 4 gigas, puis à une adresse physique dans les seconds 4 gigas.
===La protection mémoire===
La '''protection mémoire''' regroupe des techniques très différentes les unes des autres, qui visent à améliorer la sécurité des programmes et des systèmes d'exploitation. Elles visent à empêcher de lire, d'écrire ou d'exécuter certaines portions de mémoire. Sans elle, les programmes peuvent techniquement lire ou écrire les données des autres, ce qui causent des situations non-prévues par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses.
La première technique de protection mémoire est l{{'}}'''isolation des processus''', qu'on a vue plus haut. Elle garantit que chaque programme n'a accès qu'à certaines portions dédiées de la mémoire et rend le reste de la mémoire inaccessible en lecture et en écriture. Le système d'exploitation attribue à chaque programme une ou plusieurs portions de mémoire rien que pour lui, auquel aucun autre programme ne peut accéder. Un tel programme, isolé des autres, s'appelle un '''processus''', d'où le nom de cet objectif. Toute tentative d'accès à une partie de la mémoire non autorisée déclenche une exception matérielle (rappelez-vous le chapitre sur les interruptions) qui est traitée par une routine du système d'exploitation. Généralement, le programme fautif est sauvagement arrêté et un message d'erreur est affiché à l'écran.
La '''protection de l'espace exécutable''' empêche d’exécuter quoique ce soit provenant de certaines zones de la mémoire. En effet, certaines portions de la mémoire sont censées contenir uniquement des données, sans aucun programme ou code exécutable. Cependant, des virus informatiques peuvent se cacher dedans et d’exécuter depuis celles-ci. Ou encore, des failles de sécurités peuvent permettre à un attaquant d'injecter du code exécutable malicieux dans des données, ce qui peut lui permettre de lire les données manipulées par un programme, prendre le contrôle de la machine, injecter des virus, ou autre. Pour éviter cela, le système d'exploitation peut marquer certaines zones mémoire comme n'étant pas exécutable. Toute tentative d’exécuter du code localisé dans ces zones entraîne la levée d'une exception ou d'une erreur et le système d'exploitation réagit en conséquence. Là encore, le processeur doit détecter les exécutions non autorisées.
D'autres méthodes de protection mémoire visent à limiter des actions dangereuses. Pour cela, le processeur et l'OS gèrent des '''droits d'accès''', qui interdisent certaines actions pour des programmes non-autorisés. Lorsqu'on exécute une opération interdite, le système d’exploitation et/ou le processeur réagissent en conséquence. La première technique de ce genre n'est autre que la séparation entre espace noyau et utilisateur, vue dans le chapitre sur les interruptions. Mais il y en a d'autres, comme nous le verrons dans ce chapitre.
==La MMU==
La traduction des adresses logiques en adresses physiques se fait par un circuit spécialisé appelé la '''''Memory Management Unit''''' (MMU), qui est souvent intégré directement dans l'interface mémoire. La MMU est souvent associée à une ou plusieurs mémoires caches, qui visent à accélérer la traduction d'adresses logiques en adresses physiques. En effet, nous verrons plus bas que la traduction d'adresse demande d'accéder à des tableaux, gérés par le système d'exploitation, qui sont en mémoire RAM. Aussi, les processeurs modernes incorporent des mémoires caches appelées des '''''Translation Lookaside Buffers''''', ou encore TLB. Nous nous pouvons pas parler des TLB pour le moment, car nous n'avons pas encore abordé le chapitre sur les mémoires caches, mais un chapitre entier sera dédié aux TLB d'ici peu.
[[File:MMU principle updated.png|centre|vignette|upright=2|MMU.]]
===Les MMU intégrées au processeur===
D'ordinaire, la MMU est intégrée au processeur. Et elle peut l'être de deux manières. La première en fait un circuit séparé, relié au bus d'adresse. La seconde fusionne la MMU avec l'unité de calcul d'adresse. La première solution est surtout utilisée avec une technique d'abstraction mémoire appelée la pagination, alors que l'autre l'est avec une autre méthode appelée la segmentation. La raison est que la traduction d'adresse avec la segmentation est assez simple : elle demande d'additionner le contenu d'un registre avec l'adresse logique, ce qui est le genre de calcul qu'une unité de calcul d'adresse sait déjà faire. La fusion est donc assez évidente.
Pour donner un exemple, l'Intel 8086 fusionnait l'unité de calcul d'adresse et la MMU. Précisément, il utilisait un même additionneur pour incrémenter le ''program counter'' et effectuer des calculs d'adresse liés à la segmentation. Il aurait été logique d'ajouter les pointeurs de pile avec, mais ce n'était pas possible. La raison est que le pointeur de pile ne peut pas être envoyé directement sur le bus d'adresse, vu qu'il doit passer par une phase de traduction en adresse physique liée à la segmentation.
[[File:80186 arch.png|centre|vignette|upright=2|Intel 8086, microarchitecture.]]
===Les MMU séparées du processeur, sur la carte mère===
Il a existé des processeurs avec une MMU externe, soudée sur la carte mère.
Par exemple, les processeurs Motorola 68000 et 68010 pouvaient être combinés avec une MMU de type Motorola 68451. Elle supportait des versions simplifiées de la segmentation et de la pagination. Au minimum, elle ajoutait un support de la protection mémoire contre certains accès non-autorisés. La gestion de la mémoire virtuelle proprement dit n'était possible que si le processeur utilisé était un Motorola 68010, en raison de la manière dont le 68000 gérait ses accès mémoire. La MMU 68451 gérait un espace d'adressage de 16 mébioctets, découpé en maximum 32 pages/segments. On pouvait dépasser cette limite de 32 segments/pages en combinant plusieurs 68451.
Le Motorola 68851 était une MMU qui était prévue pour fonctionner de paire avec le Motorola 68020. Elle gérait la pagination pour un espace d'adressage de 32 bits.
Les processeurs suivants, les 68030, 68040, et 68060, avaient une MMU interne au processeur.
==La relocation matérielle==
Pour rappel, les systèmes d'exploitation moderne permettent de lancer plusieurs programmes en même temps et les laissent se partager la mémoire. Dans le cas le plus simple, qui n'est pas celui des OS modernes, le système d'exploitation découpe la mémoire en blocs d'adresses contiguës qui sont appelés des '''segments''', ou encore des ''partitions mémoire''. Les segments correspondent à un bloc de mémoire RAM. C'est-à-dire qu'un segment de 259 mébioctets sera un segment continu de 259 mébioctets dans la mémoire physique comme dans la mémoire logique. Dans ce qui suit, un segment contient un programme en cours d'exécution, comme illustré ci-dessous.
[[File:CPT Memory Addressable.svg|centre|vignette|upright=2|Espace d'adressage segmenté.]]
Le système d'exploitation mémorise la position de chaque segment en mémoire, ainsi que d'autres informations annexes. Le tout est regroupé dans la '''table de segment''', un tableau dont chaque case est attribuée à un programme/segment. La table des segments est un tableau numéroté, chaque segment ayant un numéro qui précise sa position dans le tableau. Chaque case, chaque entrée, contient un '''descripteur de segment''' qui regroupe plusieurs informations sur le segment : son adresse de base, sa taille, diverses informations.
===La relocation avec la relocation matérielle : le registre de base===
Un segment peut être placé n'importe où en RAM physique et sa position en RAM change à chaque exécution. Le programme est chargé à une adresse, celle du début du segment, qui change à chaque chargement du programme. Et toutes les adresses utilisées par le programme doivent être corrigées lors du chargement du programme, généralement par l'OS. Cette correction s'appelle la '''relocation''', et elle consiste à ajouter l'adresse de début du segment à chaque adresse manipulée par le programme.
[[File:Relocation assistée par matériel.png|centre|vignette|upright=2.5|Relocation.]]
La relocation matérielle fait que la relocation est faite par le processeur, pas par l'OS. La relocation est intégrée dans le processeur par l'intégration d'un registre : le '''registre de base''', aussi appelé '''registre de relocation'''. Il mémorise l'adresse à laquelle commence le segment, la première adresse du programme. Pour effectuer la relocation, le processeur ajoute automatiquement l'adresse de base à chaque accès mémoire, en allant la chercher dans le registre de relocation.
[[File:Registre de base de segment.png|centre|vignette|upright=2|Registre de base de segment.]]
Le processeur s'occupe de la relocation des segments et le programme compilé n'en voit rien. Pour le dire autrement, les programmes manipulent des adresses logiques, qui sont traduites par le processeur en adresses physiques. La traduction se fait en ajoutant le contenu du registre de relocation à l'adresse logique. De plus, cette méthode fait que chaque programme a son propre espace d'adressage.
[[File:CPU created logical address presentation.png|centre|vignette|upright=2|Traduction d'adresse avec la relocation matérielle.]]
Le système d'exploitation mémorise les adresses de base pour chaque programme, dans la table des segments. Le registre de base est mis à jour automatiquement lors de chaque changement de segment. Pour cela, le registre de base est accessible via certaines instructions, accessibles en espace noyau, plus rarement en espace utilisateur. Le registre de segment est censé être adressé implicitement, vu qu'il est unique. Si ce n'est pas le cas, il est possible d'écrire dans ce registre de segment, qui est alors adressable.
===La protection mémoire avec la relocation matérielle : le registre limite===
Sans restrictions supplémentaires, la taille maximale d'un segment est égale à la taille complète de l'espace d'adressage. Sur les processeurs 32 bits, un segment a une taille maximale de 2^32 octets, soit 4 gibioctets. Mais il est possible de limiter la taille du segment à 2 gibioctets, 1 gibioctet, 64 Kibioctets, ou toute autre taille. La limite est définie lors de la création du segment, mais elle peut cependant évoluer au cours de l'exécution du programme, grâce à l'allocation mémoire. Le processeur vérifie à chaque accès mémoire que celui-ci se fait bien dans le segment, en comparant l'adresse accédée à l'adresse de base et l'adresse maximale, l'adresse limite.
Limiter la taille d'un segment demande soit de mémoriser sa taille, soit de mémoriser l'adresse limite (l'adresse de fin de segment, l'adresse limite à ne pas dépasser). Les deux sont possibles et marchent parfaitement, le choix entre les deux solutions est une pure question de préférence. A la rigueur, la vérification des débordements est légèrement plus rapide si on utilise l'adresse de fin du segment. Précisons que l'adresse limite est une adresse logique, le segment commence toujours à l'adresse logique zéro.
Pour cela, la table des segments doit être modifiée. Au lieu de ne contenir que l'adresse de base, elle contient soit l'adresse maximale du segment, soit la taille du segment. En clair, le descripteur de segment est enrichi avec l'adresse limite. 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.
Techniquement, il y a une petite différence de vitesse entre utiliser la taille et l'adresse maximale. Vérifier les débordements avec la taille demande juste de comparer la taille avec l'adresse logique, avant relocation, ce qui peut être fait en parallèle de la relocation. Par contre, l'adresse limite est comparée à une adresse physique, ce qui demande de faire la relocation avant la vérification, ce qui prend un peu plus de temps. Mais l'impact sur les performances est des plus mineurs.
[[File:Registre limite.png|centre|vignette|upright=2|Registre limite]]
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> </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>+ </code><code style="background:#DDF">0001 0010 0011 0100</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </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. Au lieu d'utiliser un additionneur séparé pour le ''program counter'' et un autre pour le calcul de l'adresse physique, un seul additionneur est utilisé pour les deux. L'idée était de partager l'additionneur, qui servait à la fois à incrémenter le ''program counter'' et pour gérer la segmentation. 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|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> </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>+ </code><code style="background:#DDF">1111 1111 1111 1111</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </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 de nos PCs, à partir du 286 d'Intel. 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. A 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. A la place, les segments sont adressés de manière indirecte. A 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 utilise l'adresse ou la taille limite pour vérifier si l'accès mémoire ne déborde pas en-dehors du segment en cours. Pour cela, le processeur compare l'adresse logique accédée avec l'adresse limite, ou compare la taille limite avec le décalage. L'information est lue depuis la table des segments à chaque accès.
[[File:Vm7.svg|centre|vignette|upright=2|Traduction d'adresse avec vérification des accès hors-segment.]]
Par contre, 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.
====L'implémentation de la protection mémoire sur le 386====
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 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. A 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]]
A 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. A 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. A la place, l'entrée contient un '''bit ''global''''', qui précise que cette correspondance est valide pour tous les processus. Le bit global accélère rapidement la traduction d'adresse pour l'accès au noyau.
Un défaut de cette méthode est que le partage d'une page entre plusieurs processus est presque impossible. Impossible de partager une page avec seulement certains processus et pas d'autres : soit on partage une page avec tous les processus, soit on l'alloue avec un seul processus.
====L'usage de plusieurs tables des pages====
Une solution alternative, plus simple, utilise une table des pages par processus lancé sur l'ordinateur, une table des pages unique par espace d'adressage. À chaque changement de processus, le registre qui mémorise la position de la table des pages est modifié pour pointer sur la bonne. C'est le système d'exploitation qui se charge de cette mise à jour.
Avec cette méthode, il est possible de partager une ou plusieurs pages entre plusieurs processus, en configurant les tables des pages convenablement. Les pages partagées sont mappées dans l'espace d'adressage de plusieurs processus, mais pas forcément au même endroit, pas forcément dans les mêmes adresses logiques. On peut placer la page partagée à l'adresse logique 0x0FFF pour un processus, à l'adresse logique 0xFF00 pour un autre processus, etc. Par contre, les entrées de la table des pages pour ces adresses pointent vers la même adresse physique.
[[File:Vm5.png|centre|vignette|upright=2|Tables des pages de plusieurs processus.]]
===La taille des pages===
La taille des pages varie suivant le processeur et le système d'exploitation et tourne souvent autour de 4 kibioctets. Les processeurs actuels gèrent plusieurs tailles différentes pour les pages : 4 kibioctets par défaut, 2 mébioctets, voire 1 à 4 gibioctets pour les pages les plus larges. Les pages de 4 kibioctets sont les pages par défaut, les autres tailles de page sont appelées des ''pages larges''. La taille optimale pour les pages dépend de nombreux paramètres et il n'y a pas de taille qui convienne à tout le monde. Certaines applications gagnent à utiliser des pages larges, d'autres vont au contraire perdre drastiquement en performance en les utilisant.
Le désavantage principal des pages larges est qu'elles favorisent la fragmentation mémoire. Si un programme veut réserver une portion de mémoire, pour une structure de donnée quelconque, il doit réserver une portion dont la taille est multiple de la taille d'une page. Par exemple, un programme ayant besoin de 110 kibioctets allouera 28 pages de 4 kibioctets, soit 120 kibioctets : 2 kibioctets seront perdus. Par contre, avec des pages larges de 2 mébioctets, on aura une perte de 2048 - 110 = 1938 kibioctets. En somme, des morceaux de mémoire seront perdus, car les pages sont trop grandes pour les données qu'on veut y mettre. Le résultat est que le programme qui utilise les pages larges utilisent plus de mémoire et ce d'autant plus qu'il utilise des données de petite taille. Un autre désavantage est qu'elles se marient mal avec certaines techniques d'optimisations de type ''copy-on-write''.
Mais l'avantage est que la traduction des adresses est plus performante. Une taille des pages plus élevée signifie moins de pages, donc des tables des pages plus petites. Et des pages des tables plus petites n'ont pas besoin de beaucoup de niveaux de hiérarchie, voire peuvent se limiter à des tables des pages simples, ce qui rend la traduction d'adresse plus simple et plus rapide. De plus, les programmes ont une certaine localité spatiale, qui font qu'ils accèdent souvent à des données proches. La traduction d'adresse peut alors profiter de systèmes de mise en cache dont nous parlerons dans le prochain chapitre, et ces systèmes de cache marchent nettement mieux avec des pages larges.
Il faut noter que la taille des pages est presque toujours une puissance de deux. Cela a de nombreux avantages, mais n'est pas une nécessité. Par exemple, le tout premier processeur avec de la pagination, le super-ordinateur Atlas, avait des pages de 3 kibioctets. L'avantage principal est que la traduction de l'adresse physique en adresse logique est trivial avec une puissance de deux. Cela garantit que l'on peut diviser l'adresse en un numéro de page et un ''offset'' : la traduction demande juste de remplacer les bits de poids forts par le numéro de page voulu. Sans cela, la traduction d'adresse implique des divisions et des multiplications, qui sont des opérations assez couteuses.
===Les entrées de la table des pages===
Avant de poursuivre, faisons un rapide rappel sur les entrées de la table des pages. Nous venons de voir que la table des pages contient de nombreuses informations : un bit ''valid'' pour la mémoire virtuelle, des bits ''dirty'' et ''accessed'' utilisés par l'OS, des bits de protection mémoire, un bit ''global'' et un potentiellement un identifiant de processus, etc. Étudions rapidement le format de la table des pages sur un processeur x86 32 bits.
* Elle contient d'abord le numéro de page physique.
* Les bits AVL sont inutilisés et peuvent être configurés à loisir par l'OS.
* Le bit G est le bit ''global''.
* Le bit PS vaut 0 pour une page de 4 kibioctets, mais est mis à 1 pour une page de 4 mébioctets dans le cas où le processus utilise des pages larges.
* Le bit D est le bit ''dirty''.
* Le bit A est le bit ''accessed''.
* Le bit PCD indique que la page ne peut pas être cachée, dans le sens où le processeur ne peut copier son contenu dans le cache et doit toujours lire ou écrire cette page directement dans la RAM.
* Le bit PWT indique que les écritures doivent mettre à jour le cache et la page en RAM (dans le chapitre sur le cache, on verra qu'il force le cache à se comporter comme un cache ''write-through'' pour cette page).
* Le bit U/S précise si la page est accessible en mode noyau ou utilisateur.
* Le bit R/W indique si la page est accessible en écriture, toutes les pages sont par défaut accessibles en lecture.
* Le bit P est le bit ''valid''.
[[File:PDE.png|centre|vignette|upright=2.5|Table des pages des processeurs Intel 32 bits.]]
==Comparaison des différentes techniques d'abstraction mémoire==
Pour résumer, l'abstraction mémoire permet de gérer : la relocation, la protection mémoire, l'isolation des processus, la mémoire virtuelle, l'extension de l'espace d'adressage, le partage de mémoire, etc. Elles sont souvent implémentées en même temps. Ce qui fait qu'elles sont souvent confondues, alors que ce sont des concepts sont différents. Ces liens sont résumés dans le tableau ci-dessous.
{|class="wikitable"
|-
!
! colspan="5" | Avec abstraction mémoire
! rowspan="2" | Sans abstraction mémoire
|-
!
! Relocation matérielle
! Segmentation en mode réel (x86)
! Segmentation, général
! Architectures à capacités
! Pagination
|-
! Abstraction matérielle des processus
| colspan="4" | Oui, relocation matérielle
| Oui, liée à la traduction d'adresse
| Impossible
|-
! Mémoire virtuelle
| colspan="2" | Non, sauf émulation logicielle
| colspan="3" | Oui, gérée par le processeur et l'OS
| Non, sauf émulation logicielle
|-
! Extension de l'espace d'adressage
| colspan="2" | Oui : registre de base élargi
| colspan="2" | Oui : adresse de base élargie dans la table des segments
| ''Physical Adress Extension'' des processeurs 32 bits
| Commutation de banques
|-
! Protection mémoire
| Registre limite
| Aucune
| colspan="2" | Registre limite, droits d'accès aux segments
| Gestion des droits d'accès aux pages
| Possible, méthodes variées
|-
! Partage de mémoire
| colspan="2" | Non
| colspan="2" | Segment partagés
| Pages partagées
| Possible, méthodes variées
|}
===Les différents types de segmentation===
La segmentation regroupe plusieurs techniques franchement différentes, qui auraient gagné à être nommées différemment. La principale différence est l'usage de registres de relocation versus des registres de sélecteurs de segments. L'usage de registres de relocation est le fait de la relocation matérielle, mais aussi de la segmentation en mode réel des CPU x86. Par contre, l'usage de sélecteurs de segments est le fait des autres formes de segmentation, architectures à capacité inclues.
La différence entre les deux est le nombre de segments. L'usage de registres de relocation fait que le CPU ne gère qu'un petit nombre de segments de grande taille. La mémoire virtuelle est donc rarement implémentée vu que swapper des segments de grande taille est trop long, l'impact sur les performances est trop important. Sans compter que l'usage de registres de base se marie très mal avec la mémoire virtuelle. Vu qu'un segment peut être swappé ou déplacée n'importe quand, il faut invalider les registres de base au moment du swap/déplacement, ce qui n'est pas chose aisée. Aucun processeur ne gère cela, les méthodes pour n'existent tout simplement pas. L'usage de registres de base implique que la mémoire virtuelle est absente.
La protection mémoire est aussi plus limitée avec l'usage de registres de relocation. Elle se limite à des registres limite, mais la gestion des droits d'accès est limitée. En théorie, la segmentation en mode réel pourrait implémenter une version limitée de protection mémoire, avec une protection de l'espace exécutable. Mais ca n'a jamais été fait en pratique sur les processeurs x86.
Le partage de la mémoire est aussi difficile sur les architectures avec des registres de base. L'absence de table des segments fait que le partage d'un segment est basiquement impossible sans utiliser des méthodes complétement tordues, qui ne sont jamais implémentées en pratique.
===Segmentation versus pagination===
Par rapport à la pagination, la segmentation a des avantages et des inconvénients. Tous sont liés aux propriétés des segments et pages : les segments sont de grande taille et de taille variable, les pages sont petites et de taille fixe.
L'avantage principal de la segmentation est sa rapidité. Le fait que les segments sont de grande taille fait qu'on a pas besoin d'équivalent aux tables des pages inversée ou multiple, juste d'une table des segments toute simple. De plus, les échanges entre table des pages/segments et registres sont plus rares avec la segmentation. Par exemple, si un programme utilise un segment de 2 gigas, tous les accès dans le segment se feront avec une seule consultation de la table des segments. Alors qu'avec la pagination, il faudra une consultation de la table des pages chaque bloc de 4 kibioctet, au minimum.
Mais les désavantages sont nombreux. Le système d'exploitation doit agencer les segments en RAM, et c'est une tâche complexe. Le fait que les segments puisse changer de taille rend le tout encore plus complexe. Par exemple, si on colle les segments les uns à la suite des autres, changer la taille d'un segment demande de réorganiser tous les segments en RAM, ce qui demande énormément de copies RAM-RAM. Une autre possibilité est de laisser assez d'espace entre les segments, mais cet espace est alors gâché, dans le sens où on ne peut pas y placer un nouveau segment.
Swapper un segment est aussi très long, vu que les segments sont de grande taille, alors que swapper une page est très rapide.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'espace d'adressage du processeur
| prevText=L'espace d'adressage du processeur
| next=Les méthodes de synchronisation entre processeur et périphériques
| nextText=Les méthodes de synchronisation entre processeur et périphériques
}}
</noinclude>
86yngh45uan6l03qar7m7a8d1xkm9c9
763700
763696
2026-04-15T00:37:09Z
Mewtow
31375
/* La protection mémoire : les accès hors-segments */
763700
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 envoyées à 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 sachez qu'il existe 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 les adresses de l'espace d'adressage sont 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'abstraction mémoire implémente plusieurs fonctionnalités complémentaires==
L'utilité de l'abstraction matérielle n'est pas évidente, mais sachez qu'elle est si utile que tous les processeurs modernes la prennent en charge. Elle sert notamment à implémenter la mémoire virtuelle, que nous aborderons dans ce qui suit. La plupart de ces fonctionnalités manipulent la relation entre adresses logiques et physique. Dans le cas le plus simple, une adresse logique correspond à une seule adresse physique. Mais beaucoup de fonctionnalités avancées ne respectent pas cette règle.
===L'abstraction matérielle des processus===
Les systèmes d'exploitation modernes sont dits multi-tâche, à savoir qu'ils sont capables d'exécuter plusieurs logiciels en même temps. Et ce même si un seul processeur est présent dans l'ordinateur : les logiciels sont alors exécutés à tour de rôle. Toutefois, cela amène un paquet de problèmes qu'il faut résoudre au mieux. Par exemple, les programmes exécutés doivent se partager la mémoire RAM, ce qui ne vient pas sans problèmes. Le problème principal est que les programmes ne doivent pas lire ou écrire dans les données d'un autre, sans quoi on se retrouverait rapidement avec des problèmes. Il faut donc introduire des mécanismes d{{'}}'''isolement des processus''', pour isoler les programmes les uns des autres.
Un de ces mécanismes est l{{'}}'''abstraction matérielle des processus''', une technique qui fait que chaque programme a son propre espace d'adressage. Chaque programme a l'impression d'avoir accès à tout l'espace d'adressage, de l'adresse 0 à l'adresse maximale gérée par le processeur. Évidemment, il s'agit d'une illusion maintenue justement grâce à la traduction d'adresse. Les espaces d'adressage contiennent des adresses logiques, les adresses de la RAM sont des adresses physiques, la nécessité de l'abstraction mémoire est évidente.
Implémenter l'abstraction mémoire peut se faire de plusieurs manières. Mais dans tous les cas, il faut que la correspondance adresse logique - physique change d'un programme à l'autre. Ce qui est normal, vu que les deux processus sont placés à des endroits différents en RAM physique. La conséquence est qu'avec l'abstraction mémoire, une adresse logique correspond à plusieurs adresses physiques. Une même adresse logique dans deux processus différents correspond à deux adresses phsiques différentes, une par processus. Une adresse logique dans un processus correspondra à l'adresse physique X, la même adresse dans un autre processus correspondra à l'adresse Y.
Les adresses physiques qui partagent la même adresse logique sont alors appelées des '''adresses homonymes'''. Le choix de la bonne adresse étant réalisé par un mécanisme matériel et dépend du programme en cours. Le mécanisme pour choisir la bonne adresse dépend du processeur, mais il y en a deux grands types :
* La première consiste à utiliser l'identifiant de processus CPU, vu au chapitre précédent. C'est, pour rappel, un numéro attribué à chaque processus par le processeur. L'identifiant du processus en cours d'exécution est mémorisé dans un registre du processeur. La traduction d'adresse utilise cet identifiant, en plus de l'adresse logique, pour déterminer l'adresse physique.
* La seconde solution mémorise les correspondances adresses logiques-physique dans des tables en mémoire RAM, qui sont différentes pour chaque programme. Les tables sont accédées à chaque accès mémoire, afin de déterminer l'adresse physique.
===Le partage de la mémoire===
L'isolation des processus est très importante sur les systèmes d'exploitation modernes. Cependant, il existe quelques situations où elle doit être contournée ou du moins mise en pause. Les situations sont multiples : gestion de bibliothèques partagées, communication entre processus, usage de ''threads'', etc. Elles impliquent toutes un '''partage de mémoire''', à savoir qu'une portion de mémoire RAM est partagée entre plusieurs programmes. Le partage de mémoire est une sorte de brèche de l'isolation des processus, mais qui est autorisée car elle est utile.
Un cas intéressant est celui des '''bibliothèques partagées'''. Les bibliothèques sont des collections de fonctions regroupées ensemble, dans une seule unité de code. Un programme qui utilise une bibliothèque peut appeler n’importe quelle fonction présente dans la bibliothèque. La bibliothèque peut être simplement inclue dans le programme lui-même, on parle alors de bibliothèques statiques. De telles bibliothèques fonctionnent très bien, mais avec un petit défaut pour les bibliothèques très utilisées : plusieurs programmes qui utilisent la même bibliothèque vont chacun l'inclure dans leur code, ce qui fera doublon.
Pour éviter cela, les OS modernes gèrent des bibliothèques partagées, à savoir qu'un seul exemplaire de la bibliothèque est partagé entre plusieurs programmes. Chaque programme peut exécuter une fonction de la bibliothèque quand il le souhaite, en effectuant un branchement adéquat. Mais cela implique que la bibliothèque soit présente dans l'espace d'adressage du programme en question. Une bibliothèque est donc présente dans plusieurs espaces d'adressage, alors qu'il n'y en a qu'un seul exemplaire en mémoire RAM.
[[File:Ogg vorbis libs and application dia.svg|centre|vignette|upright=2|Exemple de bibliothèques, avec Ogg vorbis.]]
D'autres situations demandent de partager de la mémoire entre deux programmes. Par exemple, les systèmes d'exploitation modernes gèrent nativement des systèmes de '''communication inter-processus''', très utilisés par les programmes modernes pour échanger des données. Et la plupart demandant de partager un bout de mémoire entre processus, même si c'est seulement temporairement. Typiquement, deux processus partagent un intervalle d'adresse où l'un écrit les données à l'autre, l'autre lisant les données envoyées.
Une dernière utilisation de la mémoire partagée est l{{'}}'''accès direct au noyau'''. Sur les systèmes d'exploitations moderne, dans l'espace d'adressage de chaque programme, les adresses hautes sont remplies avec une partie du noyau ! Évidemment, ces adresses sont accessibles uniquement en lecture, pas en écriture. Pas question de modifier le noyau de l'OS ! De plus, il s'agit d'une portion du noyau dont on sait que la consultation ne pose pas de problèmes de sécurité.
Le programme peut lire des données dans cette portion du noyau, mais aussi exécuter les fonctions du noyau qui sont dedans. L'idée est d'éviter des appels systèmes trop fréquents. Au lieu d'effectuer un véritable appel système, avec une interruption logicielle, le programme peut exécuter des appels systèmes simplifiés, de simples appels de fonctions couplés avec un changement de niveau de privilège (passage en espace noyau nécessaire).
[[File:AMD64-canonical--48-bit.png|vignette|Répartition des adresses entre noyau (jaune/orange) et programme (verte), sur les systèmes x86-64 bits, avec des adresses physiques de 48 bits.]]
L'espace d'adressage est donc séparé en deux portions : l'OS d'un côté, le programme de l'autre. La répartition des adresses entre noyau et programme varie suivant l'OS ou le processeur utilisé. Sur les PC x86 32 bits, Linux attribuait 3 gigas pour les programmes et 1 giga pour le noyau, Windows attribuait 2 gigas à chacun. Sur les systèmes x86 64 bits, l'espace d'adressage d'un programme est coupé en trois, comme illustré ci-contre : une partie basse de 2^48 octets, une partie haute de même taille, et un bloc d'adresses invalides entre les deux. Les adresses basses sont utilisées pour le programme, les adresses hautes pour le noyau, il n'y a rien entre les deux.
Avec le partage de mémoire, plusieurs adresses logiques correspondent à la même adresse physique. Tel processus verra la zone de mémoire partagée à l'adresse X, l'autre la verra à l'adresse Y. Mais il s'agira de la même portion de mémoire physique, avec une seule adresse physique. En clair, lorsque deux processus partagent une même zone de mémoire, la zone sera mappées à des adresses logiques différentes. Les adresses logiques sont alors appelées des '''adresses synonymes''', terme qui trahit le fait qu'elles correspondent à la même adresse physique.
===La mémoire virtuelle===
Toutes les adresses ne sont pas forcément occupées par de la mémoire RAM, s'il n'y a pas assez de RAM installée. Par exemple, un processeur 32 bits peut adresser 4 gibioctets de RAM, même si seulement 3 gibioctets sont installés dans l'ordinateur. L'espace d'adressage contient donc 1 gigas d'adresses inutilisées, et il faut éviter ce surplus d'adresses pose problème.
Sans mémoire virtuelle, seule la mémoire réellement installée est utilisable. Si un programme utilise trop de mémoire, il est censé se rendre compte qu'il n'a pas accès à tout l'espace d'adressage. Quand il demandera au système d'exploitation de lui réserver de la mémoire, le système d'exploitation le préviendra qu'il n'y a plus de mémoire libre. Par exemple, si un programme tente d'utiliser 4 gibioctets sur un ordinateur avec 3 gibioctets de mémoire, il ne pourra pas. Pareil s'il veut utiliser 2 gibioctets de mémoire sur un ordinateur avec 4 gibioctets, mais dont 3 gibioctets sont déjà utilisés par d'autres programmes. Dans les deux cas, l'illusion tombe à plat.
Les techniques de '''mémoire virtuelle''' font que l'espace d'adressage est utilisable au complet, même s'il n'y a pas assez de mémoire installée dans l'ordinateur ou que d'autres programmes utilisent de la RAM. Par exemple, sur un processeur 32 bits, le programme aura accès à 4 gibioctets de RAM, même si d'autres programmes utilisent la RAM, même s'il n'y a que 2 gibioctets de RAM d'installés dans l'ordinateur.
Pour cela, on utilise une partie des mémoires de masse (disques durs) d'un ordinateur en remplacement de la mémoire physique manquante. Le système d'exploitation crée sur le disque dur un fichier, appelé le ''swapfile'' ou '''fichier de ''swap''''', qui est utilisé comme mémoire RAM supplémentaire. Il mémorise le surplus de données et de programmes qui ne peut pas être mis en mémoire RAM.
[[File:Vm1.png|centre|vignette|upright=2.0|Mémoire virtuelle et fichier de Swap.]]
Une technique naïve de mémoire virtuelle serait la suivante. Avant de l'aborder, précisons qu'il s'agit d'une technique abordée à but pédagogique, mais qui n'est implémentée nulle part tellement elle est lente et inefficace. Un espace d'adressage de 4 gigas ne contient que 3 gigas de RAM, ce qui fait 1 giga d'adresses inutilisées. Les accès mémoire aux 3 gigas de RAM se font normalement, mais l'accès aux adresses inutilisées lève une exception matérielle "Memory Unavailable". La routine d'interruption de cette exception accède alors au ''swapfile'' et récupère les données associées à cette adresse. La mémoire virtuelle est alors émulée par le système d'exploitation.
Le défaut de cette méthode est que l'accès au giga manquant est toujours très lent, parce qu'il se fait depuis le disque dur. D'autres techniques de mémoire virtuelle logicielle font beaucoup mieux, mais nous allons les passer sous silence, vu qu'on peut faire mieux, avec l'aide du matériel.
L'idée est de charger les données dont le programme a besoin dans la RAM, et de déplacer les autres sur le disque dur. Par exemple, imaginons la situation suivante : un programme a besoin de 4 gigas de mémoire, mais ne dispose que de 2 gigas de mémoire installée. On peut imaginer découper l'espace d'adressage en 2 blocs de 2 gigas, qui sont chargés à la demande. Si le programme accède aux adresses basses, on charge les 2 gigas d'adresse basse en RAM. S'il accède aux adresses hautes, on charge les 2 gigas d'adresse haute dans la RAM après avoir copié les adresses basses sur le ''swapfile''.
On perd du temps dans les copies de données entre RAM et ''swapfile'', mais on gagne en performance vu que tous les accès mémoire se font en RAM. Du fait de la localité temporelle, le programme utilise les données chargées depuis le swapfile durant un bon moment avant de passer au bloc suivant. La RAM est alors utilisée comme une sorte de cache alors que les données sont placées dans une mémoire fictive représentée par l'espace d'adressage et qui correspond au disque dur.
Mais avec cette technique, la correspondance entre adresses du programme et adresses de la RAM change au cours du temps. Les adresses de la RAM correspondent d'abord aux adresses basses, puis aux adresses hautes, et ainsi de suite. On a donc besoin d'abstraction mémoire. Les correspondances entre adresse logique et physique peuvent varier avec le temps, ce qui permet de déplacer des données de la RAM vers le disque dur ou inversement. Une adresse logique peut correspondre à une adresse physique, ou bien à une donnée swappée sur le disque dur. C'est l'unité de traduction d'adresse qui se charge de faire la différence. Si une correspondance entre adresse logique et physique est trouvée, elle l'utilise pour traduire les adresses. Si aucune correspondance n'est trouvée, alors elle laisse la main au système d'exploitation pour charger la donnée en RAM. Une fois la donnée chargée en RAM, les correspondances entre adresse logique et physiques sont modifiées de manière à ce que l'adresse logique pointe vers la donnée chargée.
===L'extension d'adressage===
Une autre fonctionnalité rendue possible par l'abstraction mémoire est l{{'}}'''extension d'adressage'''. Elle permet d'utiliser plus de mémoire que l'espace d'adressage ne le permet. Par exemple, utiliser 7 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'extension d'adresse est l'exact inverse de la mémoire virtuelle. La mémoire virtuelle sert quand on a moins de mémoire que d'adresses, l'extension d'adresse sert quand on a plus de mémoire que d'adresses.
Il y a quelques chapitres, nous avions vu que c'est possible via la commutation de banques. Mais l'abstraction mémoire est une méthode alternative. Que ce soit avec la commutation de banques ou avec l'abstraction mémoire, les adresses envoyées à la mémoire doivent être plus longues que les adresses gérées par le processeur. La différence est que l'abstraction mémoire étend les adresses d'une manière différente.
Une implémentation possible de l'extension d'adressage fait usage de l'abstraction matérielle des processus. Chaque processus a son propre espace d'adressage, mais ceux-ci sont placés à des endroits différents dans la mémoire physique. Par exemple, sur un ordinateur avec 16 gigas de RAM, mais un espace d'adressage de 2 gigas, on peut remplir la RAM en lançant 8 processus différents et chaque processus aura accès à un bloc de 2 gigas de RAM, pas plus, il ne peut pas dépasser cette limite. Ainsi, chaque processus est limité par son espace d'adressage, mais on remplit la mémoire avec plusieurs processus, ce qui compense. Il s'agit là de l'implémentation la plus simple, qui a en plus l'avantage d'avoir la meilleure compatibilité logicielle. De simples changements dans le système d'exploitation suffisent à l'implémenter.
[[File:Extension de l'espace d'adressage.png|centre|vignette|upright=1.5|Extension de l'espace d'adressage]]
Un autre implémentation donne plusieurs espaces d'adressage différents à chaque processus, et a donc accès à autant de mémoire que permis par la somme de ces espaces d'adressage. Par exemple, sur un ordinateur avec 16 gigas de RAM et un espace d'adressage de 4 gigas, un programme peut utiliser toute la RAM en utilisant 4 espaces d'adressage distincts. On passe d'un espace d'adressage à l'autre en changeant la correspondance adresse logique-physique. L'inconvénient est que la compatibilité logicielle est assez mauvaise. Modifier l'OS ne suffit pas, les programmeurs doivent impérativement concevoir leurs programmes pour qu'ils utilisent explicitement plusieurs espaces d'adressage.
Les deux implémentations font usage des adresses logiques homonymes, mais à l'intérieur d'un même processus. Pour rappel, cela veut dire qu'une adresse logique correspond à des adresses physiques différentes. Rien d'étonnant vu qu'on utilise plusieurs espaces d'adressage, comme pour l'abstraction des processus, sauf que cette fois-ci, on a plusieurs espaces d'adressage par processus. Prenons l'exemple où on a 8 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'idée est qu'une adresse correspondra à une adresse dans les premiers 4 gigas, ou dans les seconds 4 gigas. L'adresse logique X correspondra d'abord à une adresse physique dans les premiers 4 gigas, puis à une adresse physique dans les seconds 4 gigas.
===La protection mémoire===
La '''protection mémoire''' regroupe des techniques très différentes les unes des autres, qui visent à améliorer la sécurité des programmes et des systèmes d'exploitation. Elles visent à empêcher de lire, d'écrire ou d'exécuter certaines portions de mémoire. Sans elle, les programmes peuvent techniquement lire ou écrire les données des autres, ce qui causent des situations non-prévues par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses.
La première technique de protection mémoire est l{{'}}'''isolation des processus''', qu'on a vue plus haut. Elle garantit que chaque programme n'a accès qu'à certaines portions dédiées de la mémoire et rend le reste de la mémoire inaccessible en lecture et en écriture. Le système d'exploitation attribue à chaque programme une ou plusieurs portions de mémoire rien que pour lui, auquel aucun autre programme ne peut accéder. Un tel programme, isolé des autres, s'appelle un '''processus''', d'où le nom de cet objectif. Toute tentative d'accès à une partie de la mémoire non autorisée déclenche une exception matérielle (rappelez-vous le chapitre sur les interruptions) qui est traitée par une routine du système d'exploitation. Généralement, le programme fautif est sauvagement arrêté et un message d'erreur est affiché à l'écran.
La '''protection de l'espace exécutable''' empêche d’exécuter quoique ce soit provenant de certaines zones de la mémoire. En effet, certaines portions de la mémoire sont censées contenir uniquement des données, sans aucun programme ou code exécutable. Cependant, des virus informatiques peuvent se cacher dedans et d’exécuter depuis celles-ci. Ou encore, des failles de sécurités peuvent permettre à un attaquant d'injecter du code exécutable malicieux dans des données, ce qui peut lui permettre de lire les données manipulées par un programme, prendre le contrôle de la machine, injecter des virus, ou autre. Pour éviter cela, le système d'exploitation peut marquer certaines zones mémoire comme n'étant pas exécutable. Toute tentative d’exécuter du code localisé dans ces zones entraîne la levée d'une exception ou d'une erreur et le système d'exploitation réagit en conséquence. Là encore, le processeur doit détecter les exécutions non autorisées.
D'autres méthodes de protection mémoire visent à limiter des actions dangereuses. Pour cela, le processeur et l'OS gèrent des '''droits d'accès''', qui interdisent certaines actions pour des programmes non-autorisés. Lorsqu'on exécute une opération interdite, le système d’exploitation et/ou le processeur réagissent en conséquence. La première technique de ce genre n'est autre que la séparation entre espace noyau et utilisateur, vue dans le chapitre sur les interruptions. Mais il y en a d'autres, comme nous le verrons dans ce chapitre.
==La MMU==
La traduction des adresses logiques en adresses physiques se fait par un circuit spécialisé appelé la '''''Memory Management Unit''''' (MMU), qui est souvent intégré directement dans l'interface mémoire. La MMU est souvent associée à une ou plusieurs mémoires caches, qui visent à accélérer la traduction d'adresses logiques en adresses physiques. En effet, nous verrons plus bas que la traduction d'adresse demande d'accéder à des tableaux, gérés par le système d'exploitation, qui sont en mémoire RAM. Aussi, les processeurs modernes incorporent des mémoires caches appelées des '''''Translation Lookaside Buffers''''', ou encore TLB. Nous nous pouvons pas parler des TLB pour le moment, car nous n'avons pas encore abordé le chapitre sur les mémoires caches, mais un chapitre entier sera dédié aux TLB d'ici peu.
[[File:MMU principle updated.png|centre|vignette|upright=2|MMU.]]
===Les MMU intégrées au processeur===
D'ordinaire, la MMU est intégrée au processeur. Et elle peut l'être de deux manières. La première en fait un circuit séparé, relié au bus d'adresse. La seconde fusionne la MMU avec l'unité de calcul d'adresse. La première solution est surtout utilisée avec une technique d'abstraction mémoire appelée la pagination, alors que l'autre l'est avec une autre méthode appelée la segmentation. La raison est que la traduction d'adresse avec la segmentation est assez simple : elle demande d'additionner le contenu d'un registre avec l'adresse logique, ce qui est le genre de calcul qu'une unité de calcul d'adresse sait déjà faire. La fusion est donc assez évidente.
Pour donner un exemple, l'Intel 8086 fusionnait l'unité de calcul d'adresse et la MMU. Précisément, il utilisait un même additionneur pour incrémenter le ''program counter'' et effectuer des calculs d'adresse liés à la segmentation. Il aurait été logique d'ajouter les pointeurs de pile avec, mais ce n'était pas possible. La raison est que le pointeur de pile ne peut pas être envoyé directement sur le bus d'adresse, vu qu'il doit passer par une phase de traduction en adresse physique liée à la segmentation.
[[File:80186 arch.png|centre|vignette|upright=2|Intel 8086, microarchitecture.]]
===Les MMU séparées du processeur, sur la carte mère===
Il a existé des processeurs avec une MMU externe, soudée sur la carte mère.
Par exemple, les processeurs Motorola 68000 et 68010 pouvaient être combinés avec une MMU de type Motorola 68451. Elle supportait des versions simplifiées de la segmentation et de la pagination. Au minimum, elle ajoutait un support de la protection mémoire contre certains accès non-autorisés. La gestion de la mémoire virtuelle proprement dit n'était possible que si le processeur utilisé était un Motorola 68010, en raison de la manière dont le 68000 gérait ses accès mémoire. La MMU 68451 gérait un espace d'adressage de 16 mébioctets, découpé en maximum 32 pages/segments. On pouvait dépasser cette limite de 32 segments/pages en combinant plusieurs 68451.
Le Motorola 68851 était une MMU qui était prévue pour fonctionner de paire avec le Motorola 68020. Elle gérait la pagination pour un espace d'adressage de 32 bits.
Les processeurs suivants, les 68030, 68040, et 68060, avaient une MMU interne au processeur.
==La relocation matérielle==
Pour rappel, les systèmes d'exploitation moderne permettent de lancer plusieurs programmes en même temps et les laissent se partager la mémoire. Dans le cas le plus simple, qui n'est pas celui des OS modernes, le système d'exploitation découpe la mémoire en blocs d'adresses contiguës qui sont appelés des '''segments''', ou encore des ''partitions mémoire''. Les segments correspondent à un bloc de mémoire RAM. C'est-à-dire qu'un segment de 259 mébioctets sera un segment continu de 259 mébioctets dans la mémoire physique comme dans la mémoire logique. Dans ce qui suit, un segment contient un programme en cours d'exécution, comme illustré ci-dessous.
[[File:CPT Memory Addressable.svg|centre|vignette|upright=2|Espace d'adressage segmenté.]]
Le système d'exploitation mémorise la position de chaque segment en mémoire, ainsi que d'autres informations annexes. Le tout est regroupé dans la '''table de segment''', un tableau dont chaque case est attribuée à un programme/segment. La table des segments est un tableau numéroté, chaque segment ayant un numéro qui précise sa position dans le tableau. Chaque case, chaque entrée, contient un '''descripteur de segment''' qui regroupe plusieurs informations sur le segment : son adresse de base, sa taille, diverses informations.
===La relocation avec la relocation matérielle : le registre de base===
Un segment peut être placé n'importe où en RAM physique et sa position en RAM change à chaque exécution. Le programme est chargé à une adresse, celle du début du segment, qui change à chaque chargement du programme. Et toutes les adresses utilisées par le programme doivent être corrigées lors du chargement du programme, généralement par l'OS. Cette correction s'appelle la '''relocation''', et elle consiste à ajouter l'adresse de début du segment à chaque adresse manipulée par le programme.
[[File:Relocation assistée par matériel.png|centre|vignette|upright=2.5|Relocation.]]
La relocation matérielle fait que la relocation est faite par le processeur, pas par l'OS. La relocation est intégrée dans le processeur par l'intégration d'un registre : le '''registre de base''', aussi appelé '''registre de relocation'''. Il mémorise l'adresse à laquelle commence le segment, la première adresse du programme. Pour effectuer la relocation, le processeur ajoute automatiquement l'adresse de base à chaque accès mémoire, en allant la chercher dans le registre de relocation.
[[File:Registre de base de segment.png|centre|vignette|upright=2|Registre de base de segment.]]
Le processeur s'occupe de la relocation des segments et le programme compilé n'en voit rien. Pour le dire autrement, les programmes manipulent des adresses logiques, qui sont traduites par le processeur en adresses physiques. La traduction se fait en ajoutant le contenu du registre de relocation à l'adresse logique. De plus, cette méthode fait que chaque programme a son propre espace d'adressage.
[[File:CPU created logical address presentation.png|centre|vignette|upright=2|Traduction d'adresse avec la relocation matérielle.]]
Le système d'exploitation mémorise les adresses de base pour chaque programme, dans la table des segments. Le registre de base est mis à jour automatiquement lors de chaque changement de segment. Pour cela, le registre de base est accessible via certaines instructions, accessibles en espace noyau, plus rarement en espace utilisateur. Le registre de segment est censé être adressé implicitement, vu qu'il est unique. Si ce n'est pas le cas, il est possible d'écrire dans ce registre de segment, qui est alors adressable.
===La protection mémoire avec la relocation matérielle : le registre limite===
Sans restrictions supplémentaires, la taille maximale d'un segment est égale à la taille complète de l'espace d'adressage. Sur les processeurs 32 bits, un segment a une taille maximale de 2^32 octets, soit 4 gibioctets. Mais il est possible de limiter la taille du segment à 2 gibioctets, 1 gibioctet, 64 Kibioctets, ou toute autre taille. La limite est définie lors de la création du segment, mais elle peut cependant évoluer au cours de l'exécution du programme, grâce à l'allocation mémoire. Le processeur vérifie à chaque accès mémoire que celui-ci se fait bien dans le segment, en comparant l'adresse accédée à l'adresse de base et l'adresse maximale, l'adresse limite.
Limiter la taille d'un segment demande soit de mémoriser sa taille, soit de mémoriser l'adresse limite (l'adresse de fin de segment, l'adresse limite à ne pas dépasser). Les deux sont possibles et marchent parfaitement, le choix entre les deux solutions est une pure question de préférence. A la rigueur, la vérification des débordements est légèrement plus rapide si on utilise l'adresse de fin du segment. Précisons que l'adresse limite est une adresse logique, le segment commence toujours à l'adresse logique zéro.
Pour cela, la table des segments doit être modifiée. Au lieu de ne contenir que l'adresse de base, elle contient soit l'adresse maximale du segment, soit la taille du segment. En clair, le descripteur de segment est enrichi avec l'adresse limite. 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.
Techniquement, il y a une petite différence de vitesse entre utiliser la taille et l'adresse maximale. Vérifier les débordements avec la taille demande juste de comparer la taille avec l'adresse logique, avant relocation, ce qui peut être fait en parallèle de la relocation. Par contre, l'adresse limite est comparée à une adresse physique, ce qui demande de faire la relocation avant la vérification, ce qui prend un peu plus de temps. Mais l'impact sur les performances est des plus mineurs.
[[File:Registre limite.png|centre|vignette|upright=2|Registre limite]]
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> </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>+ </code><code style="background:#DDF">0001 0010 0011 0100</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </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. Au lieu d'utiliser un additionneur séparé pour le ''program counter'' et un autre pour le calcul de l'adresse physique, un seul additionneur est utilisé pour les deux. L'idée était de partager l'additionneur, qui servait à la fois à incrémenter le ''program counter'' et pour gérer la segmentation. 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|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> </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>+ </code><code style="background:#DDF">1111 1111 1111 1111</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </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 de nos PCs, à partir du 286 d'Intel. 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. A 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. A la place, les segments sont adressés de manière indirecte. A 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 utilise l'adresse ou la taille limite pour vérifier si l'accès mémoire ne déborde pas en-dehors du segment en cours. Pour cela, le processeur compare l'adresse logique accédée avec l'adresse limite, ou compare la taille limite avec le décalage. L'information est lue depuis la table des segments à chaque accès.
[[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.
====L'implémentation de la protection mémoire sur le 386====
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 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. A 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]]
A 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. A 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. A la place, l'entrée contient un '''bit ''global''''', qui précise que cette correspondance est valide pour tous les processus. Le bit global accélère rapidement la traduction d'adresse pour l'accès au noyau.
Un défaut de cette méthode est que le partage d'une page entre plusieurs processus est presque impossible. Impossible de partager une page avec seulement certains processus et pas d'autres : soit on partage une page avec tous les processus, soit on l'alloue avec un seul processus.
====L'usage de plusieurs tables des pages====
Une solution alternative, plus simple, utilise une table des pages par processus lancé sur l'ordinateur, une table des pages unique par espace d'adressage. À chaque changement de processus, le registre qui mémorise la position de la table des pages est modifié pour pointer sur la bonne. C'est le système d'exploitation qui se charge de cette mise à jour.
Avec cette méthode, il est possible de partager une ou plusieurs pages entre plusieurs processus, en configurant les tables des pages convenablement. Les pages partagées sont mappées dans l'espace d'adressage de plusieurs processus, mais pas forcément au même endroit, pas forcément dans les mêmes adresses logiques. On peut placer la page partagée à l'adresse logique 0x0FFF pour un processus, à l'adresse logique 0xFF00 pour un autre processus, etc. Par contre, les entrées de la table des pages pour ces adresses pointent vers la même adresse physique.
[[File:Vm5.png|centre|vignette|upright=2|Tables des pages de plusieurs processus.]]
===La taille des pages===
La taille des pages varie suivant le processeur et le système d'exploitation et tourne souvent autour de 4 kibioctets. Les processeurs actuels gèrent plusieurs tailles différentes pour les pages : 4 kibioctets par défaut, 2 mébioctets, voire 1 à 4 gibioctets pour les pages les plus larges. Les pages de 4 kibioctets sont les pages par défaut, les autres tailles de page sont appelées des ''pages larges''. La taille optimale pour les pages dépend de nombreux paramètres et il n'y a pas de taille qui convienne à tout le monde. Certaines applications gagnent à utiliser des pages larges, d'autres vont au contraire perdre drastiquement en performance en les utilisant.
Le désavantage principal des pages larges est qu'elles favorisent la fragmentation mémoire. Si un programme veut réserver une portion de mémoire, pour une structure de donnée quelconque, il doit réserver une portion dont la taille est multiple de la taille d'une page. Par exemple, un programme ayant besoin de 110 kibioctets allouera 28 pages de 4 kibioctets, soit 120 kibioctets : 2 kibioctets seront perdus. Par contre, avec des pages larges de 2 mébioctets, on aura une perte de 2048 - 110 = 1938 kibioctets. En somme, des morceaux de mémoire seront perdus, car les pages sont trop grandes pour les données qu'on veut y mettre. Le résultat est que le programme qui utilise les pages larges utilisent plus de mémoire et ce d'autant plus qu'il utilise des données de petite taille. Un autre désavantage est qu'elles se marient mal avec certaines techniques d'optimisations de type ''copy-on-write''.
Mais l'avantage est que la traduction des adresses est plus performante. Une taille des pages plus élevée signifie moins de pages, donc des tables des pages plus petites. Et des pages des tables plus petites n'ont pas besoin de beaucoup de niveaux de hiérarchie, voire peuvent se limiter à des tables des pages simples, ce qui rend la traduction d'adresse plus simple et plus rapide. De plus, les programmes ont une certaine localité spatiale, qui font qu'ils accèdent souvent à des données proches. La traduction d'adresse peut alors profiter de systèmes de mise en cache dont nous parlerons dans le prochain chapitre, et ces systèmes de cache marchent nettement mieux avec des pages larges.
Il faut noter que la taille des pages est presque toujours une puissance de deux. Cela a de nombreux avantages, mais n'est pas une nécessité. Par exemple, le tout premier processeur avec de la pagination, le super-ordinateur Atlas, avait des pages de 3 kibioctets. L'avantage principal est que la traduction de l'adresse physique en adresse logique est trivial avec une puissance de deux. Cela garantit que l'on peut diviser l'adresse en un numéro de page et un ''offset'' : la traduction demande juste de remplacer les bits de poids forts par le numéro de page voulu. Sans cela, la traduction d'adresse implique des divisions et des multiplications, qui sont des opérations assez couteuses.
===Les entrées de la table des pages===
Avant de poursuivre, faisons un rapide rappel sur les entrées de la table des pages. Nous venons de voir que la table des pages contient de nombreuses informations : un bit ''valid'' pour la mémoire virtuelle, des bits ''dirty'' et ''accessed'' utilisés par l'OS, des bits de protection mémoire, un bit ''global'' et un potentiellement un identifiant de processus, etc. Étudions rapidement le format de la table des pages sur un processeur x86 32 bits.
* Elle contient d'abord le numéro de page physique.
* Les bits AVL sont inutilisés et peuvent être configurés à loisir par l'OS.
* Le bit G est le bit ''global''.
* Le bit PS vaut 0 pour une page de 4 kibioctets, mais est mis à 1 pour une page de 4 mébioctets dans le cas où le processus utilise des pages larges.
* Le bit D est le bit ''dirty''.
* Le bit A est le bit ''accessed''.
* Le bit PCD indique que la page ne peut pas être cachée, dans le sens où le processeur ne peut copier son contenu dans le cache et doit toujours lire ou écrire cette page directement dans la RAM.
* Le bit PWT indique que les écritures doivent mettre à jour le cache et la page en RAM (dans le chapitre sur le cache, on verra qu'il force le cache à se comporter comme un cache ''write-through'' pour cette page).
* Le bit U/S précise si la page est accessible en mode noyau ou utilisateur.
* Le bit R/W indique si la page est accessible en écriture, toutes les pages sont par défaut accessibles en lecture.
* Le bit P est le bit ''valid''.
[[File:PDE.png|centre|vignette|upright=2.5|Table des pages des processeurs Intel 32 bits.]]
==Comparaison des différentes techniques d'abstraction mémoire==
Pour résumer, l'abstraction mémoire permet de gérer : la relocation, la protection mémoire, l'isolation des processus, la mémoire virtuelle, l'extension de l'espace d'adressage, le partage de mémoire, etc. Elles sont souvent implémentées en même temps. Ce qui fait qu'elles sont souvent confondues, alors que ce sont des concepts sont différents. Ces liens sont résumés dans le tableau ci-dessous.
{|class="wikitable"
|-
!
! colspan="5" | Avec abstraction mémoire
! rowspan="2" | Sans abstraction mémoire
|-
!
! Relocation matérielle
! Segmentation en mode réel (x86)
! Segmentation, général
! Architectures à capacités
! Pagination
|-
! Abstraction matérielle des processus
| colspan="4" | Oui, relocation matérielle
| Oui, liée à la traduction d'adresse
| Impossible
|-
! Mémoire virtuelle
| colspan="2" | Non, sauf émulation logicielle
| colspan="3" | Oui, gérée par le processeur et l'OS
| Non, sauf émulation logicielle
|-
! Extension de l'espace d'adressage
| colspan="2" | Oui : registre de base élargi
| colspan="2" | Oui : adresse de base élargie dans la table des segments
| ''Physical Adress Extension'' des processeurs 32 bits
| Commutation de banques
|-
! Protection mémoire
| Registre limite
| Aucune
| colspan="2" | Registre limite, droits d'accès aux segments
| Gestion des droits d'accès aux pages
| Possible, méthodes variées
|-
! Partage de mémoire
| colspan="2" | Non
| colspan="2" | Segment partagés
| Pages partagées
| Possible, méthodes variées
|}
===Les différents types de segmentation===
La segmentation regroupe plusieurs techniques franchement différentes, qui auraient gagné à être nommées différemment. La principale différence est l'usage de registres de relocation versus des registres de sélecteurs de segments. L'usage de registres de relocation est le fait de la relocation matérielle, mais aussi de la segmentation en mode réel des CPU x86. Par contre, l'usage de sélecteurs de segments est le fait des autres formes de segmentation, architectures à capacité inclues.
La différence entre les deux est le nombre de segments. L'usage de registres de relocation fait que le CPU ne gère qu'un petit nombre de segments de grande taille. La mémoire virtuelle est donc rarement implémentée vu que swapper des segments de grande taille est trop long, l'impact sur les performances est trop important. Sans compter que l'usage de registres de base se marie très mal avec la mémoire virtuelle. Vu qu'un segment peut être swappé ou déplacée n'importe quand, il faut invalider les registres de base au moment du swap/déplacement, ce qui n'est pas chose aisée. Aucun processeur ne gère cela, les méthodes pour n'existent tout simplement pas. L'usage de registres de base implique que la mémoire virtuelle est absente.
La protection mémoire est aussi plus limitée avec l'usage de registres de relocation. Elle se limite à des registres limite, mais la gestion des droits d'accès est limitée. En théorie, la segmentation en mode réel pourrait implémenter une version limitée de protection mémoire, avec une protection de l'espace exécutable. Mais ca n'a jamais été fait en pratique sur les processeurs x86.
Le partage de la mémoire est aussi difficile sur les architectures avec des registres de base. L'absence de table des segments fait que le partage d'un segment est basiquement impossible sans utiliser des méthodes complétement tordues, qui ne sont jamais implémentées en pratique.
===Segmentation versus pagination===
Par rapport à la pagination, la segmentation a des avantages et des inconvénients. Tous sont liés aux propriétés des segments et pages : les segments sont de grande taille et de taille variable, les pages sont petites et de taille fixe.
L'avantage principal de la segmentation est sa rapidité. Le fait que les segments sont de grande taille fait qu'on a pas besoin d'équivalent aux tables des pages inversée ou multiple, juste d'une table des segments toute simple. De plus, les échanges entre table des pages/segments et registres sont plus rares avec la segmentation. Par exemple, si un programme utilise un segment de 2 gigas, tous les accès dans le segment se feront avec une seule consultation de la table des segments. Alors qu'avec la pagination, il faudra une consultation de la table des pages chaque bloc de 4 kibioctet, au minimum.
Mais les désavantages sont nombreux. Le système d'exploitation doit agencer les segments en RAM, et c'est une tâche complexe. Le fait que les segments puisse changer de taille rend le tout encore plus complexe. Par exemple, si on colle les segments les uns à la suite des autres, changer la taille d'un segment demande de réorganiser tous les segments en RAM, ce qui demande énormément de copies RAM-RAM. Une autre possibilité est de laisser assez d'espace entre les segments, mais cet espace est alors gâché, dans le sens où on ne peut pas y placer un nouveau segment.
Swapper un segment est aussi très long, vu que les segments sont de grande taille, alors que swapper une page est très rapide.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'espace d'adressage du processeur
| prevText=L'espace d'adressage du processeur
| next=Les méthodes de synchronisation entre processeur et périphériques
| nextText=Les méthodes de synchronisation entre processeur et périphériques
}}
</noinclude>
j41k3ouf1vwvnns0nfa11d4ejoppk3r
763701
763700
2026-04-15T00:44:01Z
Mewtow
31375
/* La protection mémoire avec la relocation matérielle : le registre limite */
763701
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 envoyées à 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 sachez qu'il existe 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 les adresses de l'espace d'adressage sont 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'abstraction mémoire implémente plusieurs fonctionnalités complémentaires==
L'utilité de l'abstraction matérielle n'est pas évidente, mais sachez qu'elle est si utile que tous les processeurs modernes la prennent en charge. Elle sert notamment à implémenter la mémoire virtuelle, que nous aborderons dans ce qui suit. La plupart de ces fonctionnalités manipulent la relation entre adresses logiques et physique. Dans le cas le plus simple, une adresse logique correspond à une seule adresse physique. Mais beaucoup de fonctionnalités avancées ne respectent pas cette règle.
===L'abstraction matérielle des processus===
Les systèmes d'exploitation modernes sont dits multi-tâche, à savoir qu'ils sont capables d'exécuter plusieurs logiciels en même temps. Et ce même si un seul processeur est présent dans l'ordinateur : les logiciels sont alors exécutés à tour de rôle. Toutefois, cela amène un paquet de problèmes qu'il faut résoudre au mieux. Par exemple, les programmes exécutés doivent se partager la mémoire RAM, ce qui ne vient pas sans problèmes. Le problème principal est que les programmes ne doivent pas lire ou écrire dans les données d'un autre, sans quoi on se retrouverait rapidement avec des problèmes. Il faut donc introduire des mécanismes d{{'}}'''isolement des processus''', pour isoler les programmes les uns des autres.
Un de ces mécanismes est l{{'}}'''abstraction matérielle des processus''', une technique qui fait que chaque programme a son propre espace d'adressage. Chaque programme a l'impression d'avoir accès à tout l'espace d'adressage, de l'adresse 0 à l'adresse maximale gérée par le processeur. Évidemment, il s'agit d'une illusion maintenue justement grâce à la traduction d'adresse. Les espaces d'adressage contiennent des adresses logiques, les adresses de la RAM sont des adresses physiques, la nécessité de l'abstraction mémoire est évidente.
Implémenter l'abstraction mémoire peut se faire de plusieurs manières. Mais dans tous les cas, il faut que la correspondance adresse logique - physique change d'un programme à l'autre. Ce qui est normal, vu que les deux processus sont placés à des endroits différents en RAM physique. La conséquence est qu'avec l'abstraction mémoire, une adresse logique correspond à plusieurs adresses physiques. Une même adresse logique dans deux processus différents correspond à deux adresses phsiques différentes, une par processus. Une adresse logique dans un processus correspondra à l'adresse physique X, la même adresse dans un autre processus correspondra à l'adresse Y.
Les adresses physiques qui partagent la même adresse logique sont alors appelées des '''adresses homonymes'''. Le choix de la bonne adresse étant réalisé par un mécanisme matériel et dépend du programme en cours. Le mécanisme pour choisir la bonne adresse dépend du processeur, mais il y en a deux grands types :
* La première consiste à utiliser l'identifiant de processus CPU, vu au chapitre précédent. C'est, pour rappel, un numéro attribué à chaque processus par le processeur. L'identifiant du processus en cours d'exécution est mémorisé dans un registre du processeur. La traduction d'adresse utilise cet identifiant, en plus de l'adresse logique, pour déterminer l'adresse physique.
* La seconde solution mémorise les correspondances adresses logiques-physique dans des tables en mémoire RAM, qui sont différentes pour chaque programme. Les tables sont accédées à chaque accès mémoire, afin de déterminer l'adresse physique.
===Le partage de la mémoire===
L'isolation des processus est très importante sur les systèmes d'exploitation modernes. Cependant, il existe quelques situations où elle doit être contournée ou du moins mise en pause. Les situations sont multiples : gestion de bibliothèques partagées, communication entre processus, usage de ''threads'', etc. Elles impliquent toutes un '''partage de mémoire''', à savoir qu'une portion de mémoire RAM est partagée entre plusieurs programmes. Le partage de mémoire est une sorte de brèche de l'isolation des processus, mais qui est autorisée car elle est utile.
Un cas intéressant est celui des '''bibliothèques partagées'''. Les bibliothèques sont des collections de fonctions regroupées ensemble, dans une seule unité de code. Un programme qui utilise une bibliothèque peut appeler n’importe quelle fonction présente dans la bibliothèque. La bibliothèque peut être simplement inclue dans le programme lui-même, on parle alors de bibliothèques statiques. De telles bibliothèques fonctionnent très bien, mais avec un petit défaut pour les bibliothèques très utilisées : plusieurs programmes qui utilisent la même bibliothèque vont chacun l'inclure dans leur code, ce qui fera doublon.
Pour éviter cela, les OS modernes gèrent des bibliothèques partagées, à savoir qu'un seul exemplaire de la bibliothèque est partagé entre plusieurs programmes. Chaque programme peut exécuter une fonction de la bibliothèque quand il le souhaite, en effectuant un branchement adéquat. Mais cela implique que la bibliothèque soit présente dans l'espace d'adressage du programme en question. Une bibliothèque est donc présente dans plusieurs espaces d'adressage, alors qu'il n'y en a qu'un seul exemplaire en mémoire RAM.
[[File:Ogg vorbis libs and application dia.svg|centre|vignette|upright=2|Exemple de bibliothèques, avec Ogg vorbis.]]
D'autres situations demandent de partager de la mémoire entre deux programmes. Par exemple, les systèmes d'exploitation modernes gèrent nativement des systèmes de '''communication inter-processus''', très utilisés par les programmes modernes pour échanger des données. Et la plupart demandant de partager un bout de mémoire entre processus, même si c'est seulement temporairement. Typiquement, deux processus partagent un intervalle d'adresse où l'un écrit les données à l'autre, l'autre lisant les données envoyées.
Une dernière utilisation de la mémoire partagée est l{{'}}'''accès direct au noyau'''. Sur les systèmes d'exploitations moderne, dans l'espace d'adressage de chaque programme, les adresses hautes sont remplies avec une partie du noyau ! Évidemment, ces adresses sont accessibles uniquement en lecture, pas en écriture. Pas question de modifier le noyau de l'OS ! De plus, il s'agit d'une portion du noyau dont on sait que la consultation ne pose pas de problèmes de sécurité.
Le programme peut lire des données dans cette portion du noyau, mais aussi exécuter les fonctions du noyau qui sont dedans. L'idée est d'éviter des appels systèmes trop fréquents. Au lieu d'effectuer un véritable appel système, avec une interruption logicielle, le programme peut exécuter des appels systèmes simplifiés, de simples appels de fonctions couplés avec un changement de niveau de privilège (passage en espace noyau nécessaire).
[[File:AMD64-canonical--48-bit.png|vignette|Répartition des adresses entre noyau (jaune/orange) et programme (verte), sur les systèmes x86-64 bits, avec des adresses physiques de 48 bits.]]
L'espace d'adressage est donc séparé en deux portions : l'OS d'un côté, le programme de l'autre. La répartition des adresses entre noyau et programme varie suivant l'OS ou le processeur utilisé. Sur les PC x86 32 bits, Linux attribuait 3 gigas pour les programmes et 1 giga pour le noyau, Windows attribuait 2 gigas à chacun. Sur les systèmes x86 64 bits, l'espace d'adressage d'un programme est coupé en trois, comme illustré ci-contre : une partie basse de 2^48 octets, une partie haute de même taille, et un bloc d'adresses invalides entre les deux. Les adresses basses sont utilisées pour le programme, les adresses hautes pour le noyau, il n'y a rien entre les deux.
Avec le partage de mémoire, plusieurs adresses logiques correspondent à la même adresse physique. Tel processus verra la zone de mémoire partagée à l'adresse X, l'autre la verra à l'adresse Y. Mais il s'agira de la même portion de mémoire physique, avec une seule adresse physique. En clair, lorsque deux processus partagent une même zone de mémoire, la zone sera mappées à des adresses logiques différentes. Les adresses logiques sont alors appelées des '''adresses synonymes''', terme qui trahit le fait qu'elles correspondent à la même adresse physique.
===La mémoire virtuelle===
Toutes les adresses ne sont pas forcément occupées par de la mémoire RAM, s'il n'y a pas assez de RAM installée. Par exemple, un processeur 32 bits peut adresser 4 gibioctets de RAM, même si seulement 3 gibioctets sont installés dans l'ordinateur. L'espace d'adressage contient donc 1 gigas d'adresses inutilisées, et il faut éviter ce surplus d'adresses pose problème.
Sans mémoire virtuelle, seule la mémoire réellement installée est utilisable. Si un programme utilise trop de mémoire, il est censé se rendre compte qu'il n'a pas accès à tout l'espace d'adressage. Quand il demandera au système d'exploitation de lui réserver de la mémoire, le système d'exploitation le préviendra qu'il n'y a plus de mémoire libre. Par exemple, si un programme tente d'utiliser 4 gibioctets sur un ordinateur avec 3 gibioctets de mémoire, il ne pourra pas. Pareil s'il veut utiliser 2 gibioctets de mémoire sur un ordinateur avec 4 gibioctets, mais dont 3 gibioctets sont déjà utilisés par d'autres programmes. Dans les deux cas, l'illusion tombe à plat.
Les techniques de '''mémoire virtuelle''' font que l'espace d'adressage est utilisable au complet, même s'il n'y a pas assez de mémoire installée dans l'ordinateur ou que d'autres programmes utilisent de la RAM. Par exemple, sur un processeur 32 bits, le programme aura accès à 4 gibioctets de RAM, même si d'autres programmes utilisent la RAM, même s'il n'y a que 2 gibioctets de RAM d'installés dans l'ordinateur.
Pour cela, on utilise une partie des mémoires de masse (disques durs) d'un ordinateur en remplacement de la mémoire physique manquante. Le système d'exploitation crée sur le disque dur un fichier, appelé le ''swapfile'' ou '''fichier de ''swap''''', qui est utilisé comme mémoire RAM supplémentaire. Il mémorise le surplus de données et de programmes qui ne peut pas être mis en mémoire RAM.
[[File:Vm1.png|centre|vignette|upright=2.0|Mémoire virtuelle et fichier de Swap.]]
Une technique naïve de mémoire virtuelle serait la suivante. Avant de l'aborder, précisons qu'il s'agit d'une technique abordée à but pédagogique, mais qui n'est implémentée nulle part tellement elle est lente et inefficace. Un espace d'adressage de 4 gigas ne contient que 3 gigas de RAM, ce qui fait 1 giga d'adresses inutilisées. Les accès mémoire aux 3 gigas de RAM se font normalement, mais l'accès aux adresses inutilisées lève une exception matérielle "Memory Unavailable". La routine d'interruption de cette exception accède alors au ''swapfile'' et récupère les données associées à cette adresse. La mémoire virtuelle est alors émulée par le système d'exploitation.
Le défaut de cette méthode est que l'accès au giga manquant est toujours très lent, parce qu'il se fait depuis le disque dur. D'autres techniques de mémoire virtuelle logicielle font beaucoup mieux, mais nous allons les passer sous silence, vu qu'on peut faire mieux, avec l'aide du matériel.
L'idée est de charger les données dont le programme a besoin dans la RAM, et de déplacer les autres sur le disque dur. Par exemple, imaginons la situation suivante : un programme a besoin de 4 gigas de mémoire, mais ne dispose que de 2 gigas de mémoire installée. On peut imaginer découper l'espace d'adressage en 2 blocs de 2 gigas, qui sont chargés à la demande. Si le programme accède aux adresses basses, on charge les 2 gigas d'adresse basse en RAM. S'il accède aux adresses hautes, on charge les 2 gigas d'adresse haute dans la RAM après avoir copié les adresses basses sur le ''swapfile''.
On perd du temps dans les copies de données entre RAM et ''swapfile'', mais on gagne en performance vu que tous les accès mémoire se font en RAM. Du fait de la localité temporelle, le programme utilise les données chargées depuis le swapfile durant un bon moment avant de passer au bloc suivant. La RAM est alors utilisée comme une sorte de cache alors que les données sont placées dans une mémoire fictive représentée par l'espace d'adressage et qui correspond au disque dur.
Mais avec cette technique, la correspondance entre adresses du programme et adresses de la RAM change au cours du temps. Les adresses de la RAM correspondent d'abord aux adresses basses, puis aux adresses hautes, et ainsi de suite. On a donc besoin d'abstraction mémoire. Les correspondances entre adresse logique et physique peuvent varier avec le temps, ce qui permet de déplacer des données de la RAM vers le disque dur ou inversement. Une adresse logique peut correspondre à une adresse physique, ou bien à une donnée swappée sur le disque dur. C'est l'unité de traduction d'adresse qui se charge de faire la différence. Si une correspondance entre adresse logique et physique est trouvée, elle l'utilise pour traduire les adresses. Si aucune correspondance n'est trouvée, alors elle laisse la main au système d'exploitation pour charger la donnée en RAM. Une fois la donnée chargée en RAM, les correspondances entre adresse logique et physiques sont modifiées de manière à ce que l'adresse logique pointe vers la donnée chargée.
===L'extension d'adressage===
Une autre fonctionnalité rendue possible par l'abstraction mémoire est l{{'}}'''extension d'adressage'''. Elle permet d'utiliser plus de mémoire que l'espace d'adressage ne le permet. Par exemple, utiliser 7 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'extension d'adresse est l'exact inverse de la mémoire virtuelle. La mémoire virtuelle sert quand on a moins de mémoire que d'adresses, l'extension d'adresse sert quand on a plus de mémoire que d'adresses.
Il y a quelques chapitres, nous avions vu que c'est possible via la commutation de banques. Mais l'abstraction mémoire est une méthode alternative. Que ce soit avec la commutation de banques ou avec l'abstraction mémoire, les adresses envoyées à la mémoire doivent être plus longues que les adresses gérées par le processeur. La différence est que l'abstraction mémoire étend les adresses d'une manière différente.
Une implémentation possible de l'extension d'adressage fait usage de l'abstraction matérielle des processus. Chaque processus a son propre espace d'adressage, mais ceux-ci sont placés à des endroits différents dans la mémoire physique. Par exemple, sur un ordinateur avec 16 gigas de RAM, mais un espace d'adressage de 2 gigas, on peut remplir la RAM en lançant 8 processus différents et chaque processus aura accès à un bloc de 2 gigas de RAM, pas plus, il ne peut pas dépasser cette limite. Ainsi, chaque processus est limité par son espace d'adressage, mais on remplit la mémoire avec plusieurs processus, ce qui compense. Il s'agit là de l'implémentation la plus simple, qui a en plus l'avantage d'avoir la meilleure compatibilité logicielle. De simples changements dans le système d'exploitation suffisent à l'implémenter.
[[File:Extension de l'espace d'adressage.png|centre|vignette|upright=1.5|Extension de l'espace d'adressage]]
Un autre implémentation donne plusieurs espaces d'adressage différents à chaque processus, et a donc accès à autant de mémoire que permis par la somme de ces espaces d'adressage. Par exemple, sur un ordinateur avec 16 gigas de RAM et un espace d'adressage de 4 gigas, un programme peut utiliser toute la RAM en utilisant 4 espaces d'adressage distincts. On passe d'un espace d'adressage à l'autre en changeant la correspondance adresse logique-physique. L'inconvénient est que la compatibilité logicielle est assez mauvaise. Modifier l'OS ne suffit pas, les programmeurs doivent impérativement concevoir leurs programmes pour qu'ils utilisent explicitement plusieurs espaces d'adressage.
Les deux implémentations font usage des adresses logiques homonymes, mais à l'intérieur d'un même processus. Pour rappel, cela veut dire qu'une adresse logique correspond à des adresses physiques différentes. Rien d'étonnant vu qu'on utilise plusieurs espaces d'adressage, comme pour l'abstraction des processus, sauf que cette fois-ci, on a plusieurs espaces d'adressage par processus. Prenons l'exemple où on a 8 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'idée est qu'une adresse correspondra à une adresse dans les premiers 4 gigas, ou dans les seconds 4 gigas. L'adresse logique X correspondra d'abord à une adresse physique dans les premiers 4 gigas, puis à une adresse physique dans les seconds 4 gigas.
===La protection mémoire===
La '''protection mémoire''' regroupe des techniques très différentes les unes des autres, qui visent à améliorer la sécurité des programmes et des systèmes d'exploitation. Elles visent à empêcher de lire, d'écrire ou d'exécuter certaines portions de mémoire. Sans elle, les programmes peuvent techniquement lire ou écrire les données des autres, ce qui causent des situations non-prévues par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses.
La première technique de protection mémoire est l{{'}}'''isolation des processus''', qu'on a vue plus haut. Elle garantit que chaque programme n'a accès qu'à certaines portions dédiées de la mémoire et rend le reste de la mémoire inaccessible en lecture et en écriture. Le système d'exploitation attribue à chaque programme une ou plusieurs portions de mémoire rien que pour lui, auquel aucun autre programme ne peut accéder. Un tel programme, isolé des autres, s'appelle un '''processus''', d'où le nom de cet objectif. Toute tentative d'accès à une partie de la mémoire non autorisée déclenche une exception matérielle (rappelez-vous le chapitre sur les interruptions) qui est traitée par une routine du système d'exploitation. Généralement, le programme fautif est sauvagement arrêté et un message d'erreur est affiché à l'écran.
La '''protection de l'espace exécutable''' empêche d’exécuter quoique ce soit provenant de certaines zones de la mémoire. En effet, certaines portions de la mémoire sont censées contenir uniquement des données, sans aucun programme ou code exécutable. Cependant, des virus informatiques peuvent se cacher dedans et d’exécuter depuis celles-ci. Ou encore, des failles de sécurités peuvent permettre à un attaquant d'injecter du code exécutable malicieux dans des données, ce qui peut lui permettre de lire les données manipulées par un programme, prendre le contrôle de la machine, injecter des virus, ou autre. Pour éviter cela, le système d'exploitation peut marquer certaines zones mémoire comme n'étant pas exécutable. Toute tentative d’exécuter du code localisé dans ces zones entraîne la levée d'une exception ou d'une erreur et le système d'exploitation réagit en conséquence. Là encore, le processeur doit détecter les exécutions non autorisées.
D'autres méthodes de protection mémoire visent à limiter des actions dangereuses. Pour cela, le processeur et l'OS gèrent des '''droits d'accès''', qui interdisent certaines actions pour des programmes non-autorisés. Lorsqu'on exécute une opération interdite, le système d’exploitation et/ou le processeur réagissent en conséquence. La première technique de ce genre n'est autre que la séparation entre espace noyau et utilisateur, vue dans le chapitre sur les interruptions. Mais il y en a d'autres, comme nous le verrons dans ce chapitre.
==La MMU==
La traduction des adresses logiques en adresses physiques se fait par un circuit spécialisé appelé la '''''Memory Management Unit''''' (MMU), qui est souvent intégré directement dans l'interface mémoire. La MMU est souvent associée à une ou plusieurs mémoires caches, qui visent à accélérer la traduction d'adresses logiques en adresses physiques. En effet, nous verrons plus bas que la traduction d'adresse demande d'accéder à des tableaux, gérés par le système d'exploitation, qui sont en mémoire RAM. Aussi, les processeurs modernes incorporent des mémoires caches appelées des '''''Translation Lookaside Buffers''''', ou encore TLB. Nous nous pouvons pas parler des TLB pour le moment, car nous n'avons pas encore abordé le chapitre sur les mémoires caches, mais un chapitre entier sera dédié aux TLB d'ici peu.
[[File:MMU principle updated.png|centre|vignette|upright=2|MMU.]]
===Les MMU intégrées au processeur===
D'ordinaire, la MMU est intégrée au processeur. Et elle peut l'être de deux manières. La première en fait un circuit séparé, relié au bus d'adresse. La seconde fusionne la MMU avec l'unité de calcul d'adresse. La première solution est surtout utilisée avec une technique d'abstraction mémoire appelée la pagination, alors que l'autre l'est avec une autre méthode appelée la segmentation. La raison est que la traduction d'adresse avec la segmentation est assez simple : elle demande d'additionner le contenu d'un registre avec l'adresse logique, ce qui est le genre de calcul qu'une unité de calcul d'adresse sait déjà faire. La fusion est donc assez évidente.
Pour donner un exemple, l'Intel 8086 fusionnait l'unité de calcul d'adresse et la MMU. Précisément, il utilisait un même additionneur pour incrémenter le ''program counter'' et effectuer des calculs d'adresse liés à la segmentation. Il aurait été logique d'ajouter les pointeurs de pile avec, mais ce n'était pas possible. La raison est que le pointeur de pile ne peut pas être envoyé directement sur le bus d'adresse, vu qu'il doit passer par une phase de traduction en adresse physique liée à la segmentation.
[[File:80186 arch.png|centre|vignette|upright=2|Intel 8086, microarchitecture.]]
===Les MMU séparées du processeur, sur la carte mère===
Il a existé des processeurs avec une MMU externe, soudée sur la carte mère.
Par exemple, les processeurs Motorola 68000 et 68010 pouvaient être combinés avec une MMU de type Motorola 68451. Elle supportait des versions simplifiées de la segmentation et de la pagination. Au minimum, elle ajoutait un support de la protection mémoire contre certains accès non-autorisés. La gestion de la mémoire virtuelle proprement dit n'était possible que si le processeur utilisé était un Motorola 68010, en raison de la manière dont le 68000 gérait ses accès mémoire. La MMU 68451 gérait un espace d'adressage de 16 mébioctets, découpé en maximum 32 pages/segments. On pouvait dépasser cette limite de 32 segments/pages en combinant plusieurs 68451.
Le Motorola 68851 était une MMU qui était prévue pour fonctionner de paire avec le Motorola 68020. Elle gérait la pagination pour un espace d'adressage de 32 bits.
Les processeurs suivants, les 68030, 68040, et 68060, avaient une MMU interne au processeur.
==La relocation matérielle==
Pour rappel, les systèmes d'exploitation moderne permettent de lancer plusieurs programmes en même temps et les laissent se partager la mémoire. Dans le cas le plus simple, qui n'est pas celui des OS modernes, le système d'exploitation découpe la mémoire en blocs d'adresses contiguës qui sont appelés des '''segments''', ou encore des ''partitions mémoire''. Les segments correspondent à un bloc de mémoire RAM. C'est-à-dire qu'un segment de 259 mébioctets sera un segment continu de 259 mébioctets dans la mémoire physique comme dans la mémoire logique. Dans ce qui suit, un segment contient un programme en cours d'exécution, comme illustré ci-dessous.
[[File:CPT Memory Addressable.svg|centre|vignette|upright=2|Espace d'adressage segmenté.]]
Le système d'exploitation mémorise la position de chaque segment en mémoire, ainsi que d'autres informations annexes. Le tout est regroupé dans la '''table de segment''', un tableau dont chaque case est attribuée à un programme/segment. La table des segments est un tableau numéroté, chaque segment ayant un numéro qui précise sa position dans le tableau. Chaque case, chaque entrée, contient un '''descripteur de segment''' qui regroupe plusieurs informations sur le segment : son adresse de base, sa taille, diverses informations.
===La relocation avec la relocation matérielle : le registre de base===
Un segment peut être placé n'importe où en RAM physique et sa position en RAM change à chaque exécution. Le programme est chargé à une adresse, celle du début du segment, qui change à chaque chargement du programme. Et toutes les adresses utilisées par le programme doivent être corrigées lors du chargement du programme, généralement par l'OS. Cette correction s'appelle la '''relocation''', et elle consiste à ajouter l'adresse de début du segment à chaque adresse manipulée par le programme.
[[File:Relocation assistée par matériel.png|centre|vignette|upright=2.5|Relocation.]]
La relocation matérielle fait que la relocation est faite par le processeur, pas par l'OS. La relocation est intégrée dans le processeur par l'intégration d'un registre : le '''registre de base''', aussi appelé '''registre de relocation'''. Il mémorise l'adresse à laquelle commence le segment, la première adresse du programme. Pour effectuer la relocation, le processeur ajoute automatiquement l'adresse de base à chaque accès mémoire, en allant la chercher dans le registre de relocation.
[[File:Registre de base de segment.png|centre|vignette|upright=2|Registre de base de segment.]]
Le processeur s'occupe de la relocation des segments et le programme compilé n'en voit rien. Pour le dire autrement, les programmes manipulent des adresses logiques, qui sont traduites par le processeur en adresses physiques. La traduction se fait en ajoutant le contenu du registre de relocation à l'adresse logique. De plus, cette méthode fait que chaque programme a son propre espace d'adressage.
[[File:CPU created logical address presentation.png|centre|vignette|upright=2|Traduction d'adresse avec la relocation matérielle.]]
Le système d'exploitation mémorise les adresses de base pour chaque programme, dans la table des segments. Le registre de base est mis à jour automatiquement lors de chaque changement de segment. Pour cela, le registre de base est accessible via certaines instructions, accessibles en espace noyau, plus rarement en espace utilisateur. Le registre de segment est censé être adressé implicitement, vu qu'il est unique. Si ce n'est pas le cas, il est possible d'écrire dans ce registre de segment, qui est alors adressable.
===La protection mémoire avec la relocation matérielle : le registre limite===
Sans restrictions supplémentaires, la taille maximale d'un segment est égale à la taille complète de l'espace d'adressage. Sur les processeurs 32 bits, un segment a une taille maximale de 2^32 octets, soit 4 gibioctets. Mais il est possible de limiter la taille du segment à 2 gibioctets, 1 gibioctet, 64 Kibioctets, ou toute autre taille. La limite est définie lors de la création du segment, mais elle peut cependant évoluer au cours de l'exécution du programme, grâce à l'allocation mémoire.
Le processeur vérifie à chaque accès mémoire que celui-ci se fait bien dans le segment, qu'il ne déborde pas en-dehors. C'est possible qu'une adresse calculée sorte du segment, à la suite d'un bug ou d'une erreur de programmation, voire pire. Et le processeur doit éviter de tels '''débordements de segments'''. Pour cela, le processeur compare l'adresse accédée et vérifie qu'elle est bien dans le segment.
Faire cette vérification demande soit de mémoriser la taille du segment, soit de mémoriser l'adresse limite (l'adresse de fin de segment, l'adresse limite à ne pas dépasser). Les deux sont possibles et marchent parfaitement, le choix entre les deux solutions est une pure question de préférence. A la rigueur, utiliser la taille limite permet quelques optimisations, comme tester les débordements en parallèle du calcul de l'adresse.
En utilisant l'adresse limite, on fait la relocation, et on compare l'adresse calculée avec l'adresse limite. Le calcul d'adresse doit se faire avant la vérification. Mais en utilisant la taille, on peut comparer l'adresse logique avec la taille du segment. Précisons que l'adresse logique est celle avant relocation, celle qui indique la position de la donnée dans le segment, celle obtenue quand on considère que le segment commence à l'adresse zéro. On peut alors faire le test de débordement avant ou pendant la relocation. Quelques processeurs en ont profité, mais on verra cela dans la section sur la segmentation.
Pour cela, la table des segments doit être modifiée. Au lieu de ne contenir que l'adresse de base, elle contient soit l'adresse maximale du segment, soit la taille du segment. En clair, le descripteur de segment est enrichi avec l'adresse limite. 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.
Techniquement, il y a une petite différence de vitesse entre utiliser la taille et l'adresse maximale. Vérifier les débordements avec la taille demande juste de comparer la taille avec l'adresse logique, avant relocation, ce qui peut être fait en parallèle de la relocation. Par contre, l'adresse limite est comparée à une adresse physique, ce qui demande de faire la relocation avant la vérification, ce qui prend un peu plus de temps. Mais l'impact sur les performances est des plus mineurs.
[[File:Registre limite.png|centre|vignette|upright=2|Registre limite]]
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> </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>+ </code><code style="background:#DDF">0001 0010 0011 0100</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </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. Au lieu d'utiliser un additionneur séparé pour le ''program counter'' et un autre pour le calcul de l'adresse physique, un seul additionneur est utilisé pour les deux. L'idée était de partager l'additionneur, qui servait à la fois à incrémenter le ''program counter'' et pour gérer la segmentation. 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|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> </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>+ </code><code style="background:#DDF">1111 1111 1111 1111</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </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 de nos PCs, à partir du 286 d'Intel. 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. A 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. A la place, les segments sont adressés de manière indirecte. A 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 utilise l'adresse ou la taille limite pour vérifier si l'accès mémoire ne déborde pas en-dehors du segment en cours. Pour cela, le processeur compare l'adresse logique accédée avec l'adresse limite, ou compare la taille limite avec le décalage. L'information est lue depuis la table des segments à chaque accès.
[[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.
====L'implémentation de la protection mémoire sur le 386====
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 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. A 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]]
A 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. A 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. A la place, l'entrée contient un '''bit ''global''''', qui précise que cette correspondance est valide pour tous les processus. Le bit global accélère rapidement la traduction d'adresse pour l'accès au noyau.
Un défaut de cette méthode est que le partage d'une page entre plusieurs processus est presque impossible. Impossible de partager une page avec seulement certains processus et pas d'autres : soit on partage une page avec tous les processus, soit on l'alloue avec un seul processus.
====L'usage de plusieurs tables des pages====
Une solution alternative, plus simple, utilise une table des pages par processus lancé sur l'ordinateur, une table des pages unique par espace d'adressage. À chaque changement de processus, le registre qui mémorise la position de la table des pages est modifié pour pointer sur la bonne. C'est le système d'exploitation qui se charge de cette mise à jour.
Avec cette méthode, il est possible de partager une ou plusieurs pages entre plusieurs processus, en configurant les tables des pages convenablement. Les pages partagées sont mappées dans l'espace d'adressage de plusieurs processus, mais pas forcément au même endroit, pas forcément dans les mêmes adresses logiques. On peut placer la page partagée à l'adresse logique 0x0FFF pour un processus, à l'adresse logique 0xFF00 pour un autre processus, etc. Par contre, les entrées de la table des pages pour ces adresses pointent vers la même adresse physique.
[[File:Vm5.png|centre|vignette|upright=2|Tables des pages de plusieurs processus.]]
===La taille des pages===
La taille des pages varie suivant le processeur et le système d'exploitation et tourne souvent autour de 4 kibioctets. Les processeurs actuels gèrent plusieurs tailles différentes pour les pages : 4 kibioctets par défaut, 2 mébioctets, voire 1 à 4 gibioctets pour les pages les plus larges. Les pages de 4 kibioctets sont les pages par défaut, les autres tailles de page sont appelées des ''pages larges''. La taille optimale pour les pages dépend de nombreux paramètres et il n'y a pas de taille qui convienne à tout le monde. Certaines applications gagnent à utiliser des pages larges, d'autres vont au contraire perdre drastiquement en performance en les utilisant.
Le désavantage principal des pages larges est qu'elles favorisent la fragmentation mémoire. Si un programme veut réserver une portion de mémoire, pour une structure de donnée quelconque, il doit réserver une portion dont la taille est multiple de la taille d'une page. Par exemple, un programme ayant besoin de 110 kibioctets allouera 28 pages de 4 kibioctets, soit 120 kibioctets : 2 kibioctets seront perdus. Par contre, avec des pages larges de 2 mébioctets, on aura une perte de 2048 - 110 = 1938 kibioctets. En somme, des morceaux de mémoire seront perdus, car les pages sont trop grandes pour les données qu'on veut y mettre. Le résultat est que le programme qui utilise les pages larges utilisent plus de mémoire et ce d'autant plus qu'il utilise des données de petite taille. Un autre désavantage est qu'elles se marient mal avec certaines techniques d'optimisations de type ''copy-on-write''.
Mais l'avantage est que la traduction des adresses est plus performante. Une taille des pages plus élevée signifie moins de pages, donc des tables des pages plus petites. Et des pages des tables plus petites n'ont pas besoin de beaucoup de niveaux de hiérarchie, voire peuvent se limiter à des tables des pages simples, ce qui rend la traduction d'adresse plus simple et plus rapide. De plus, les programmes ont une certaine localité spatiale, qui font qu'ils accèdent souvent à des données proches. La traduction d'adresse peut alors profiter de systèmes de mise en cache dont nous parlerons dans le prochain chapitre, et ces systèmes de cache marchent nettement mieux avec des pages larges.
Il faut noter que la taille des pages est presque toujours une puissance de deux. Cela a de nombreux avantages, mais n'est pas une nécessité. Par exemple, le tout premier processeur avec de la pagination, le super-ordinateur Atlas, avait des pages de 3 kibioctets. L'avantage principal est que la traduction de l'adresse physique en adresse logique est trivial avec une puissance de deux. Cela garantit que l'on peut diviser l'adresse en un numéro de page et un ''offset'' : la traduction demande juste de remplacer les bits de poids forts par le numéro de page voulu. Sans cela, la traduction d'adresse implique des divisions et des multiplications, qui sont des opérations assez couteuses.
===Les entrées de la table des pages===
Avant de poursuivre, faisons un rapide rappel sur les entrées de la table des pages. Nous venons de voir que la table des pages contient de nombreuses informations : un bit ''valid'' pour la mémoire virtuelle, des bits ''dirty'' et ''accessed'' utilisés par l'OS, des bits de protection mémoire, un bit ''global'' et un potentiellement un identifiant de processus, etc. Étudions rapidement le format de la table des pages sur un processeur x86 32 bits.
* Elle contient d'abord le numéro de page physique.
* Les bits AVL sont inutilisés et peuvent être configurés à loisir par l'OS.
* Le bit G est le bit ''global''.
* Le bit PS vaut 0 pour une page de 4 kibioctets, mais est mis à 1 pour une page de 4 mébioctets dans le cas où le processus utilise des pages larges.
* Le bit D est le bit ''dirty''.
* Le bit A est le bit ''accessed''.
* Le bit PCD indique que la page ne peut pas être cachée, dans le sens où le processeur ne peut copier son contenu dans le cache et doit toujours lire ou écrire cette page directement dans la RAM.
* Le bit PWT indique que les écritures doivent mettre à jour le cache et la page en RAM (dans le chapitre sur le cache, on verra qu'il force le cache à se comporter comme un cache ''write-through'' pour cette page).
* Le bit U/S précise si la page est accessible en mode noyau ou utilisateur.
* Le bit R/W indique si la page est accessible en écriture, toutes les pages sont par défaut accessibles en lecture.
* Le bit P est le bit ''valid''.
[[File:PDE.png|centre|vignette|upright=2.5|Table des pages des processeurs Intel 32 bits.]]
==Comparaison des différentes techniques d'abstraction mémoire==
Pour résumer, l'abstraction mémoire permet de gérer : la relocation, la protection mémoire, l'isolation des processus, la mémoire virtuelle, l'extension de l'espace d'adressage, le partage de mémoire, etc. Elles sont souvent implémentées en même temps. Ce qui fait qu'elles sont souvent confondues, alors que ce sont des concepts sont différents. Ces liens sont résumés dans le tableau ci-dessous.
{|class="wikitable"
|-
!
! colspan="5" | Avec abstraction mémoire
! rowspan="2" | Sans abstraction mémoire
|-
!
! Relocation matérielle
! Segmentation en mode réel (x86)
! Segmentation, général
! Architectures à capacités
! Pagination
|-
! Abstraction matérielle des processus
| colspan="4" | Oui, relocation matérielle
| Oui, liée à la traduction d'adresse
| Impossible
|-
! Mémoire virtuelle
| colspan="2" | Non, sauf émulation logicielle
| colspan="3" | Oui, gérée par le processeur et l'OS
| Non, sauf émulation logicielle
|-
! Extension de l'espace d'adressage
| colspan="2" | Oui : registre de base élargi
| colspan="2" | Oui : adresse de base élargie dans la table des segments
| ''Physical Adress Extension'' des processeurs 32 bits
| Commutation de banques
|-
! Protection mémoire
| Registre limite
| Aucune
| colspan="2" | Registre limite, droits d'accès aux segments
| Gestion des droits d'accès aux pages
| Possible, méthodes variées
|-
! Partage de mémoire
| colspan="2" | Non
| colspan="2" | Segment partagés
| Pages partagées
| Possible, méthodes variées
|}
===Les différents types de segmentation===
La segmentation regroupe plusieurs techniques franchement différentes, qui auraient gagné à être nommées différemment. La principale différence est l'usage de registres de relocation versus des registres de sélecteurs de segments. L'usage de registres de relocation est le fait de la relocation matérielle, mais aussi de la segmentation en mode réel des CPU x86. Par contre, l'usage de sélecteurs de segments est le fait des autres formes de segmentation, architectures à capacité inclues.
La différence entre les deux est le nombre de segments. L'usage de registres de relocation fait que le CPU ne gère qu'un petit nombre de segments de grande taille. La mémoire virtuelle est donc rarement implémentée vu que swapper des segments de grande taille est trop long, l'impact sur les performances est trop important. Sans compter que l'usage de registres de base se marie très mal avec la mémoire virtuelle. Vu qu'un segment peut être swappé ou déplacée n'importe quand, il faut invalider les registres de base au moment du swap/déplacement, ce qui n'est pas chose aisée. Aucun processeur ne gère cela, les méthodes pour n'existent tout simplement pas. L'usage de registres de base implique que la mémoire virtuelle est absente.
La protection mémoire est aussi plus limitée avec l'usage de registres de relocation. Elle se limite à des registres limite, mais la gestion des droits d'accès est limitée. En théorie, la segmentation en mode réel pourrait implémenter une version limitée de protection mémoire, avec une protection de l'espace exécutable. Mais ca n'a jamais été fait en pratique sur les processeurs x86.
Le partage de la mémoire est aussi difficile sur les architectures avec des registres de base. L'absence de table des segments fait que le partage d'un segment est basiquement impossible sans utiliser des méthodes complétement tordues, qui ne sont jamais implémentées en pratique.
===Segmentation versus pagination===
Par rapport à la pagination, la segmentation a des avantages et des inconvénients. Tous sont liés aux propriétés des segments et pages : les segments sont de grande taille et de taille variable, les pages sont petites et de taille fixe.
L'avantage principal de la segmentation est sa rapidité. Le fait que les segments sont de grande taille fait qu'on a pas besoin d'équivalent aux tables des pages inversée ou multiple, juste d'une table des segments toute simple. De plus, les échanges entre table des pages/segments et registres sont plus rares avec la segmentation. Par exemple, si un programme utilise un segment de 2 gigas, tous les accès dans le segment se feront avec une seule consultation de la table des segments. Alors qu'avec la pagination, il faudra une consultation de la table des pages chaque bloc de 4 kibioctet, au minimum.
Mais les désavantages sont nombreux. Le système d'exploitation doit agencer les segments en RAM, et c'est une tâche complexe. Le fait que les segments puisse changer de taille rend le tout encore plus complexe. Par exemple, si on colle les segments les uns à la suite des autres, changer la taille d'un segment demande de réorganiser tous les segments en RAM, ce qui demande énormément de copies RAM-RAM. Une autre possibilité est de laisser assez d'espace entre les segments, mais cet espace est alors gâché, dans le sens où on ne peut pas y placer un nouveau segment.
Swapper un segment est aussi très long, vu que les segments sont de grande taille, alors que swapper une page est très rapide.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'espace d'adressage du processeur
| prevText=L'espace d'adressage du processeur
| next=Les méthodes de synchronisation entre processeur et périphériques
| nextText=Les méthodes de synchronisation entre processeur et périphériques
}}
</noinclude>
4l83ztp4bvegy9zqsm6je3m5k6gsyt1
763702
763701
2026-04-15T00:53:26Z
Mewtow
31375
/* La protection mémoire : les accès hors-segments */
763702
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 envoyées à 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 sachez qu'il existe 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 les adresses de l'espace d'adressage sont 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'abstraction mémoire implémente plusieurs fonctionnalités complémentaires==
L'utilité de l'abstraction matérielle n'est pas évidente, mais sachez qu'elle est si utile que tous les processeurs modernes la prennent en charge. Elle sert notamment à implémenter la mémoire virtuelle, que nous aborderons dans ce qui suit. La plupart de ces fonctionnalités manipulent la relation entre adresses logiques et physique. Dans le cas le plus simple, une adresse logique correspond à une seule adresse physique. Mais beaucoup de fonctionnalités avancées ne respectent pas cette règle.
===L'abstraction matérielle des processus===
Les systèmes d'exploitation modernes sont dits multi-tâche, à savoir qu'ils sont capables d'exécuter plusieurs logiciels en même temps. Et ce même si un seul processeur est présent dans l'ordinateur : les logiciels sont alors exécutés à tour de rôle. Toutefois, cela amène un paquet de problèmes qu'il faut résoudre au mieux. Par exemple, les programmes exécutés doivent se partager la mémoire RAM, ce qui ne vient pas sans problèmes. Le problème principal est que les programmes ne doivent pas lire ou écrire dans les données d'un autre, sans quoi on se retrouverait rapidement avec des problèmes. Il faut donc introduire des mécanismes d{{'}}'''isolement des processus''', pour isoler les programmes les uns des autres.
Un de ces mécanismes est l{{'}}'''abstraction matérielle des processus''', une technique qui fait que chaque programme a son propre espace d'adressage. Chaque programme a l'impression d'avoir accès à tout l'espace d'adressage, de l'adresse 0 à l'adresse maximale gérée par le processeur. Évidemment, il s'agit d'une illusion maintenue justement grâce à la traduction d'adresse. Les espaces d'adressage contiennent des adresses logiques, les adresses de la RAM sont des adresses physiques, la nécessité de l'abstraction mémoire est évidente.
Implémenter l'abstraction mémoire peut se faire de plusieurs manières. Mais dans tous les cas, il faut que la correspondance adresse logique - physique change d'un programme à l'autre. Ce qui est normal, vu que les deux processus sont placés à des endroits différents en RAM physique. La conséquence est qu'avec l'abstraction mémoire, une adresse logique correspond à plusieurs adresses physiques. Une même adresse logique dans deux processus différents correspond à deux adresses phsiques différentes, une par processus. Une adresse logique dans un processus correspondra à l'adresse physique X, la même adresse dans un autre processus correspondra à l'adresse Y.
Les adresses physiques qui partagent la même adresse logique sont alors appelées des '''adresses homonymes'''. Le choix de la bonne adresse étant réalisé par un mécanisme matériel et dépend du programme en cours. Le mécanisme pour choisir la bonne adresse dépend du processeur, mais il y en a deux grands types :
* La première consiste à utiliser l'identifiant de processus CPU, vu au chapitre précédent. C'est, pour rappel, un numéro attribué à chaque processus par le processeur. L'identifiant du processus en cours d'exécution est mémorisé dans un registre du processeur. La traduction d'adresse utilise cet identifiant, en plus de l'adresse logique, pour déterminer l'adresse physique.
* La seconde solution mémorise les correspondances adresses logiques-physique dans des tables en mémoire RAM, qui sont différentes pour chaque programme. Les tables sont accédées à chaque accès mémoire, afin de déterminer l'adresse physique.
===Le partage de la mémoire===
L'isolation des processus est très importante sur les systèmes d'exploitation modernes. Cependant, il existe quelques situations où elle doit être contournée ou du moins mise en pause. Les situations sont multiples : gestion de bibliothèques partagées, communication entre processus, usage de ''threads'', etc. Elles impliquent toutes un '''partage de mémoire''', à savoir qu'une portion de mémoire RAM est partagée entre plusieurs programmes. Le partage de mémoire est une sorte de brèche de l'isolation des processus, mais qui est autorisée car elle est utile.
Un cas intéressant est celui des '''bibliothèques partagées'''. Les bibliothèques sont des collections de fonctions regroupées ensemble, dans une seule unité de code. Un programme qui utilise une bibliothèque peut appeler n’importe quelle fonction présente dans la bibliothèque. La bibliothèque peut être simplement inclue dans le programme lui-même, on parle alors de bibliothèques statiques. De telles bibliothèques fonctionnent très bien, mais avec un petit défaut pour les bibliothèques très utilisées : plusieurs programmes qui utilisent la même bibliothèque vont chacun l'inclure dans leur code, ce qui fera doublon.
Pour éviter cela, les OS modernes gèrent des bibliothèques partagées, à savoir qu'un seul exemplaire de la bibliothèque est partagé entre plusieurs programmes. Chaque programme peut exécuter une fonction de la bibliothèque quand il le souhaite, en effectuant un branchement adéquat. Mais cela implique que la bibliothèque soit présente dans l'espace d'adressage du programme en question. Une bibliothèque est donc présente dans plusieurs espaces d'adressage, alors qu'il n'y en a qu'un seul exemplaire en mémoire RAM.
[[File:Ogg vorbis libs and application dia.svg|centre|vignette|upright=2|Exemple de bibliothèques, avec Ogg vorbis.]]
D'autres situations demandent de partager de la mémoire entre deux programmes. Par exemple, les systèmes d'exploitation modernes gèrent nativement des systèmes de '''communication inter-processus''', très utilisés par les programmes modernes pour échanger des données. Et la plupart demandant de partager un bout de mémoire entre processus, même si c'est seulement temporairement. Typiquement, deux processus partagent un intervalle d'adresse où l'un écrit les données à l'autre, l'autre lisant les données envoyées.
Une dernière utilisation de la mémoire partagée est l{{'}}'''accès direct au noyau'''. Sur les systèmes d'exploitations moderne, dans l'espace d'adressage de chaque programme, les adresses hautes sont remplies avec une partie du noyau ! Évidemment, ces adresses sont accessibles uniquement en lecture, pas en écriture. Pas question de modifier le noyau de l'OS ! De plus, il s'agit d'une portion du noyau dont on sait que la consultation ne pose pas de problèmes de sécurité.
Le programme peut lire des données dans cette portion du noyau, mais aussi exécuter les fonctions du noyau qui sont dedans. L'idée est d'éviter des appels systèmes trop fréquents. Au lieu d'effectuer un véritable appel système, avec une interruption logicielle, le programme peut exécuter des appels systèmes simplifiés, de simples appels de fonctions couplés avec un changement de niveau de privilège (passage en espace noyau nécessaire).
[[File:AMD64-canonical--48-bit.png|vignette|Répartition des adresses entre noyau (jaune/orange) et programme (verte), sur les systèmes x86-64 bits, avec des adresses physiques de 48 bits.]]
L'espace d'adressage est donc séparé en deux portions : l'OS d'un côté, le programme de l'autre. La répartition des adresses entre noyau et programme varie suivant l'OS ou le processeur utilisé. Sur les PC x86 32 bits, Linux attribuait 3 gigas pour les programmes et 1 giga pour le noyau, Windows attribuait 2 gigas à chacun. Sur les systèmes x86 64 bits, l'espace d'adressage d'un programme est coupé en trois, comme illustré ci-contre : une partie basse de 2^48 octets, une partie haute de même taille, et un bloc d'adresses invalides entre les deux. Les adresses basses sont utilisées pour le programme, les adresses hautes pour le noyau, il n'y a rien entre les deux.
Avec le partage de mémoire, plusieurs adresses logiques correspondent à la même adresse physique. Tel processus verra la zone de mémoire partagée à l'adresse X, l'autre la verra à l'adresse Y. Mais il s'agira de la même portion de mémoire physique, avec une seule adresse physique. En clair, lorsque deux processus partagent une même zone de mémoire, la zone sera mappées à des adresses logiques différentes. Les adresses logiques sont alors appelées des '''adresses synonymes''', terme qui trahit le fait qu'elles correspondent à la même adresse physique.
===La mémoire virtuelle===
Toutes les adresses ne sont pas forcément occupées par de la mémoire RAM, s'il n'y a pas assez de RAM installée. Par exemple, un processeur 32 bits peut adresser 4 gibioctets de RAM, même si seulement 3 gibioctets sont installés dans l'ordinateur. L'espace d'adressage contient donc 1 gigas d'adresses inutilisées, et il faut éviter ce surplus d'adresses pose problème.
Sans mémoire virtuelle, seule la mémoire réellement installée est utilisable. Si un programme utilise trop de mémoire, il est censé se rendre compte qu'il n'a pas accès à tout l'espace d'adressage. Quand il demandera au système d'exploitation de lui réserver de la mémoire, le système d'exploitation le préviendra qu'il n'y a plus de mémoire libre. Par exemple, si un programme tente d'utiliser 4 gibioctets sur un ordinateur avec 3 gibioctets de mémoire, il ne pourra pas. Pareil s'il veut utiliser 2 gibioctets de mémoire sur un ordinateur avec 4 gibioctets, mais dont 3 gibioctets sont déjà utilisés par d'autres programmes. Dans les deux cas, l'illusion tombe à plat.
Les techniques de '''mémoire virtuelle''' font que l'espace d'adressage est utilisable au complet, même s'il n'y a pas assez de mémoire installée dans l'ordinateur ou que d'autres programmes utilisent de la RAM. Par exemple, sur un processeur 32 bits, le programme aura accès à 4 gibioctets de RAM, même si d'autres programmes utilisent la RAM, même s'il n'y a que 2 gibioctets de RAM d'installés dans l'ordinateur.
Pour cela, on utilise une partie des mémoires de masse (disques durs) d'un ordinateur en remplacement de la mémoire physique manquante. Le système d'exploitation crée sur le disque dur un fichier, appelé le ''swapfile'' ou '''fichier de ''swap''''', qui est utilisé comme mémoire RAM supplémentaire. Il mémorise le surplus de données et de programmes qui ne peut pas être mis en mémoire RAM.
[[File:Vm1.png|centre|vignette|upright=2.0|Mémoire virtuelle et fichier de Swap.]]
Une technique naïve de mémoire virtuelle serait la suivante. Avant de l'aborder, précisons qu'il s'agit d'une technique abordée à but pédagogique, mais qui n'est implémentée nulle part tellement elle est lente et inefficace. Un espace d'adressage de 4 gigas ne contient que 3 gigas de RAM, ce qui fait 1 giga d'adresses inutilisées. Les accès mémoire aux 3 gigas de RAM se font normalement, mais l'accès aux adresses inutilisées lève une exception matérielle "Memory Unavailable". La routine d'interruption de cette exception accède alors au ''swapfile'' et récupère les données associées à cette adresse. La mémoire virtuelle est alors émulée par le système d'exploitation.
Le défaut de cette méthode est que l'accès au giga manquant est toujours très lent, parce qu'il se fait depuis le disque dur. D'autres techniques de mémoire virtuelle logicielle font beaucoup mieux, mais nous allons les passer sous silence, vu qu'on peut faire mieux, avec l'aide du matériel.
L'idée est de charger les données dont le programme a besoin dans la RAM, et de déplacer les autres sur le disque dur. Par exemple, imaginons la situation suivante : un programme a besoin de 4 gigas de mémoire, mais ne dispose que de 2 gigas de mémoire installée. On peut imaginer découper l'espace d'adressage en 2 blocs de 2 gigas, qui sont chargés à la demande. Si le programme accède aux adresses basses, on charge les 2 gigas d'adresse basse en RAM. S'il accède aux adresses hautes, on charge les 2 gigas d'adresse haute dans la RAM après avoir copié les adresses basses sur le ''swapfile''.
On perd du temps dans les copies de données entre RAM et ''swapfile'', mais on gagne en performance vu que tous les accès mémoire se font en RAM. Du fait de la localité temporelle, le programme utilise les données chargées depuis le swapfile durant un bon moment avant de passer au bloc suivant. La RAM est alors utilisée comme une sorte de cache alors que les données sont placées dans une mémoire fictive représentée par l'espace d'adressage et qui correspond au disque dur.
Mais avec cette technique, la correspondance entre adresses du programme et adresses de la RAM change au cours du temps. Les adresses de la RAM correspondent d'abord aux adresses basses, puis aux adresses hautes, et ainsi de suite. On a donc besoin d'abstraction mémoire. Les correspondances entre adresse logique et physique peuvent varier avec le temps, ce qui permet de déplacer des données de la RAM vers le disque dur ou inversement. Une adresse logique peut correspondre à une adresse physique, ou bien à une donnée swappée sur le disque dur. C'est l'unité de traduction d'adresse qui se charge de faire la différence. Si une correspondance entre adresse logique et physique est trouvée, elle l'utilise pour traduire les adresses. Si aucune correspondance n'est trouvée, alors elle laisse la main au système d'exploitation pour charger la donnée en RAM. Une fois la donnée chargée en RAM, les correspondances entre adresse logique et physiques sont modifiées de manière à ce que l'adresse logique pointe vers la donnée chargée.
===L'extension d'adressage===
Une autre fonctionnalité rendue possible par l'abstraction mémoire est l{{'}}'''extension d'adressage'''. Elle permet d'utiliser plus de mémoire que l'espace d'adressage ne le permet. Par exemple, utiliser 7 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'extension d'adresse est l'exact inverse de la mémoire virtuelle. La mémoire virtuelle sert quand on a moins de mémoire que d'adresses, l'extension d'adresse sert quand on a plus de mémoire que d'adresses.
Il y a quelques chapitres, nous avions vu que c'est possible via la commutation de banques. Mais l'abstraction mémoire est une méthode alternative. Que ce soit avec la commutation de banques ou avec l'abstraction mémoire, les adresses envoyées à la mémoire doivent être plus longues que les adresses gérées par le processeur. La différence est que l'abstraction mémoire étend les adresses d'une manière différente.
Une implémentation possible de l'extension d'adressage fait usage de l'abstraction matérielle des processus. Chaque processus a son propre espace d'adressage, mais ceux-ci sont placés à des endroits différents dans la mémoire physique. Par exemple, sur un ordinateur avec 16 gigas de RAM, mais un espace d'adressage de 2 gigas, on peut remplir la RAM en lançant 8 processus différents et chaque processus aura accès à un bloc de 2 gigas de RAM, pas plus, il ne peut pas dépasser cette limite. Ainsi, chaque processus est limité par son espace d'adressage, mais on remplit la mémoire avec plusieurs processus, ce qui compense. Il s'agit là de l'implémentation la plus simple, qui a en plus l'avantage d'avoir la meilleure compatibilité logicielle. De simples changements dans le système d'exploitation suffisent à l'implémenter.
[[File:Extension de l'espace d'adressage.png|centre|vignette|upright=1.5|Extension de l'espace d'adressage]]
Un autre implémentation donne plusieurs espaces d'adressage différents à chaque processus, et a donc accès à autant de mémoire que permis par la somme de ces espaces d'adressage. Par exemple, sur un ordinateur avec 16 gigas de RAM et un espace d'adressage de 4 gigas, un programme peut utiliser toute la RAM en utilisant 4 espaces d'adressage distincts. On passe d'un espace d'adressage à l'autre en changeant la correspondance adresse logique-physique. L'inconvénient est que la compatibilité logicielle est assez mauvaise. Modifier l'OS ne suffit pas, les programmeurs doivent impérativement concevoir leurs programmes pour qu'ils utilisent explicitement plusieurs espaces d'adressage.
Les deux implémentations font usage des adresses logiques homonymes, mais à l'intérieur d'un même processus. Pour rappel, cela veut dire qu'une adresse logique correspond à des adresses physiques différentes. Rien d'étonnant vu qu'on utilise plusieurs espaces d'adressage, comme pour l'abstraction des processus, sauf que cette fois-ci, on a plusieurs espaces d'adressage par processus. Prenons l'exemple où on a 8 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'idée est qu'une adresse correspondra à une adresse dans les premiers 4 gigas, ou dans les seconds 4 gigas. L'adresse logique X correspondra d'abord à une adresse physique dans les premiers 4 gigas, puis à une adresse physique dans les seconds 4 gigas.
===La protection mémoire===
La '''protection mémoire''' regroupe des techniques très différentes les unes des autres, qui visent à améliorer la sécurité des programmes et des systèmes d'exploitation. Elles visent à empêcher de lire, d'écrire ou d'exécuter certaines portions de mémoire. Sans elle, les programmes peuvent techniquement lire ou écrire les données des autres, ce qui causent des situations non-prévues par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses.
La première technique de protection mémoire est l{{'}}'''isolation des processus''', qu'on a vue plus haut. Elle garantit que chaque programme n'a accès qu'à certaines portions dédiées de la mémoire et rend le reste de la mémoire inaccessible en lecture et en écriture. Le système d'exploitation attribue à chaque programme une ou plusieurs portions de mémoire rien que pour lui, auquel aucun autre programme ne peut accéder. Un tel programme, isolé des autres, s'appelle un '''processus''', d'où le nom de cet objectif. Toute tentative d'accès à une partie de la mémoire non autorisée déclenche une exception matérielle (rappelez-vous le chapitre sur les interruptions) qui est traitée par une routine du système d'exploitation. Généralement, le programme fautif est sauvagement arrêté et un message d'erreur est affiché à l'écran.
La '''protection de l'espace exécutable''' empêche d’exécuter quoique ce soit provenant de certaines zones de la mémoire. En effet, certaines portions de la mémoire sont censées contenir uniquement des données, sans aucun programme ou code exécutable. Cependant, des virus informatiques peuvent se cacher dedans et d’exécuter depuis celles-ci. Ou encore, des failles de sécurités peuvent permettre à un attaquant d'injecter du code exécutable malicieux dans des données, ce qui peut lui permettre de lire les données manipulées par un programme, prendre le contrôle de la machine, injecter des virus, ou autre. Pour éviter cela, le système d'exploitation peut marquer certaines zones mémoire comme n'étant pas exécutable. Toute tentative d’exécuter du code localisé dans ces zones entraîne la levée d'une exception ou d'une erreur et le système d'exploitation réagit en conséquence. Là encore, le processeur doit détecter les exécutions non autorisées.
D'autres méthodes de protection mémoire visent à limiter des actions dangereuses. Pour cela, le processeur et l'OS gèrent des '''droits d'accès''', qui interdisent certaines actions pour des programmes non-autorisés. Lorsqu'on exécute une opération interdite, le système d’exploitation et/ou le processeur réagissent en conséquence. La première technique de ce genre n'est autre que la séparation entre espace noyau et utilisateur, vue dans le chapitre sur les interruptions. Mais il y en a d'autres, comme nous le verrons dans ce chapitre.
==La MMU==
La traduction des adresses logiques en adresses physiques se fait par un circuit spécialisé appelé la '''''Memory Management Unit''''' (MMU), qui est souvent intégré directement dans l'interface mémoire. La MMU est souvent associée à une ou plusieurs mémoires caches, qui visent à accélérer la traduction d'adresses logiques en adresses physiques. En effet, nous verrons plus bas que la traduction d'adresse demande d'accéder à des tableaux, gérés par le système d'exploitation, qui sont en mémoire RAM. Aussi, les processeurs modernes incorporent des mémoires caches appelées des '''''Translation Lookaside Buffers''''', ou encore TLB. Nous nous pouvons pas parler des TLB pour le moment, car nous n'avons pas encore abordé le chapitre sur les mémoires caches, mais un chapitre entier sera dédié aux TLB d'ici peu.
[[File:MMU principle updated.png|centre|vignette|upright=2|MMU.]]
===Les MMU intégrées au processeur===
D'ordinaire, la MMU est intégrée au processeur. Et elle peut l'être de deux manières. La première en fait un circuit séparé, relié au bus d'adresse. La seconde fusionne la MMU avec l'unité de calcul d'adresse. La première solution est surtout utilisée avec une technique d'abstraction mémoire appelée la pagination, alors que l'autre l'est avec une autre méthode appelée la segmentation. La raison est que la traduction d'adresse avec la segmentation est assez simple : elle demande d'additionner le contenu d'un registre avec l'adresse logique, ce qui est le genre de calcul qu'une unité de calcul d'adresse sait déjà faire. La fusion est donc assez évidente.
Pour donner un exemple, l'Intel 8086 fusionnait l'unité de calcul d'adresse et la MMU. Précisément, il utilisait un même additionneur pour incrémenter le ''program counter'' et effectuer des calculs d'adresse liés à la segmentation. Il aurait été logique d'ajouter les pointeurs de pile avec, mais ce n'était pas possible. La raison est que le pointeur de pile ne peut pas être envoyé directement sur le bus d'adresse, vu qu'il doit passer par une phase de traduction en adresse physique liée à la segmentation.
[[File:80186 arch.png|centre|vignette|upright=2|Intel 8086, microarchitecture.]]
===Les MMU séparées du processeur, sur la carte mère===
Il a existé des processeurs avec une MMU externe, soudée sur la carte mère.
Par exemple, les processeurs Motorola 68000 et 68010 pouvaient être combinés avec une MMU de type Motorola 68451. Elle supportait des versions simplifiées de la segmentation et de la pagination. Au minimum, elle ajoutait un support de la protection mémoire contre certains accès non-autorisés. La gestion de la mémoire virtuelle proprement dit n'était possible que si le processeur utilisé était un Motorola 68010, en raison de la manière dont le 68000 gérait ses accès mémoire. La MMU 68451 gérait un espace d'adressage de 16 mébioctets, découpé en maximum 32 pages/segments. On pouvait dépasser cette limite de 32 segments/pages en combinant plusieurs 68451.
Le Motorola 68851 était une MMU qui était prévue pour fonctionner de paire avec le Motorola 68020. Elle gérait la pagination pour un espace d'adressage de 32 bits.
Les processeurs suivants, les 68030, 68040, et 68060, avaient une MMU interne au processeur.
==La relocation matérielle==
Pour rappel, les systèmes d'exploitation moderne permettent de lancer plusieurs programmes en même temps et les laissent se partager la mémoire. Dans le cas le plus simple, qui n'est pas celui des OS modernes, le système d'exploitation découpe la mémoire en blocs d'adresses contiguës qui sont appelés des '''segments''', ou encore des ''partitions mémoire''. Les segments correspondent à un bloc de mémoire RAM. C'est-à-dire qu'un segment de 259 mébioctets sera un segment continu de 259 mébioctets dans la mémoire physique comme dans la mémoire logique. Dans ce qui suit, un segment contient un programme en cours d'exécution, comme illustré ci-dessous.
[[File:CPT Memory Addressable.svg|centre|vignette|upright=2|Espace d'adressage segmenté.]]
Le système d'exploitation mémorise la position de chaque segment en mémoire, ainsi que d'autres informations annexes. Le tout est regroupé dans la '''table de segment''', un tableau dont chaque case est attribuée à un programme/segment. La table des segments est un tableau numéroté, chaque segment ayant un numéro qui précise sa position dans le tableau. Chaque case, chaque entrée, contient un '''descripteur de segment''' qui regroupe plusieurs informations sur le segment : son adresse de base, sa taille, diverses informations.
===La relocation avec la relocation matérielle : le registre de base===
Un segment peut être placé n'importe où en RAM physique et sa position en RAM change à chaque exécution. Le programme est chargé à une adresse, celle du début du segment, qui change à chaque chargement du programme. Et toutes les adresses utilisées par le programme doivent être corrigées lors du chargement du programme, généralement par l'OS. Cette correction s'appelle la '''relocation''', et elle consiste à ajouter l'adresse de début du segment à chaque adresse manipulée par le programme.
[[File:Relocation assistée par matériel.png|centre|vignette|upright=2.5|Relocation.]]
La relocation matérielle fait que la relocation est faite par le processeur, pas par l'OS. La relocation est intégrée dans le processeur par l'intégration d'un registre : le '''registre de base''', aussi appelé '''registre de relocation'''. Il mémorise l'adresse à laquelle commence le segment, la première adresse du programme. Pour effectuer la relocation, le processeur ajoute automatiquement l'adresse de base à chaque accès mémoire, en allant la chercher dans le registre de relocation.
[[File:Registre de base de segment.png|centre|vignette|upright=2|Registre de base de segment.]]
Le processeur s'occupe de la relocation des segments et le programme compilé n'en voit rien. Pour le dire autrement, les programmes manipulent des adresses logiques, qui sont traduites par le processeur en adresses physiques. La traduction se fait en ajoutant le contenu du registre de relocation à l'adresse logique. De plus, cette méthode fait que chaque programme a son propre espace d'adressage.
[[File:CPU created logical address presentation.png|centre|vignette|upright=2|Traduction d'adresse avec la relocation matérielle.]]
Le système d'exploitation mémorise les adresses de base pour chaque programme, dans la table des segments. Le registre de base est mis à jour automatiquement lors de chaque changement de segment. Pour cela, le registre de base est accessible via certaines instructions, accessibles en espace noyau, plus rarement en espace utilisateur. Le registre de segment est censé être adressé implicitement, vu qu'il est unique. Si ce n'est pas le cas, il est possible d'écrire dans ce registre de segment, qui est alors adressable.
===La protection mémoire avec la relocation matérielle : le registre limite===
Sans restrictions supplémentaires, la taille maximale d'un segment est égale à la taille complète de l'espace d'adressage. Sur les processeurs 32 bits, un segment a une taille maximale de 2^32 octets, soit 4 gibioctets. Mais il est possible de limiter la taille du segment à 2 gibioctets, 1 gibioctet, 64 Kibioctets, ou toute autre taille. La limite est définie lors de la création du segment, mais elle peut cependant évoluer au cours de l'exécution du programme, grâce à l'allocation mémoire.
Le processeur vérifie à chaque accès mémoire que celui-ci se fait bien dans le segment, qu'il ne déborde pas en-dehors. C'est possible qu'une adresse calculée sorte du segment, à la suite d'un bug ou d'une erreur de programmation, voire pire. Et le processeur doit éviter de tels '''débordements de segments'''. Pour cela, le processeur compare l'adresse accédée et vérifie qu'elle est bien dans le segment.
Faire cette vérification demande soit de mémoriser la taille du segment, soit de mémoriser l'adresse limite (l'adresse de fin de segment, l'adresse limite à ne pas dépasser). Les deux sont possibles et marchent parfaitement, le choix entre les deux solutions est une pure question de préférence. A la rigueur, utiliser la taille limite permet quelques optimisations, comme tester les débordements en parallèle du calcul de l'adresse.
En utilisant l'adresse limite, on fait la relocation, et on compare l'adresse calculée avec l'adresse limite. Le calcul d'adresse doit se faire avant la vérification. Mais en utilisant la taille, on peut comparer l'adresse logique avec la taille du segment. Précisons que l'adresse logique est celle avant relocation, celle qui indique la position de la donnée dans le segment, celle obtenue quand on considère que le segment commence à l'adresse zéro. On peut alors faire le test de débordement avant ou pendant la relocation. Quelques processeurs en ont profité, mais on verra cela dans la section sur la segmentation.
Pour cela, la table des segments doit être modifiée. Au lieu de ne contenir que l'adresse de base, elle contient soit l'adresse maximale du segment, soit la taille du segment. En clair, le descripteur de segment est enrichi avec l'adresse limite. 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.
Techniquement, il y a une petite différence de vitesse entre utiliser la taille et l'adresse maximale. Vérifier les débordements avec la taille demande juste de comparer la taille avec l'adresse logique, avant relocation, ce qui peut être fait en parallèle de la relocation. Par contre, l'adresse limite est comparée à une adresse physique, ce qui demande de faire la relocation avant la vérification, ce qui prend un peu plus de temps. Mais l'impact sur les performances est des plus mineurs.
[[File:Registre limite.png|centre|vignette|upright=2|Registre limite]]
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> </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>+ </code><code style="background:#DDF">0001 0010 0011 0100</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </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. Au lieu d'utiliser un additionneur séparé pour le ''program counter'' et un autre pour le calcul de l'adresse physique, un seul additionneur est utilisé pour les deux. L'idée était de partager l'additionneur, qui servait à la fois à incrémenter le ''program counter'' et pour gérer la segmentation. 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|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> </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>+ </code><code style="background:#DDF">1111 1111 1111 1111</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </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 de nos PCs, à partir du 286 d'Intel. 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. A 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. A la place, les segments sont adressés de manière indirecte. A 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 utilise l'adresse ou la taille limite pour vérifier si l'accès mémoire ne déborde pas en-dehors du segment en cours. Pour cela, le processeur compare l'adresse logique accédée avec l'adresse limite, ou compare la taille limite avec le décalage. L'information est lue depuis la table des segments à chaque accès, sauf si le tout est optimisé en utilisant un cache de descripteurs de segments.
Le processeur Intel 386 préférait comparer la taille du segment avec le décalage,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 le faire 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''. 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''.
L'Intel 386 faisait le calcul autrement. 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.
====L'implémentation de la protection mémoire sur le 386====
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 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. A 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]]
A 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. A 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. A la place, l'entrée contient un '''bit ''global''''', qui précise que cette correspondance est valide pour tous les processus. Le bit global accélère rapidement la traduction d'adresse pour l'accès au noyau.
Un défaut de cette méthode est que le partage d'une page entre plusieurs processus est presque impossible. Impossible de partager une page avec seulement certains processus et pas d'autres : soit on partage une page avec tous les processus, soit on l'alloue avec un seul processus.
====L'usage de plusieurs tables des pages====
Une solution alternative, plus simple, utilise une table des pages par processus lancé sur l'ordinateur, une table des pages unique par espace d'adressage. À chaque changement de processus, le registre qui mémorise la position de la table des pages est modifié pour pointer sur la bonne. C'est le système d'exploitation qui se charge de cette mise à jour.
Avec cette méthode, il est possible de partager une ou plusieurs pages entre plusieurs processus, en configurant les tables des pages convenablement. Les pages partagées sont mappées dans l'espace d'adressage de plusieurs processus, mais pas forcément au même endroit, pas forcément dans les mêmes adresses logiques. On peut placer la page partagée à l'adresse logique 0x0FFF pour un processus, à l'adresse logique 0xFF00 pour un autre processus, etc. Par contre, les entrées de la table des pages pour ces adresses pointent vers la même adresse physique.
[[File:Vm5.png|centre|vignette|upright=2|Tables des pages de plusieurs processus.]]
===La taille des pages===
La taille des pages varie suivant le processeur et le système d'exploitation et tourne souvent autour de 4 kibioctets. Les processeurs actuels gèrent plusieurs tailles différentes pour les pages : 4 kibioctets par défaut, 2 mébioctets, voire 1 à 4 gibioctets pour les pages les plus larges. Les pages de 4 kibioctets sont les pages par défaut, les autres tailles de page sont appelées des ''pages larges''. La taille optimale pour les pages dépend de nombreux paramètres et il n'y a pas de taille qui convienne à tout le monde. Certaines applications gagnent à utiliser des pages larges, d'autres vont au contraire perdre drastiquement en performance en les utilisant.
Le désavantage principal des pages larges est qu'elles favorisent la fragmentation mémoire. Si un programme veut réserver une portion de mémoire, pour une structure de donnée quelconque, il doit réserver une portion dont la taille est multiple de la taille d'une page. Par exemple, un programme ayant besoin de 110 kibioctets allouera 28 pages de 4 kibioctets, soit 120 kibioctets : 2 kibioctets seront perdus. Par contre, avec des pages larges de 2 mébioctets, on aura une perte de 2048 - 110 = 1938 kibioctets. En somme, des morceaux de mémoire seront perdus, car les pages sont trop grandes pour les données qu'on veut y mettre. Le résultat est que le programme qui utilise les pages larges utilisent plus de mémoire et ce d'autant plus qu'il utilise des données de petite taille. Un autre désavantage est qu'elles se marient mal avec certaines techniques d'optimisations de type ''copy-on-write''.
Mais l'avantage est que la traduction des adresses est plus performante. Une taille des pages plus élevée signifie moins de pages, donc des tables des pages plus petites. Et des pages des tables plus petites n'ont pas besoin de beaucoup de niveaux de hiérarchie, voire peuvent se limiter à des tables des pages simples, ce qui rend la traduction d'adresse plus simple et plus rapide. De plus, les programmes ont une certaine localité spatiale, qui font qu'ils accèdent souvent à des données proches. La traduction d'adresse peut alors profiter de systèmes de mise en cache dont nous parlerons dans le prochain chapitre, et ces systèmes de cache marchent nettement mieux avec des pages larges.
Il faut noter que la taille des pages est presque toujours une puissance de deux. Cela a de nombreux avantages, mais n'est pas une nécessité. Par exemple, le tout premier processeur avec de la pagination, le super-ordinateur Atlas, avait des pages de 3 kibioctets. L'avantage principal est que la traduction de l'adresse physique en adresse logique est trivial avec une puissance de deux. Cela garantit que l'on peut diviser l'adresse en un numéro de page et un ''offset'' : la traduction demande juste de remplacer les bits de poids forts par le numéro de page voulu. Sans cela, la traduction d'adresse implique des divisions et des multiplications, qui sont des opérations assez couteuses.
===Les entrées de la table des pages===
Avant de poursuivre, faisons un rapide rappel sur les entrées de la table des pages. Nous venons de voir que la table des pages contient de nombreuses informations : un bit ''valid'' pour la mémoire virtuelle, des bits ''dirty'' et ''accessed'' utilisés par l'OS, des bits de protection mémoire, un bit ''global'' et un potentiellement un identifiant de processus, etc. Étudions rapidement le format de la table des pages sur un processeur x86 32 bits.
* Elle contient d'abord le numéro de page physique.
* Les bits AVL sont inutilisés et peuvent être configurés à loisir par l'OS.
* Le bit G est le bit ''global''.
* Le bit PS vaut 0 pour une page de 4 kibioctets, mais est mis à 1 pour une page de 4 mébioctets dans le cas où le processus utilise des pages larges.
* Le bit D est le bit ''dirty''.
* Le bit A est le bit ''accessed''.
* Le bit PCD indique que la page ne peut pas être cachée, dans le sens où le processeur ne peut copier son contenu dans le cache et doit toujours lire ou écrire cette page directement dans la RAM.
* Le bit PWT indique que les écritures doivent mettre à jour le cache et la page en RAM (dans le chapitre sur le cache, on verra qu'il force le cache à se comporter comme un cache ''write-through'' pour cette page).
* Le bit U/S précise si la page est accessible en mode noyau ou utilisateur.
* Le bit R/W indique si la page est accessible en écriture, toutes les pages sont par défaut accessibles en lecture.
* Le bit P est le bit ''valid''.
[[File:PDE.png|centre|vignette|upright=2.5|Table des pages des processeurs Intel 32 bits.]]
==Comparaison des différentes techniques d'abstraction mémoire==
Pour résumer, l'abstraction mémoire permet de gérer : la relocation, la protection mémoire, l'isolation des processus, la mémoire virtuelle, l'extension de l'espace d'adressage, le partage de mémoire, etc. Elles sont souvent implémentées en même temps. Ce qui fait qu'elles sont souvent confondues, alors que ce sont des concepts sont différents. Ces liens sont résumés dans le tableau ci-dessous.
{|class="wikitable"
|-
!
! colspan="5" | Avec abstraction mémoire
! rowspan="2" | Sans abstraction mémoire
|-
!
! Relocation matérielle
! Segmentation en mode réel (x86)
! Segmentation, général
! Architectures à capacités
! Pagination
|-
! Abstraction matérielle des processus
| colspan="4" | Oui, relocation matérielle
| Oui, liée à la traduction d'adresse
| Impossible
|-
! Mémoire virtuelle
| colspan="2" | Non, sauf émulation logicielle
| colspan="3" | Oui, gérée par le processeur et l'OS
| Non, sauf émulation logicielle
|-
! Extension de l'espace d'adressage
| colspan="2" | Oui : registre de base élargi
| colspan="2" | Oui : adresse de base élargie dans la table des segments
| ''Physical Adress Extension'' des processeurs 32 bits
| Commutation de banques
|-
! Protection mémoire
| Registre limite
| Aucune
| colspan="2" | Registre limite, droits d'accès aux segments
| Gestion des droits d'accès aux pages
| Possible, méthodes variées
|-
! Partage de mémoire
| colspan="2" | Non
| colspan="2" | Segment partagés
| Pages partagées
| Possible, méthodes variées
|}
===Les différents types de segmentation===
La segmentation regroupe plusieurs techniques franchement différentes, qui auraient gagné à être nommées différemment. La principale différence est l'usage de registres de relocation versus des registres de sélecteurs de segments. L'usage de registres de relocation est le fait de la relocation matérielle, mais aussi de la segmentation en mode réel des CPU x86. Par contre, l'usage de sélecteurs de segments est le fait des autres formes de segmentation, architectures à capacité inclues.
La différence entre les deux est le nombre de segments. L'usage de registres de relocation fait que le CPU ne gère qu'un petit nombre de segments de grande taille. La mémoire virtuelle est donc rarement implémentée vu que swapper des segments de grande taille est trop long, l'impact sur les performances est trop important. Sans compter que l'usage de registres de base se marie très mal avec la mémoire virtuelle. Vu qu'un segment peut être swappé ou déplacée n'importe quand, il faut invalider les registres de base au moment du swap/déplacement, ce qui n'est pas chose aisée. Aucun processeur ne gère cela, les méthodes pour n'existent tout simplement pas. L'usage de registres de base implique que la mémoire virtuelle est absente.
La protection mémoire est aussi plus limitée avec l'usage de registres de relocation. Elle se limite à des registres limite, mais la gestion des droits d'accès est limitée. En théorie, la segmentation en mode réel pourrait implémenter une version limitée de protection mémoire, avec une protection de l'espace exécutable. Mais ca n'a jamais été fait en pratique sur les processeurs x86.
Le partage de la mémoire est aussi difficile sur les architectures avec des registres de base. L'absence de table des segments fait que le partage d'un segment est basiquement impossible sans utiliser des méthodes complétement tordues, qui ne sont jamais implémentées en pratique.
===Segmentation versus pagination===
Par rapport à la pagination, la segmentation a des avantages et des inconvénients. Tous sont liés aux propriétés des segments et pages : les segments sont de grande taille et de taille variable, les pages sont petites et de taille fixe.
L'avantage principal de la segmentation est sa rapidité. Le fait que les segments sont de grande taille fait qu'on a pas besoin d'équivalent aux tables des pages inversée ou multiple, juste d'une table des segments toute simple. De plus, les échanges entre table des pages/segments et registres sont plus rares avec la segmentation. Par exemple, si un programme utilise un segment de 2 gigas, tous les accès dans le segment se feront avec une seule consultation de la table des segments. Alors qu'avec la pagination, il faudra une consultation de la table des pages chaque bloc de 4 kibioctet, au minimum.
Mais les désavantages sont nombreux. Le système d'exploitation doit agencer les segments en RAM, et c'est une tâche complexe. Le fait que les segments puisse changer de taille rend le tout encore plus complexe. Par exemple, si on colle les segments les uns à la suite des autres, changer la taille d'un segment demande de réorganiser tous les segments en RAM, ce qui demande énormément de copies RAM-RAM. Une autre possibilité est de laisser assez d'espace entre les segments, mais cet espace est alors gâché, dans le sens où on ne peut pas y placer un nouveau segment.
Swapper un segment est aussi très long, vu que les segments sont de grande taille, alors que swapper une page est très rapide.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'espace d'adressage du processeur
| prevText=L'espace d'adressage du processeur
| next=Les méthodes de synchronisation entre processeur et périphériques
| nextText=Les méthodes de synchronisation entre processeur et périphériques
}}
</noinclude>
j8bud79ym84zea38nvg135vvwdr4vj2
763704
763702
2026-04-15T00:58:39Z
Mewtow
31375
/* La protection mémoire : les accès hors-segments */
763704
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 envoyées à 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 sachez qu'il existe 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 les adresses de l'espace d'adressage sont 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'abstraction mémoire implémente plusieurs fonctionnalités complémentaires==
L'utilité de l'abstraction matérielle n'est pas évidente, mais sachez qu'elle est si utile que tous les processeurs modernes la prennent en charge. Elle sert notamment à implémenter la mémoire virtuelle, que nous aborderons dans ce qui suit. La plupart de ces fonctionnalités manipulent la relation entre adresses logiques et physique. Dans le cas le plus simple, une adresse logique correspond à une seule adresse physique. Mais beaucoup de fonctionnalités avancées ne respectent pas cette règle.
===L'abstraction matérielle des processus===
Les systèmes d'exploitation modernes sont dits multi-tâche, à savoir qu'ils sont capables d'exécuter plusieurs logiciels en même temps. Et ce même si un seul processeur est présent dans l'ordinateur : les logiciels sont alors exécutés à tour de rôle. Toutefois, cela amène un paquet de problèmes qu'il faut résoudre au mieux. Par exemple, les programmes exécutés doivent se partager la mémoire RAM, ce qui ne vient pas sans problèmes. Le problème principal est que les programmes ne doivent pas lire ou écrire dans les données d'un autre, sans quoi on se retrouverait rapidement avec des problèmes. Il faut donc introduire des mécanismes d{{'}}'''isolement des processus''', pour isoler les programmes les uns des autres.
Un de ces mécanismes est l{{'}}'''abstraction matérielle des processus''', une technique qui fait que chaque programme a son propre espace d'adressage. Chaque programme a l'impression d'avoir accès à tout l'espace d'adressage, de l'adresse 0 à l'adresse maximale gérée par le processeur. Évidemment, il s'agit d'une illusion maintenue justement grâce à la traduction d'adresse. Les espaces d'adressage contiennent des adresses logiques, les adresses de la RAM sont des adresses physiques, la nécessité de l'abstraction mémoire est évidente.
Implémenter l'abstraction mémoire peut se faire de plusieurs manières. Mais dans tous les cas, il faut que la correspondance adresse logique - physique change d'un programme à l'autre. Ce qui est normal, vu que les deux processus sont placés à des endroits différents en RAM physique. La conséquence est qu'avec l'abstraction mémoire, une adresse logique correspond à plusieurs adresses physiques. Une même adresse logique dans deux processus différents correspond à deux adresses phsiques différentes, une par processus. Une adresse logique dans un processus correspondra à l'adresse physique X, la même adresse dans un autre processus correspondra à l'adresse Y.
Les adresses physiques qui partagent la même adresse logique sont alors appelées des '''adresses homonymes'''. Le choix de la bonne adresse étant réalisé par un mécanisme matériel et dépend du programme en cours. Le mécanisme pour choisir la bonne adresse dépend du processeur, mais il y en a deux grands types :
* La première consiste à utiliser l'identifiant de processus CPU, vu au chapitre précédent. C'est, pour rappel, un numéro attribué à chaque processus par le processeur. L'identifiant du processus en cours d'exécution est mémorisé dans un registre du processeur. La traduction d'adresse utilise cet identifiant, en plus de l'adresse logique, pour déterminer l'adresse physique.
* La seconde solution mémorise les correspondances adresses logiques-physique dans des tables en mémoire RAM, qui sont différentes pour chaque programme. Les tables sont accédées à chaque accès mémoire, afin de déterminer l'adresse physique.
===Le partage de la mémoire===
L'isolation des processus est très importante sur les systèmes d'exploitation modernes. Cependant, il existe quelques situations où elle doit être contournée ou du moins mise en pause. Les situations sont multiples : gestion de bibliothèques partagées, communication entre processus, usage de ''threads'', etc. Elles impliquent toutes un '''partage de mémoire''', à savoir qu'une portion de mémoire RAM est partagée entre plusieurs programmes. Le partage de mémoire est une sorte de brèche de l'isolation des processus, mais qui est autorisée car elle est utile.
Un cas intéressant est celui des '''bibliothèques partagées'''. Les bibliothèques sont des collections de fonctions regroupées ensemble, dans une seule unité de code. Un programme qui utilise une bibliothèque peut appeler n’importe quelle fonction présente dans la bibliothèque. La bibliothèque peut être simplement inclue dans le programme lui-même, on parle alors de bibliothèques statiques. De telles bibliothèques fonctionnent très bien, mais avec un petit défaut pour les bibliothèques très utilisées : plusieurs programmes qui utilisent la même bibliothèque vont chacun l'inclure dans leur code, ce qui fera doublon.
Pour éviter cela, les OS modernes gèrent des bibliothèques partagées, à savoir qu'un seul exemplaire de la bibliothèque est partagé entre plusieurs programmes. Chaque programme peut exécuter une fonction de la bibliothèque quand il le souhaite, en effectuant un branchement adéquat. Mais cela implique que la bibliothèque soit présente dans l'espace d'adressage du programme en question. Une bibliothèque est donc présente dans plusieurs espaces d'adressage, alors qu'il n'y en a qu'un seul exemplaire en mémoire RAM.
[[File:Ogg vorbis libs and application dia.svg|centre|vignette|upright=2|Exemple de bibliothèques, avec Ogg vorbis.]]
D'autres situations demandent de partager de la mémoire entre deux programmes. Par exemple, les systèmes d'exploitation modernes gèrent nativement des systèmes de '''communication inter-processus''', très utilisés par les programmes modernes pour échanger des données. Et la plupart demandant de partager un bout de mémoire entre processus, même si c'est seulement temporairement. Typiquement, deux processus partagent un intervalle d'adresse où l'un écrit les données à l'autre, l'autre lisant les données envoyées.
Une dernière utilisation de la mémoire partagée est l{{'}}'''accès direct au noyau'''. Sur les systèmes d'exploitations moderne, dans l'espace d'adressage de chaque programme, les adresses hautes sont remplies avec une partie du noyau ! Évidemment, ces adresses sont accessibles uniquement en lecture, pas en écriture. Pas question de modifier le noyau de l'OS ! De plus, il s'agit d'une portion du noyau dont on sait que la consultation ne pose pas de problèmes de sécurité.
Le programme peut lire des données dans cette portion du noyau, mais aussi exécuter les fonctions du noyau qui sont dedans. L'idée est d'éviter des appels systèmes trop fréquents. Au lieu d'effectuer un véritable appel système, avec une interruption logicielle, le programme peut exécuter des appels systèmes simplifiés, de simples appels de fonctions couplés avec un changement de niveau de privilège (passage en espace noyau nécessaire).
[[File:AMD64-canonical--48-bit.png|vignette|Répartition des adresses entre noyau (jaune/orange) et programme (verte), sur les systèmes x86-64 bits, avec des adresses physiques de 48 bits.]]
L'espace d'adressage est donc séparé en deux portions : l'OS d'un côté, le programme de l'autre. La répartition des adresses entre noyau et programme varie suivant l'OS ou le processeur utilisé. Sur les PC x86 32 bits, Linux attribuait 3 gigas pour les programmes et 1 giga pour le noyau, Windows attribuait 2 gigas à chacun. Sur les systèmes x86 64 bits, l'espace d'adressage d'un programme est coupé en trois, comme illustré ci-contre : une partie basse de 2^48 octets, une partie haute de même taille, et un bloc d'adresses invalides entre les deux. Les adresses basses sont utilisées pour le programme, les adresses hautes pour le noyau, il n'y a rien entre les deux.
Avec le partage de mémoire, plusieurs adresses logiques correspondent à la même adresse physique. Tel processus verra la zone de mémoire partagée à l'adresse X, l'autre la verra à l'adresse Y. Mais il s'agira de la même portion de mémoire physique, avec une seule adresse physique. En clair, lorsque deux processus partagent une même zone de mémoire, la zone sera mappées à des adresses logiques différentes. Les adresses logiques sont alors appelées des '''adresses synonymes''', terme qui trahit le fait qu'elles correspondent à la même adresse physique.
===La mémoire virtuelle===
Toutes les adresses ne sont pas forcément occupées par de la mémoire RAM, s'il n'y a pas assez de RAM installée. Par exemple, un processeur 32 bits peut adresser 4 gibioctets de RAM, même si seulement 3 gibioctets sont installés dans l'ordinateur. L'espace d'adressage contient donc 1 gigas d'adresses inutilisées, et il faut éviter ce surplus d'adresses pose problème.
Sans mémoire virtuelle, seule la mémoire réellement installée est utilisable. Si un programme utilise trop de mémoire, il est censé se rendre compte qu'il n'a pas accès à tout l'espace d'adressage. Quand il demandera au système d'exploitation de lui réserver de la mémoire, le système d'exploitation le préviendra qu'il n'y a plus de mémoire libre. Par exemple, si un programme tente d'utiliser 4 gibioctets sur un ordinateur avec 3 gibioctets de mémoire, il ne pourra pas. Pareil s'il veut utiliser 2 gibioctets de mémoire sur un ordinateur avec 4 gibioctets, mais dont 3 gibioctets sont déjà utilisés par d'autres programmes. Dans les deux cas, l'illusion tombe à plat.
Les techniques de '''mémoire virtuelle''' font que l'espace d'adressage est utilisable au complet, même s'il n'y a pas assez de mémoire installée dans l'ordinateur ou que d'autres programmes utilisent de la RAM. Par exemple, sur un processeur 32 bits, le programme aura accès à 4 gibioctets de RAM, même si d'autres programmes utilisent la RAM, même s'il n'y a que 2 gibioctets de RAM d'installés dans l'ordinateur.
Pour cela, on utilise une partie des mémoires de masse (disques durs) d'un ordinateur en remplacement de la mémoire physique manquante. Le système d'exploitation crée sur le disque dur un fichier, appelé le ''swapfile'' ou '''fichier de ''swap''''', qui est utilisé comme mémoire RAM supplémentaire. Il mémorise le surplus de données et de programmes qui ne peut pas être mis en mémoire RAM.
[[File:Vm1.png|centre|vignette|upright=2.0|Mémoire virtuelle et fichier de Swap.]]
Une technique naïve de mémoire virtuelle serait la suivante. Avant de l'aborder, précisons qu'il s'agit d'une technique abordée à but pédagogique, mais qui n'est implémentée nulle part tellement elle est lente et inefficace. Un espace d'adressage de 4 gigas ne contient que 3 gigas de RAM, ce qui fait 1 giga d'adresses inutilisées. Les accès mémoire aux 3 gigas de RAM se font normalement, mais l'accès aux adresses inutilisées lève une exception matérielle "Memory Unavailable". La routine d'interruption de cette exception accède alors au ''swapfile'' et récupère les données associées à cette adresse. La mémoire virtuelle est alors émulée par le système d'exploitation.
Le défaut de cette méthode est que l'accès au giga manquant est toujours très lent, parce qu'il se fait depuis le disque dur. D'autres techniques de mémoire virtuelle logicielle font beaucoup mieux, mais nous allons les passer sous silence, vu qu'on peut faire mieux, avec l'aide du matériel.
L'idée est de charger les données dont le programme a besoin dans la RAM, et de déplacer les autres sur le disque dur. Par exemple, imaginons la situation suivante : un programme a besoin de 4 gigas de mémoire, mais ne dispose que de 2 gigas de mémoire installée. On peut imaginer découper l'espace d'adressage en 2 blocs de 2 gigas, qui sont chargés à la demande. Si le programme accède aux adresses basses, on charge les 2 gigas d'adresse basse en RAM. S'il accède aux adresses hautes, on charge les 2 gigas d'adresse haute dans la RAM après avoir copié les adresses basses sur le ''swapfile''.
On perd du temps dans les copies de données entre RAM et ''swapfile'', mais on gagne en performance vu que tous les accès mémoire se font en RAM. Du fait de la localité temporelle, le programme utilise les données chargées depuis le swapfile durant un bon moment avant de passer au bloc suivant. La RAM est alors utilisée comme une sorte de cache alors que les données sont placées dans une mémoire fictive représentée par l'espace d'adressage et qui correspond au disque dur.
Mais avec cette technique, la correspondance entre adresses du programme et adresses de la RAM change au cours du temps. Les adresses de la RAM correspondent d'abord aux adresses basses, puis aux adresses hautes, et ainsi de suite. On a donc besoin d'abstraction mémoire. Les correspondances entre adresse logique et physique peuvent varier avec le temps, ce qui permet de déplacer des données de la RAM vers le disque dur ou inversement. Une adresse logique peut correspondre à une adresse physique, ou bien à une donnée swappée sur le disque dur. C'est l'unité de traduction d'adresse qui se charge de faire la différence. Si une correspondance entre adresse logique et physique est trouvée, elle l'utilise pour traduire les adresses. Si aucune correspondance n'est trouvée, alors elle laisse la main au système d'exploitation pour charger la donnée en RAM. Une fois la donnée chargée en RAM, les correspondances entre adresse logique et physiques sont modifiées de manière à ce que l'adresse logique pointe vers la donnée chargée.
===L'extension d'adressage===
Une autre fonctionnalité rendue possible par l'abstraction mémoire est l{{'}}'''extension d'adressage'''. Elle permet d'utiliser plus de mémoire que l'espace d'adressage ne le permet. Par exemple, utiliser 7 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'extension d'adresse est l'exact inverse de la mémoire virtuelle. La mémoire virtuelle sert quand on a moins de mémoire que d'adresses, l'extension d'adresse sert quand on a plus de mémoire que d'adresses.
Il y a quelques chapitres, nous avions vu que c'est possible via la commutation de banques. Mais l'abstraction mémoire est une méthode alternative. Que ce soit avec la commutation de banques ou avec l'abstraction mémoire, les adresses envoyées à la mémoire doivent être plus longues que les adresses gérées par le processeur. La différence est que l'abstraction mémoire étend les adresses d'une manière différente.
Une implémentation possible de l'extension d'adressage fait usage de l'abstraction matérielle des processus. Chaque processus a son propre espace d'adressage, mais ceux-ci sont placés à des endroits différents dans la mémoire physique. Par exemple, sur un ordinateur avec 16 gigas de RAM, mais un espace d'adressage de 2 gigas, on peut remplir la RAM en lançant 8 processus différents et chaque processus aura accès à un bloc de 2 gigas de RAM, pas plus, il ne peut pas dépasser cette limite. Ainsi, chaque processus est limité par son espace d'adressage, mais on remplit la mémoire avec plusieurs processus, ce qui compense. Il s'agit là de l'implémentation la plus simple, qui a en plus l'avantage d'avoir la meilleure compatibilité logicielle. De simples changements dans le système d'exploitation suffisent à l'implémenter.
[[File:Extension de l'espace d'adressage.png|centre|vignette|upright=1.5|Extension de l'espace d'adressage]]
Un autre implémentation donne plusieurs espaces d'adressage différents à chaque processus, et a donc accès à autant de mémoire que permis par la somme de ces espaces d'adressage. Par exemple, sur un ordinateur avec 16 gigas de RAM et un espace d'adressage de 4 gigas, un programme peut utiliser toute la RAM en utilisant 4 espaces d'adressage distincts. On passe d'un espace d'adressage à l'autre en changeant la correspondance adresse logique-physique. L'inconvénient est que la compatibilité logicielle est assez mauvaise. Modifier l'OS ne suffit pas, les programmeurs doivent impérativement concevoir leurs programmes pour qu'ils utilisent explicitement plusieurs espaces d'adressage.
Les deux implémentations font usage des adresses logiques homonymes, mais à l'intérieur d'un même processus. Pour rappel, cela veut dire qu'une adresse logique correspond à des adresses physiques différentes. Rien d'étonnant vu qu'on utilise plusieurs espaces d'adressage, comme pour l'abstraction des processus, sauf que cette fois-ci, on a plusieurs espaces d'adressage par processus. Prenons l'exemple où on a 8 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'idée est qu'une adresse correspondra à une adresse dans les premiers 4 gigas, ou dans les seconds 4 gigas. L'adresse logique X correspondra d'abord à une adresse physique dans les premiers 4 gigas, puis à une adresse physique dans les seconds 4 gigas.
===La protection mémoire===
La '''protection mémoire''' regroupe des techniques très différentes les unes des autres, qui visent à améliorer la sécurité des programmes et des systèmes d'exploitation. Elles visent à empêcher de lire, d'écrire ou d'exécuter certaines portions de mémoire. Sans elle, les programmes peuvent techniquement lire ou écrire les données des autres, ce qui causent des situations non-prévues par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses.
La première technique de protection mémoire est l{{'}}'''isolation des processus''', qu'on a vue plus haut. Elle garantit que chaque programme n'a accès qu'à certaines portions dédiées de la mémoire et rend le reste de la mémoire inaccessible en lecture et en écriture. Le système d'exploitation attribue à chaque programme une ou plusieurs portions de mémoire rien que pour lui, auquel aucun autre programme ne peut accéder. Un tel programme, isolé des autres, s'appelle un '''processus''', d'où le nom de cet objectif. Toute tentative d'accès à une partie de la mémoire non autorisée déclenche une exception matérielle (rappelez-vous le chapitre sur les interruptions) qui est traitée par une routine du système d'exploitation. Généralement, le programme fautif est sauvagement arrêté et un message d'erreur est affiché à l'écran.
La '''protection de l'espace exécutable''' empêche d’exécuter quoique ce soit provenant de certaines zones de la mémoire. En effet, certaines portions de la mémoire sont censées contenir uniquement des données, sans aucun programme ou code exécutable. Cependant, des virus informatiques peuvent se cacher dedans et d’exécuter depuis celles-ci. Ou encore, des failles de sécurités peuvent permettre à un attaquant d'injecter du code exécutable malicieux dans des données, ce qui peut lui permettre de lire les données manipulées par un programme, prendre le contrôle de la machine, injecter des virus, ou autre. Pour éviter cela, le système d'exploitation peut marquer certaines zones mémoire comme n'étant pas exécutable. Toute tentative d’exécuter du code localisé dans ces zones entraîne la levée d'une exception ou d'une erreur et le système d'exploitation réagit en conséquence. Là encore, le processeur doit détecter les exécutions non autorisées.
D'autres méthodes de protection mémoire visent à limiter des actions dangereuses. Pour cela, le processeur et l'OS gèrent des '''droits d'accès''', qui interdisent certaines actions pour des programmes non-autorisés. Lorsqu'on exécute une opération interdite, le système d’exploitation et/ou le processeur réagissent en conséquence. La première technique de ce genre n'est autre que la séparation entre espace noyau et utilisateur, vue dans le chapitre sur les interruptions. Mais il y en a d'autres, comme nous le verrons dans ce chapitre.
==La MMU==
La traduction des adresses logiques en adresses physiques se fait par un circuit spécialisé appelé la '''''Memory Management Unit''''' (MMU), qui est souvent intégré directement dans l'interface mémoire. La MMU est souvent associée à une ou plusieurs mémoires caches, qui visent à accélérer la traduction d'adresses logiques en adresses physiques. En effet, nous verrons plus bas que la traduction d'adresse demande d'accéder à des tableaux, gérés par le système d'exploitation, qui sont en mémoire RAM. Aussi, les processeurs modernes incorporent des mémoires caches appelées des '''''Translation Lookaside Buffers''''', ou encore TLB. Nous nous pouvons pas parler des TLB pour le moment, car nous n'avons pas encore abordé le chapitre sur les mémoires caches, mais un chapitre entier sera dédié aux TLB d'ici peu.
[[File:MMU principle updated.png|centre|vignette|upright=2|MMU.]]
===Les MMU intégrées au processeur===
D'ordinaire, la MMU est intégrée au processeur. Et elle peut l'être de deux manières. La première en fait un circuit séparé, relié au bus d'adresse. La seconde fusionne la MMU avec l'unité de calcul d'adresse. La première solution est surtout utilisée avec une technique d'abstraction mémoire appelée la pagination, alors que l'autre l'est avec une autre méthode appelée la segmentation. La raison est que la traduction d'adresse avec la segmentation est assez simple : elle demande d'additionner le contenu d'un registre avec l'adresse logique, ce qui est le genre de calcul qu'une unité de calcul d'adresse sait déjà faire. La fusion est donc assez évidente.
Pour donner un exemple, l'Intel 8086 fusionnait l'unité de calcul d'adresse et la MMU. Précisément, il utilisait un même additionneur pour incrémenter le ''program counter'' et effectuer des calculs d'adresse liés à la segmentation. Il aurait été logique d'ajouter les pointeurs de pile avec, mais ce n'était pas possible. La raison est que le pointeur de pile ne peut pas être envoyé directement sur le bus d'adresse, vu qu'il doit passer par une phase de traduction en adresse physique liée à la segmentation.
[[File:80186 arch.png|centre|vignette|upright=2|Intel 8086, microarchitecture.]]
===Les MMU séparées du processeur, sur la carte mère===
Il a existé des processeurs avec une MMU externe, soudée sur la carte mère.
Par exemple, les processeurs Motorola 68000 et 68010 pouvaient être combinés avec une MMU de type Motorola 68451. Elle supportait des versions simplifiées de la segmentation et de la pagination. Au minimum, elle ajoutait un support de la protection mémoire contre certains accès non-autorisés. La gestion de la mémoire virtuelle proprement dit n'était possible que si le processeur utilisé était un Motorola 68010, en raison de la manière dont le 68000 gérait ses accès mémoire. La MMU 68451 gérait un espace d'adressage de 16 mébioctets, découpé en maximum 32 pages/segments. On pouvait dépasser cette limite de 32 segments/pages en combinant plusieurs 68451.
Le Motorola 68851 était une MMU qui était prévue pour fonctionner de paire avec le Motorola 68020. Elle gérait la pagination pour un espace d'adressage de 32 bits.
Les processeurs suivants, les 68030, 68040, et 68060, avaient une MMU interne au processeur.
==La relocation matérielle==
Pour rappel, les systèmes d'exploitation moderne permettent de lancer plusieurs programmes en même temps et les laissent se partager la mémoire. Dans le cas le plus simple, qui n'est pas celui des OS modernes, le système d'exploitation découpe la mémoire en blocs d'adresses contiguës qui sont appelés des '''segments''', ou encore des ''partitions mémoire''. Les segments correspondent à un bloc de mémoire RAM. C'est-à-dire qu'un segment de 259 mébioctets sera un segment continu de 259 mébioctets dans la mémoire physique comme dans la mémoire logique. Dans ce qui suit, un segment contient un programme en cours d'exécution, comme illustré ci-dessous.
[[File:CPT Memory Addressable.svg|centre|vignette|upright=2|Espace d'adressage segmenté.]]
Le système d'exploitation mémorise la position de chaque segment en mémoire, ainsi que d'autres informations annexes. Le tout est regroupé dans la '''table de segment''', un tableau dont chaque case est attribuée à un programme/segment. La table des segments est un tableau numéroté, chaque segment ayant un numéro qui précise sa position dans le tableau. Chaque case, chaque entrée, contient un '''descripteur de segment''' qui regroupe plusieurs informations sur le segment : son adresse de base, sa taille, diverses informations.
===La relocation avec la relocation matérielle : le registre de base===
Un segment peut être placé n'importe où en RAM physique et sa position en RAM change à chaque exécution. Le programme est chargé à une adresse, celle du début du segment, qui change à chaque chargement du programme. Et toutes les adresses utilisées par le programme doivent être corrigées lors du chargement du programme, généralement par l'OS. Cette correction s'appelle la '''relocation''', et elle consiste à ajouter l'adresse de début du segment à chaque adresse manipulée par le programme.
[[File:Relocation assistée par matériel.png|centre|vignette|upright=2.5|Relocation.]]
La relocation matérielle fait que la relocation est faite par le processeur, pas par l'OS. La relocation est intégrée dans le processeur par l'intégration d'un registre : le '''registre de base''', aussi appelé '''registre de relocation'''. Il mémorise l'adresse à laquelle commence le segment, la première adresse du programme. Pour effectuer la relocation, le processeur ajoute automatiquement l'adresse de base à chaque accès mémoire, en allant la chercher dans le registre de relocation.
[[File:Registre de base de segment.png|centre|vignette|upright=2|Registre de base de segment.]]
Le processeur s'occupe de la relocation des segments et le programme compilé n'en voit rien. Pour le dire autrement, les programmes manipulent des adresses logiques, qui sont traduites par le processeur en adresses physiques. La traduction se fait en ajoutant le contenu du registre de relocation à l'adresse logique. De plus, cette méthode fait que chaque programme a son propre espace d'adressage.
[[File:CPU created logical address presentation.png|centre|vignette|upright=2|Traduction d'adresse avec la relocation matérielle.]]
Le système d'exploitation mémorise les adresses de base pour chaque programme, dans la table des segments. Le registre de base est mis à jour automatiquement lors de chaque changement de segment. Pour cela, le registre de base est accessible via certaines instructions, accessibles en espace noyau, plus rarement en espace utilisateur. Le registre de segment est censé être adressé implicitement, vu qu'il est unique. Si ce n'est pas le cas, il est possible d'écrire dans ce registre de segment, qui est alors adressable.
===La protection mémoire avec la relocation matérielle : le registre limite===
Sans restrictions supplémentaires, la taille maximale d'un segment est égale à la taille complète de l'espace d'adressage. Sur les processeurs 32 bits, un segment a une taille maximale de 2^32 octets, soit 4 gibioctets. Mais il est possible de limiter la taille du segment à 2 gibioctets, 1 gibioctet, 64 Kibioctets, ou toute autre taille. La limite est définie lors de la création du segment, mais elle peut cependant évoluer au cours de l'exécution du programme, grâce à l'allocation mémoire.
Le processeur vérifie à chaque accès mémoire que celui-ci se fait bien dans le segment, qu'il ne déborde pas en-dehors. C'est possible qu'une adresse calculée sorte du segment, à la suite d'un bug ou d'une erreur de programmation, voire pire. Et le processeur doit éviter de tels '''débordements de segments'''. Pour cela, le processeur compare l'adresse accédée et vérifie qu'elle est bien dans le segment.
Faire cette vérification demande soit de mémoriser la taille du segment, soit de mémoriser l'adresse limite (l'adresse de fin de segment, l'adresse limite à ne pas dépasser). Les deux sont possibles et marchent parfaitement, le choix entre les deux solutions est une pure question de préférence. A la rigueur, utiliser la taille limite permet quelques optimisations, comme tester les débordements en parallèle du calcul de l'adresse.
En utilisant l'adresse limite, on fait la relocation, et on compare l'adresse calculée avec l'adresse limite. Le calcul d'adresse doit se faire avant la vérification. Mais en utilisant la taille, on peut comparer l'adresse logique avec la taille du segment. Précisons que l'adresse logique est celle avant relocation, celle qui indique la position de la donnée dans le segment, celle obtenue quand on considère que le segment commence à l'adresse zéro. On peut alors faire le test de débordement avant ou pendant la relocation. Quelques processeurs en ont profité, mais on verra cela dans la section sur la segmentation.
Pour cela, la table des segments doit être modifiée. Au lieu de ne contenir que l'adresse de base, elle contient soit l'adresse maximale du segment, soit la taille du segment. En clair, le descripteur de segment est enrichi avec l'adresse limite. 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.
Techniquement, il y a une petite différence de vitesse entre utiliser la taille et l'adresse maximale. Vérifier les débordements avec la taille demande juste de comparer la taille avec l'adresse logique, avant relocation, ce qui peut être fait en parallèle de la relocation. Par contre, l'adresse limite est comparée à une adresse physique, ce qui demande de faire la relocation avant la vérification, ce qui prend un peu plus de temps. Mais l'impact sur les performances est des plus mineurs.
[[File:Registre limite.png|centre|vignette|upright=2|Registre limite]]
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> </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>+ </code><code style="background:#DDF">0001 0010 0011 0100</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </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. Au lieu d'utiliser un additionneur séparé pour le ''program counter'' et un autre pour le calcul de l'adresse physique, un seul additionneur est utilisé pour les deux. L'idée était de partager l'additionneur, qui servait à la fois à incrémenter le ''program counter'' et pour gérer la segmentation. 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|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> </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>+ </code><code style="background:#DDF">1111 1111 1111 1111</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </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 de nos PCs, à partir du 286 d'Intel. 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. A 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. A la place, les segments sont adressés de manière indirecte. A 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. Le processeur Intel 386 préférait comparer la taille du segment avec le décalage, 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 le faire 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.
====L'implémentation de la protection mémoire sur le 386====
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 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. A 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]]
A 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. A 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. A la place, l'entrée contient un '''bit ''global''''', qui précise que cette correspondance est valide pour tous les processus. Le bit global accélère rapidement la traduction d'adresse pour l'accès au noyau.
Un défaut de cette méthode est que le partage d'une page entre plusieurs processus est presque impossible. Impossible de partager une page avec seulement certains processus et pas d'autres : soit on partage une page avec tous les processus, soit on l'alloue avec un seul processus.
====L'usage de plusieurs tables des pages====
Une solution alternative, plus simple, utilise une table des pages par processus lancé sur l'ordinateur, une table des pages unique par espace d'adressage. À chaque changement de processus, le registre qui mémorise la position de la table des pages est modifié pour pointer sur la bonne. C'est le système d'exploitation qui se charge de cette mise à jour.
Avec cette méthode, il est possible de partager une ou plusieurs pages entre plusieurs processus, en configurant les tables des pages convenablement. Les pages partagées sont mappées dans l'espace d'adressage de plusieurs processus, mais pas forcément au même endroit, pas forcément dans les mêmes adresses logiques. On peut placer la page partagée à l'adresse logique 0x0FFF pour un processus, à l'adresse logique 0xFF00 pour un autre processus, etc. Par contre, les entrées de la table des pages pour ces adresses pointent vers la même adresse physique.
[[File:Vm5.png|centre|vignette|upright=2|Tables des pages de plusieurs processus.]]
===La taille des pages===
La taille des pages varie suivant le processeur et le système d'exploitation et tourne souvent autour de 4 kibioctets. Les processeurs actuels gèrent plusieurs tailles différentes pour les pages : 4 kibioctets par défaut, 2 mébioctets, voire 1 à 4 gibioctets pour les pages les plus larges. Les pages de 4 kibioctets sont les pages par défaut, les autres tailles de page sont appelées des ''pages larges''. La taille optimale pour les pages dépend de nombreux paramètres et il n'y a pas de taille qui convienne à tout le monde. Certaines applications gagnent à utiliser des pages larges, d'autres vont au contraire perdre drastiquement en performance en les utilisant.
Le désavantage principal des pages larges est qu'elles favorisent la fragmentation mémoire. Si un programme veut réserver une portion de mémoire, pour une structure de donnée quelconque, il doit réserver une portion dont la taille est multiple de la taille d'une page. Par exemple, un programme ayant besoin de 110 kibioctets allouera 28 pages de 4 kibioctets, soit 120 kibioctets : 2 kibioctets seront perdus. Par contre, avec des pages larges de 2 mébioctets, on aura une perte de 2048 - 110 = 1938 kibioctets. En somme, des morceaux de mémoire seront perdus, car les pages sont trop grandes pour les données qu'on veut y mettre. Le résultat est que le programme qui utilise les pages larges utilisent plus de mémoire et ce d'autant plus qu'il utilise des données de petite taille. Un autre désavantage est qu'elles se marient mal avec certaines techniques d'optimisations de type ''copy-on-write''.
Mais l'avantage est que la traduction des adresses est plus performante. Une taille des pages plus élevée signifie moins de pages, donc des tables des pages plus petites. Et des pages des tables plus petites n'ont pas besoin de beaucoup de niveaux de hiérarchie, voire peuvent se limiter à des tables des pages simples, ce qui rend la traduction d'adresse plus simple et plus rapide. De plus, les programmes ont une certaine localité spatiale, qui font qu'ils accèdent souvent à des données proches. La traduction d'adresse peut alors profiter de systèmes de mise en cache dont nous parlerons dans le prochain chapitre, et ces systèmes de cache marchent nettement mieux avec des pages larges.
Il faut noter que la taille des pages est presque toujours une puissance de deux. Cela a de nombreux avantages, mais n'est pas une nécessité. Par exemple, le tout premier processeur avec de la pagination, le super-ordinateur Atlas, avait des pages de 3 kibioctets. L'avantage principal est que la traduction de l'adresse physique en adresse logique est trivial avec une puissance de deux. Cela garantit que l'on peut diviser l'adresse en un numéro de page et un ''offset'' : la traduction demande juste de remplacer les bits de poids forts par le numéro de page voulu. Sans cela, la traduction d'adresse implique des divisions et des multiplications, qui sont des opérations assez couteuses.
===Les entrées de la table des pages===
Avant de poursuivre, faisons un rapide rappel sur les entrées de la table des pages. Nous venons de voir que la table des pages contient de nombreuses informations : un bit ''valid'' pour la mémoire virtuelle, des bits ''dirty'' et ''accessed'' utilisés par l'OS, des bits de protection mémoire, un bit ''global'' et un potentiellement un identifiant de processus, etc. Étudions rapidement le format de la table des pages sur un processeur x86 32 bits.
* Elle contient d'abord le numéro de page physique.
* Les bits AVL sont inutilisés et peuvent être configurés à loisir par l'OS.
* Le bit G est le bit ''global''.
* Le bit PS vaut 0 pour une page de 4 kibioctets, mais est mis à 1 pour une page de 4 mébioctets dans le cas où le processus utilise des pages larges.
* Le bit D est le bit ''dirty''.
* Le bit A est le bit ''accessed''.
* Le bit PCD indique que la page ne peut pas être cachée, dans le sens où le processeur ne peut copier son contenu dans le cache et doit toujours lire ou écrire cette page directement dans la RAM.
* Le bit PWT indique que les écritures doivent mettre à jour le cache et la page en RAM (dans le chapitre sur le cache, on verra qu'il force le cache à se comporter comme un cache ''write-through'' pour cette page).
* Le bit U/S précise si la page est accessible en mode noyau ou utilisateur.
* Le bit R/W indique si la page est accessible en écriture, toutes les pages sont par défaut accessibles en lecture.
* Le bit P est le bit ''valid''.
[[File:PDE.png|centre|vignette|upright=2.5|Table des pages des processeurs Intel 32 bits.]]
==Comparaison des différentes techniques d'abstraction mémoire==
Pour résumer, l'abstraction mémoire permet de gérer : la relocation, la protection mémoire, l'isolation des processus, la mémoire virtuelle, l'extension de l'espace d'adressage, le partage de mémoire, etc. Elles sont souvent implémentées en même temps. Ce qui fait qu'elles sont souvent confondues, alors que ce sont des concepts sont différents. Ces liens sont résumés dans le tableau ci-dessous.
{|class="wikitable"
|-
!
! colspan="5" | Avec abstraction mémoire
! rowspan="2" | Sans abstraction mémoire
|-
!
! Relocation matérielle
! Segmentation en mode réel (x86)
! Segmentation, général
! Architectures à capacités
! Pagination
|-
! Abstraction matérielle des processus
| colspan="4" | Oui, relocation matérielle
| Oui, liée à la traduction d'adresse
| Impossible
|-
! Mémoire virtuelle
| colspan="2" | Non, sauf émulation logicielle
| colspan="3" | Oui, gérée par le processeur et l'OS
| Non, sauf émulation logicielle
|-
! Extension de l'espace d'adressage
| colspan="2" | Oui : registre de base élargi
| colspan="2" | Oui : adresse de base élargie dans la table des segments
| ''Physical Adress Extension'' des processeurs 32 bits
| Commutation de banques
|-
! Protection mémoire
| Registre limite
| Aucune
| colspan="2" | Registre limite, droits d'accès aux segments
| Gestion des droits d'accès aux pages
| Possible, méthodes variées
|-
! Partage de mémoire
| colspan="2" | Non
| colspan="2" | Segment partagés
| Pages partagées
| Possible, méthodes variées
|}
===Les différents types de segmentation===
La segmentation regroupe plusieurs techniques franchement différentes, qui auraient gagné à être nommées différemment. La principale différence est l'usage de registres de relocation versus des registres de sélecteurs de segments. L'usage de registres de relocation est le fait de la relocation matérielle, mais aussi de la segmentation en mode réel des CPU x86. Par contre, l'usage de sélecteurs de segments est le fait des autres formes de segmentation, architectures à capacité inclues.
La différence entre les deux est le nombre de segments. L'usage de registres de relocation fait que le CPU ne gère qu'un petit nombre de segments de grande taille. La mémoire virtuelle est donc rarement implémentée vu que swapper des segments de grande taille est trop long, l'impact sur les performances est trop important. Sans compter que l'usage de registres de base se marie très mal avec la mémoire virtuelle. Vu qu'un segment peut être swappé ou déplacée n'importe quand, il faut invalider les registres de base au moment du swap/déplacement, ce qui n'est pas chose aisée. Aucun processeur ne gère cela, les méthodes pour n'existent tout simplement pas. L'usage de registres de base implique que la mémoire virtuelle est absente.
La protection mémoire est aussi plus limitée avec l'usage de registres de relocation. Elle se limite à des registres limite, mais la gestion des droits d'accès est limitée. En théorie, la segmentation en mode réel pourrait implémenter une version limitée de protection mémoire, avec une protection de l'espace exécutable. Mais ca n'a jamais été fait en pratique sur les processeurs x86.
Le partage de la mémoire est aussi difficile sur les architectures avec des registres de base. L'absence de table des segments fait que le partage d'un segment est basiquement impossible sans utiliser des méthodes complétement tordues, qui ne sont jamais implémentées en pratique.
===Segmentation versus pagination===
Par rapport à la pagination, la segmentation a des avantages et des inconvénients. Tous sont liés aux propriétés des segments et pages : les segments sont de grande taille et de taille variable, les pages sont petites et de taille fixe.
L'avantage principal de la segmentation est sa rapidité. Le fait que les segments sont de grande taille fait qu'on a pas besoin d'équivalent aux tables des pages inversée ou multiple, juste d'une table des segments toute simple. De plus, les échanges entre table des pages/segments et registres sont plus rares avec la segmentation. Par exemple, si un programme utilise un segment de 2 gigas, tous les accès dans le segment se feront avec une seule consultation de la table des segments. Alors qu'avec la pagination, il faudra une consultation de la table des pages chaque bloc de 4 kibioctet, au minimum.
Mais les désavantages sont nombreux. Le système d'exploitation doit agencer les segments en RAM, et c'est une tâche complexe. Le fait que les segments puisse changer de taille rend le tout encore plus complexe. Par exemple, si on colle les segments les uns à la suite des autres, changer la taille d'un segment demande de réorganiser tous les segments en RAM, ce qui demande énormément de copies RAM-RAM. Une autre possibilité est de laisser assez d'espace entre les segments, mais cet espace est alors gâché, dans le sens où on ne peut pas y placer un nouveau segment.
Swapper un segment est aussi très long, vu que les segments sont de grande taille, alors que swapper une page est très rapide.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'espace d'adressage du processeur
| prevText=L'espace d'adressage du processeur
| next=Les méthodes de synchronisation entre processeur et périphériques
| nextText=Les méthodes de synchronisation entre processeur et périphériques
}}
</noinclude>
of1we3rmh47janhx9nvb2832vhrllgn
763705
763704
2026-04-15T01:01:11Z
Mewtow
31375
/* La protection mémoire : les accès hors-segments */
763705
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 envoyées à 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 sachez qu'il existe 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 les adresses de l'espace d'adressage sont 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'abstraction mémoire implémente plusieurs fonctionnalités complémentaires==
L'utilité de l'abstraction matérielle n'est pas évidente, mais sachez qu'elle est si utile que tous les processeurs modernes la prennent en charge. Elle sert notamment à implémenter la mémoire virtuelle, que nous aborderons dans ce qui suit. La plupart de ces fonctionnalités manipulent la relation entre adresses logiques et physique. Dans le cas le plus simple, une adresse logique correspond à une seule adresse physique. Mais beaucoup de fonctionnalités avancées ne respectent pas cette règle.
===L'abstraction matérielle des processus===
Les systèmes d'exploitation modernes sont dits multi-tâche, à savoir qu'ils sont capables d'exécuter plusieurs logiciels en même temps. Et ce même si un seul processeur est présent dans l'ordinateur : les logiciels sont alors exécutés à tour de rôle. Toutefois, cela amène un paquet de problèmes qu'il faut résoudre au mieux. Par exemple, les programmes exécutés doivent se partager la mémoire RAM, ce qui ne vient pas sans problèmes. Le problème principal est que les programmes ne doivent pas lire ou écrire dans les données d'un autre, sans quoi on se retrouverait rapidement avec des problèmes. Il faut donc introduire des mécanismes d{{'}}'''isolement des processus''', pour isoler les programmes les uns des autres.
Un de ces mécanismes est l{{'}}'''abstraction matérielle des processus''', une technique qui fait que chaque programme a son propre espace d'adressage. Chaque programme a l'impression d'avoir accès à tout l'espace d'adressage, de l'adresse 0 à l'adresse maximale gérée par le processeur. Évidemment, il s'agit d'une illusion maintenue justement grâce à la traduction d'adresse. Les espaces d'adressage contiennent des adresses logiques, les adresses de la RAM sont des adresses physiques, la nécessité de l'abstraction mémoire est évidente.
Implémenter l'abstraction mémoire peut se faire de plusieurs manières. Mais dans tous les cas, il faut que la correspondance adresse logique - physique change d'un programme à l'autre. Ce qui est normal, vu que les deux processus sont placés à des endroits différents en RAM physique. La conséquence est qu'avec l'abstraction mémoire, une adresse logique correspond à plusieurs adresses physiques. Une même adresse logique dans deux processus différents correspond à deux adresses phsiques différentes, une par processus. Une adresse logique dans un processus correspondra à l'adresse physique X, la même adresse dans un autre processus correspondra à l'adresse Y.
Les adresses physiques qui partagent la même adresse logique sont alors appelées des '''adresses homonymes'''. Le choix de la bonne adresse étant réalisé par un mécanisme matériel et dépend du programme en cours. Le mécanisme pour choisir la bonne adresse dépend du processeur, mais il y en a deux grands types :
* La première consiste à utiliser l'identifiant de processus CPU, vu au chapitre précédent. C'est, pour rappel, un numéro attribué à chaque processus par le processeur. L'identifiant du processus en cours d'exécution est mémorisé dans un registre du processeur. La traduction d'adresse utilise cet identifiant, en plus de l'adresse logique, pour déterminer l'adresse physique.
* La seconde solution mémorise les correspondances adresses logiques-physique dans des tables en mémoire RAM, qui sont différentes pour chaque programme. Les tables sont accédées à chaque accès mémoire, afin de déterminer l'adresse physique.
===Le partage de la mémoire===
L'isolation des processus est très importante sur les systèmes d'exploitation modernes. Cependant, il existe quelques situations où elle doit être contournée ou du moins mise en pause. Les situations sont multiples : gestion de bibliothèques partagées, communication entre processus, usage de ''threads'', etc. Elles impliquent toutes un '''partage de mémoire''', à savoir qu'une portion de mémoire RAM est partagée entre plusieurs programmes. Le partage de mémoire est une sorte de brèche de l'isolation des processus, mais qui est autorisée car elle est utile.
Un cas intéressant est celui des '''bibliothèques partagées'''. Les bibliothèques sont des collections de fonctions regroupées ensemble, dans une seule unité de code. Un programme qui utilise une bibliothèque peut appeler n’importe quelle fonction présente dans la bibliothèque. La bibliothèque peut être simplement inclue dans le programme lui-même, on parle alors de bibliothèques statiques. De telles bibliothèques fonctionnent très bien, mais avec un petit défaut pour les bibliothèques très utilisées : plusieurs programmes qui utilisent la même bibliothèque vont chacun l'inclure dans leur code, ce qui fera doublon.
Pour éviter cela, les OS modernes gèrent des bibliothèques partagées, à savoir qu'un seul exemplaire de la bibliothèque est partagé entre plusieurs programmes. Chaque programme peut exécuter une fonction de la bibliothèque quand il le souhaite, en effectuant un branchement adéquat. Mais cela implique que la bibliothèque soit présente dans l'espace d'adressage du programme en question. Une bibliothèque est donc présente dans plusieurs espaces d'adressage, alors qu'il n'y en a qu'un seul exemplaire en mémoire RAM.
[[File:Ogg vorbis libs and application dia.svg|centre|vignette|upright=2|Exemple de bibliothèques, avec Ogg vorbis.]]
D'autres situations demandent de partager de la mémoire entre deux programmes. Par exemple, les systèmes d'exploitation modernes gèrent nativement des systèmes de '''communication inter-processus''', très utilisés par les programmes modernes pour échanger des données. Et la plupart demandant de partager un bout de mémoire entre processus, même si c'est seulement temporairement. Typiquement, deux processus partagent un intervalle d'adresse où l'un écrit les données à l'autre, l'autre lisant les données envoyées.
Une dernière utilisation de la mémoire partagée est l{{'}}'''accès direct au noyau'''. Sur les systèmes d'exploitations moderne, dans l'espace d'adressage de chaque programme, les adresses hautes sont remplies avec une partie du noyau ! Évidemment, ces adresses sont accessibles uniquement en lecture, pas en écriture. Pas question de modifier le noyau de l'OS ! De plus, il s'agit d'une portion du noyau dont on sait que la consultation ne pose pas de problèmes de sécurité.
Le programme peut lire des données dans cette portion du noyau, mais aussi exécuter les fonctions du noyau qui sont dedans. L'idée est d'éviter des appels systèmes trop fréquents. Au lieu d'effectuer un véritable appel système, avec une interruption logicielle, le programme peut exécuter des appels systèmes simplifiés, de simples appels de fonctions couplés avec un changement de niveau de privilège (passage en espace noyau nécessaire).
[[File:AMD64-canonical--48-bit.png|vignette|Répartition des adresses entre noyau (jaune/orange) et programme (verte), sur les systèmes x86-64 bits, avec des adresses physiques de 48 bits.]]
L'espace d'adressage est donc séparé en deux portions : l'OS d'un côté, le programme de l'autre. La répartition des adresses entre noyau et programme varie suivant l'OS ou le processeur utilisé. Sur les PC x86 32 bits, Linux attribuait 3 gigas pour les programmes et 1 giga pour le noyau, Windows attribuait 2 gigas à chacun. Sur les systèmes x86 64 bits, l'espace d'adressage d'un programme est coupé en trois, comme illustré ci-contre : une partie basse de 2^48 octets, une partie haute de même taille, et un bloc d'adresses invalides entre les deux. Les adresses basses sont utilisées pour le programme, les adresses hautes pour le noyau, il n'y a rien entre les deux.
Avec le partage de mémoire, plusieurs adresses logiques correspondent à la même adresse physique. Tel processus verra la zone de mémoire partagée à l'adresse X, l'autre la verra à l'adresse Y. Mais il s'agira de la même portion de mémoire physique, avec une seule adresse physique. En clair, lorsque deux processus partagent une même zone de mémoire, la zone sera mappées à des adresses logiques différentes. Les adresses logiques sont alors appelées des '''adresses synonymes''', terme qui trahit le fait qu'elles correspondent à la même adresse physique.
===La mémoire virtuelle===
Toutes les adresses ne sont pas forcément occupées par de la mémoire RAM, s'il n'y a pas assez de RAM installée. Par exemple, un processeur 32 bits peut adresser 4 gibioctets de RAM, même si seulement 3 gibioctets sont installés dans l'ordinateur. L'espace d'adressage contient donc 1 gigas d'adresses inutilisées, et il faut éviter ce surplus d'adresses pose problème.
Sans mémoire virtuelle, seule la mémoire réellement installée est utilisable. Si un programme utilise trop de mémoire, il est censé se rendre compte qu'il n'a pas accès à tout l'espace d'adressage. Quand il demandera au système d'exploitation de lui réserver de la mémoire, le système d'exploitation le préviendra qu'il n'y a plus de mémoire libre. Par exemple, si un programme tente d'utiliser 4 gibioctets sur un ordinateur avec 3 gibioctets de mémoire, il ne pourra pas. Pareil s'il veut utiliser 2 gibioctets de mémoire sur un ordinateur avec 4 gibioctets, mais dont 3 gibioctets sont déjà utilisés par d'autres programmes. Dans les deux cas, l'illusion tombe à plat.
Les techniques de '''mémoire virtuelle''' font que l'espace d'adressage est utilisable au complet, même s'il n'y a pas assez de mémoire installée dans l'ordinateur ou que d'autres programmes utilisent de la RAM. Par exemple, sur un processeur 32 bits, le programme aura accès à 4 gibioctets de RAM, même si d'autres programmes utilisent la RAM, même s'il n'y a que 2 gibioctets de RAM d'installés dans l'ordinateur.
Pour cela, on utilise une partie des mémoires de masse (disques durs) d'un ordinateur en remplacement de la mémoire physique manquante. Le système d'exploitation crée sur le disque dur un fichier, appelé le ''swapfile'' ou '''fichier de ''swap''''', qui est utilisé comme mémoire RAM supplémentaire. Il mémorise le surplus de données et de programmes qui ne peut pas être mis en mémoire RAM.
[[File:Vm1.png|centre|vignette|upright=2.0|Mémoire virtuelle et fichier de Swap.]]
Une technique naïve de mémoire virtuelle serait la suivante. Avant de l'aborder, précisons qu'il s'agit d'une technique abordée à but pédagogique, mais qui n'est implémentée nulle part tellement elle est lente et inefficace. Un espace d'adressage de 4 gigas ne contient que 3 gigas de RAM, ce qui fait 1 giga d'adresses inutilisées. Les accès mémoire aux 3 gigas de RAM se font normalement, mais l'accès aux adresses inutilisées lève une exception matérielle "Memory Unavailable". La routine d'interruption de cette exception accède alors au ''swapfile'' et récupère les données associées à cette adresse. La mémoire virtuelle est alors émulée par le système d'exploitation.
Le défaut de cette méthode est que l'accès au giga manquant est toujours très lent, parce qu'il se fait depuis le disque dur. D'autres techniques de mémoire virtuelle logicielle font beaucoup mieux, mais nous allons les passer sous silence, vu qu'on peut faire mieux, avec l'aide du matériel.
L'idée est de charger les données dont le programme a besoin dans la RAM, et de déplacer les autres sur le disque dur. Par exemple, imaginons la situation suivante : un programme a besoin de 4 gigas de mémoire, mais ne dispose que de 2 gigas de mémoire installée. On peut imaginer découper l'espace d'adressage en 2 blocs de 2 gigas, qui sont chargés à la demande. Si le programme accède aux adresses basses, on charge les 2 gigas d'adresse basse en RAM. S'il accède aux adresses hautes, on charge les 2 gigas d'adresse haute dans la RAM après avoir copié les adresses basses sur le ''swapfile''.
On perd du temps dans les copies de données entre RAM et ''swapfile'', mais on gagne en performance vu que tous les accès mémoire se font en RAM. Du fait de la localité temporelle, le programme utilise les données chargées depuis le swapfile durant un bon moment avant de passer au bloc suivant. La RAM est alors utilisée comme une sorte de cache alors que les données sont placées dans une mémoire fictive représentée par l'espace d'adressage et qui correspond au disque dur.
Mais avec cette technique, la correspondance entre adresses du programme et adresses de la RAM change au cours du temps. Les adresses de la RAM correspondent d'abord aux adresses basses, puis aux adresses hautes, et ainsi de suite. On a donc besoin d'abstraction mémoire. Les correspondances entre adresse logique et physique peuvent varier avec le temps, ce qui permet de déplacer des données de la RAM vers le disque dur ou inversement. Une adresse logique peut correspondre à une adresse physique, ou bien à une donnée swappée sur le disque dur. C'est l'unité de traduction d'adresse qui se charge de faire la différence. Si une correspondance entre adresse logique et physique est trouvée, elle l'utilise pour traduire les adresses. Si aucune correspondance n'est trouvée, alors elle laisse la main au système d'exploitation pour charger la donnée en RAM. Une fois la donnée chargée en RAM, les correspondances entre adresse logique et physiques sont modifiées de manière à ce que l'adresse logique pointe vers la donnée chargée.
===L'extension d'adressage===
Une autre fonctionnalité rendue possible par l'abstraction mémoire est l{{'}}'''extension d'adressage'''. Elle permet d'utiliser plus de mémoire que l'espace d'adressage ne le permet. Par exemple, utiliser 7 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'extension d'adresse est l'exact inverse de la mémoire virtuelle. La mémoire virtuelle sert quand on a moins de mémoire que d'adresses, l'extension d'adresse sert quand on a plus de mémoire que d'adresses.
Il y a quelques chapitres, nous avions vu que c'est possible via la commutation de banques. Mais l'abstraction mémoire est une méthode alternative. Que ce soit avec la commutation de banques ou avec l'abstraction mémoire, les adresses envoyées à la mémoire doivent être plus longues que les adresses gérées par le processeur. La différence est que l'abstraction mémoire étend les adresses d'une manière différente.
Une implémentation possible de l'extension d'adressage fait usage de l'abstraction matérielle des processus. Chaque processus a son propre espace d'adressage, mais ceux-ci sont placés à des endroits différents dans la mémoire physique. Par exemple, sur un ordinateur avec 16 gigas de RAM, mais un espace d'adressage de 2 gigas, on peut remplir la RAM en lançant 8 processus différents et chaque processus aura accès à un bloc de 2 gigas de RAM, pas plus, il ne peut pas dépasser cette limite. Ainsi, chaque processus est limité par son espace d'adressage, mais on remplit la mémoire avec plusieurs processus, ce qui compense. Il s'agit là de l'implémentation la plus simple, qui a en plus l'avantage d'avoir la meilleure compatibilité logicielle. De simples changements dans le système d'exploitation suffisent à l'implémenter.
[[File:Extension de l'espace d'adressage.png|centre|vignette|upright=1.5|Extension de l'espace d'adressage]]
Un autre implémentation donne plusieurs espaces d'adressage différents à chaque processus, et a donc accès à autant de mémoire que permis par la somme de ces espaces d'adressage. Par exemple, sur un ordinateur avec 16 gigas de RAM et un espace d'adressage de 4 gigas, un programme peut utiliser toute la RAM en utilisant 4 espaces d'adressage distincts. On passe d'un espace d'adressage à l'autre en changeant la correspondance adresse logique-physique. L'inconvénient est que la compatibilité logicielle est assez mauvaise. Modifier l'OS ne suffit pas, les programmeurs doivent impérativement concevoir leurs programmes pour qu'ils utilisent explicitement plusieurs espaces d'adressage.
Les deux implémentations font usage des adresses logiques homonymes, mais à l'intérieur d'un même processus. Pour rappel, cela veut dire qu'une adresse logique correspond à des adresses physiques différentes. Rien d'étonnant vu qu'on utilise plusieurs espaces d'adressage, comme pour l'abstraction des processus, sauf que cette fois-ci, on a plusieurs espaces d'adressage par processus. Prenons l'exemple où on a 8 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'idée est qu'une adresse correspondra à une adresse dans les premiers 4 gigas, ou dans les seconds 4 gigas. L'adresse logique X correspondra d'abord à une adresse physique dans les premiers 4 gigas, puis à une adresse physique dans les seconds 4 gigas.
===La protection mémoire===
La '''protection mémoire''' regroupe des techniques très différentes les unes des autres, qui visent à améliorer la sécurité des programmes et des systèmes d'exploitation. Elles visent à empêcher de lire, d'écrire ou d'exécuter certaines portions de mémoire. Sans elle, les programmes peuvent techniquement lire ou écrire les données des autres, ce qui causent des situations non-prévues par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses.
La première technique de protection mémoire est l{{'}}'''isolation des processus''', qu'on a vue plus haut. Elle garantit que chaque programme n'a accès qu'à certaines portions dédiées de la mémoire et rend le reste de la mémoire inaccessible en lecture et en écriture. Le système d'exploitation attribue à chaque programme une ou plusieurs portions de mémoire rien que pour lui, auquel aucun autre programme ne peut accéder. Un tel programme, isolé des autres, s'appelle un '''processus''', d'où le nom de cet objectif. Toute tentative d'accès à une partie de la mémoire non autorisée déclenche une exception matérielle (rappelez-vous le chapitre sur les interruptions) qui est traitée par une routine du système d'exploitation. Généralement, le programme fautif est sauvagement arrêté et un message d'erreur est affiché à l'écran.
La '''protection de l'espace exécutable''' empêche d’exécuter quoique ce soit provenant de certaines zones de la mémoire. En effet, certaines portions de la mémoire sont censées contenir uniquement des données, sans aucun programme ou code exécutable. Cependant, des virus informatiques peuvent se cacher dedans et d’exécuter depuis celles-ci. Ou encore, des failles de sécurités peuvent permettre à un attaquant d'injecter du code exécutable malicieux dans des données, ce qui peut lui permettre de lire les données manipulées par un programme, prendre le contrôle de la machine, injecter des virus, ou autre. Pour éviter cela, le système d'exploitation peut marquer certaines zones mémoire comme n'étant pas exécutable. Toute tentative d’exécuter du code localisé dans ces zones entraîne la levée d'une exception ou d'une erreur et le système d'exploitation réagit en conséquence. Là encore, le processeur doit détecter les exécutions non autorisées.
D'autres méthodes de protection mémoire visent à limiter des actions dangereuses. Pour cela, le processeur et l'OS gèrent des '''droits d'accès''', qui interdisent certaines actions pour des programmes non-autorisés. Lorsqu'on exécute une opération interdite, le système d’exploitation et/ou le processeur réagissent en conséquence. La première technique de ce genre n'est autre que la séparation entre espace noyau et utilisateur, vue dans le chapitre sur les interruptions. Mais il y en a d'autres, comme nous le verrons dans ce chapitre.
==La MMU==
La traduction des adresses logiques en adresses physiques se fait par un circuit spécialisé appelé la '''''Memory Management Unit''''' (MMU), qui est souvent intégré directement dans l'interface mémoire. La MMU est souvent associée à une ou plusieurs mémoires caches, qui visent à accélérer la traduction d'adresses logiques en adresses physiques. En effet, nous verrons plus bas que la traduction d'adresse demande d'accéder à des tableaux, gérés par le système d'exploitation, qui sont en mémoire RAM. Aussi, les processeurs modernes incorporent des mémoires caches appelées des '''''Translation Lookaside Buffers''''', ou encore TLB. Nous nous pouvons pas parler des TLB pour le moment, car nous n'avons pas encore abordé le chapitre sur les mémoires caches, mais un chapitre entier sera dédié aux TLB d'ici peu.
[[File:MMU principle updated.png|centre|vignette|upright=2|MMU.]]
===Les MMU intégrées au processeur===
D'ordinaire, la MMU est intégrée au processeur. Et elle peut l'être de deux manières. La première en fait un circuit séparé, relié au bus d'adresse. La seconde fusionne la MMU avec l'unité de calcul d'adresse. La première solution est surtout utilisée avec une technique d'abstraction mémoire appelée la pagination, alors que l'autre l'est avec une autre méthode appelée la segmentation. La raison est que la traduction d'adresse avec la segmentation est assez simple : elle demande d'additionner le contenu d'un registre avec l'adresse logique, ce qui est le genre de calcul qu'une unité de calcul d'adresse sait déjà faire. La fusion est donc assez évidente.
Pour donner un exemple, l'Intel 8086 fusionnait l'unité de calcul d'adresse et la MMU. Précisément, il utilisait un même additionneur pour incrémenter le ''program counter'' et effectuer des calculs d'adresse liés à la segmentation. Il aurait été logique d'ajouter les pointeurs de pile avec, mais ce n'était pas possible. La raison est que le pointeur de pile ne peut pas être envoyé directement sur le bus d'adresse, vu qu'il doit passer par une phase de traduction en adresse physique liée à la segmentation.
[[File:80186 arch.png|centre|vignette|upright=2|Intel 8086, microarchitecture.]]
===Les MMU séparées du processeur, sur la carte mère===
Il a existé des processeurs avec une MMU externe, soudée sur la carte mère.
Par exemple, les processeurs Motorola 68000 et 68010 pouvaient être combinés avec une MMU de type Motorola 68451. Elle supportait des versions simplifiées de la segmentation et de la pagination. Au minimum, elle ajoutait un support de la protection mémoire contre certains accès non-autorisés. La gestion de la mémoire virtuelle proprement dit n'était possible que si le processeur utilisé était un Motorola 68010, en raison de la manière dont le 68000 gérait ses accès mémoire. La MMU 68451 gérait un espace d'adressage de 16 mébioctets, découpé en maximum 32 pages/segments. On pouvait dépasser cette limite de 32 segments/pages en combinant plusieurs 68451.
Le Motorola 68851 était une MMU qui était prévue pour fonctionner de paire avec le Motorola 68020. Elle gérait la pagination pour un espace d'adressage de 32 bits.
Les processeurs suivants, les 68030, 68040, et 68060, avaient une MMU interne au processeur.
==La relocation matérielle==
Pour rappel, les systèmes d'exploitation moderne permettent de lancer plusieurs programmes en même temps et les laissent se partager la mémoire. Dans le cas le plus simple, qui n'est pas celui des OS modernes, le système d'exploitation découpe la mémoire en blocs d'adresses contiguës qui sont appelés des '''segments''', ou encore des ''partitions mémoire''. Les segments correspondent à un bloc de mémoire RAM. C'est-à-dire qu'un segment de 259 mébioctets sera un segment continu de 259 mébioctets dans la mémoire physique comme dans la mémoire logique. Dans ce qui suit, un segment contient un programme en cours d'exécution, comme illustré ci-dessous.
[[File:CPT Memory Addressable.svg|centre|vignette|upright=2|Espace d'adressage segmenté.]]
Le système d'exploitation mémorise la position de chaque segment en mémoire, ainsi que d'autres informations annexes. Le tout est regroupé dans la '''table de segment''', un tableau dont chaque case est attribuée à un programme/segment. La table des segments est un tableau numéroté, chaque segment ayant un numéro qui précise sa position dans le tableau. Chaque case, chaque entrée, contient un '''descripteur de segment''' qui regroupe plusieurs informations sur le segment : son adresse de base, sa taille, diverses informations.
===La relocation avec la relocation matérielle : le registre de base===
Un segment peut être placé n'importe où en RAM physique et sa position en RAM change à chaque exécution. Le programme est chargé à une adresse, celle du début du segment, qui change à chaque chargement du programme. Et toutes les adresses utilisées par le programme doivent être corrigées lors du chargement du programme, généralement par l'OS. Cette correction s'appelle la '''relocation''', et elle consiste à ajouter l'adresse de début du segment à chaque adresse manipulée par le programme.
[[File:Relocation assistée par matériel.png|centre|vignette|upright=2.5|Relocation.]]
La relocation matérielle fait que la relocation est faite par le processeur, pas par l'OS. La relocation est intégrée dans le processeur par l'intégration d'un registre : le '''registre de base''', aussi appelé '''registre de relocation'''. Il mémorise l'adresse à laquelle commence le segment, la première adresse du programme. Pour effectuer la relocation, le processeur ajoute automatiquement l'adresse de base à chaque accès mémoire, en allant la chercher dans le registre de relocation.
[[File:Registre de base de segment.png|centre|vignette|upright=2|Registre de base de segment.]]
Le processeur s'occupe de la relocation des segments et le programme compilé n'en voit rien. Pour le dire autrement, les programmes manipulent des adresses logiques, qui sont traduites par le processeur en adresses physiques. La traduction se fait en ajoutant le contenu du registre de relocation à l'adresse logique. De plus, cette méthode fait que chaque programme a son propre espace d'adressage.
[[File:CPU created logical address presentation.png|centre|vignette|upright=2|Traduction d'adresse avec la relocation matérielle.]]
Le système d'exploitation mémorise les adresses de base pour chaque programme, dans la table des segments. Le registre de base est mis à jour automatiquement lors de chaque changement de segment. Pour cela, le registre de base est accessible via certaines instructions, accessibles en espace noyau, plus rarement en espace utilisateur. Le registre de segment est censé être adressé implicitement, vu qu'il est unique. Si ce n'est pas le cas, il est possible d'écrire dans ce registre de segment, qui est alors adressable.
===La protection mémoire avec la relocation matérielle : le registre limite===
Sans restrictions supplémentaires, la taille maximale d'un segment est égale à la taille complète de l'espace d'adressage. Sur les processeurs 32 bits, un segment a une taille maximale de 2^32 octets, soit 4 gibioctets. Mais il est possible de limiter la taille du segment à 2 gibioctets, 1 gibioctet, 64 Kibioctets, ou toute autre taille. La limite est définie lors de la création du segment, mais elle peut cependant évoluer au cours de l'exécution du programme, grâce à l'allocation mémoire.
Le processeur vérifie à chaque accès mémoire que celui-ci se fait bien dans le segment, qu'il ne déborde pas en-dehors. C'est possible qu'une adresse calculée sorte du segment, à la suite d'un bug ou d'une erreur de programmation, voire pire. Et le processeur doit éviter de tels '''débordements de segments'''. Pour cela, le processeur compare l'adresse accédée et vérifie qu'elle est bien dans le segment.
Faire cette vérification demande soit de mémoriser la taille du segment, soit de mémoriser l'adresse limite (l'adresse de fin de segment, l'adresse limite à ne pas dépasser). Les deux sont possibles et marchent parfaitement, le choix entre les deux solutions est une pure question de préférence. A la rigueur, utiliser la taille limite permet quelques optimisations, comme tester les débordements en parallèle du calcul de l'adresse.
En utilisant l'adresse limite, on fait la relocation, et on compare l'adresse calculée avec l'adresse limite. Le calcul d'adresse doit se faire avant la vérification. Mais en utilisant la taille, on peut comparer l'adresse logique avec la taille du segment. Précisons que l'adresse logique est celle avant relocation, celle qui indique la position de la donnée dans le segment, celle obtenue quand on considère que le segment commence à l'adresse zéro. On peut alors faire le test de débordement avant ou pendant la relocation. Quelques processeurs en ont profité, mais on verra cela dans la section sur la segmentation.
Pour cela, la table des segments doit être modifiée. Au lieu de ne contenir que l'adresse de base, elle contient soit l'adresse maximale du segment, soit la taille du segment. En clair, le descripteur de segment est enrichi avec l'adresse limite. 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.
Techniquement, il y a une petite différence de vitesse entre utiliser la taille et l'adresse maximale. Vérifier les débordements avec la taille demande juste de comparer la taille avec l'adresse logique, avant relocation, ce qui peut être fait en parallèle de la relocation. Par contre, l'adresse limite est comparée à une adresse physique, ce qui demande de faire la relocation avant la vérification, ce qui prend un peu plus de temps. Mais l'impact sur les performances est des plus mineurs.
[[File:Registre limite.png|centre|vignette|upright=2|Registre limite]]
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> </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>+ </code><code style="background:#DDF">0001 0010 0011 0100</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </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. Au lieu d'utiliser un additionneur séparé pour le ''program counter'' et un autre pour le calcul de l'adresse physique, un seul additionneur est utilisé pour les deux. L'idée était de partager l'additionneur, qui servait à la fois à incrémenter le ''program counter'' et pour gérer la segmentation. 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|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> </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>+ </code><code style="background:#DDF">1111 1111 1111 1111</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </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 de nos PCs, à partir du 286 d'Intel. 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. A 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. A la place, les segments sont adressés de manière indirecte. A 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.
====L'implémentation de la protection mémoire sur le 386====
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 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. A 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]]
A 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. A 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. A la place, l'entrée contient un '''bit ''global''''', qui précise que cette correspondance est valide pour tous les processus. Le bit global accélère rapidement la traduction d'adresse pour l'accès au noyau.
Un défaut de cette méthode est que le partage d'une page entre plusieurs processus est presque impossible. Impossible de partager une page avec seulement certains processus et pas d'autres : soit on partage une page avec tous les processus, soit on l'alloue avec un seul processus.
====L'usage de plusieurs tables des pages====
Une solution alternative, plus simple, utilise une table des pages par processus lancé sur l'ordinateur, une table des pages unique par espace d'adressage. À chaque changement de processus, le registre qui mémorise la position de la table des pages est modifié pour pointer sur la bonne. C'est le système d'exploitation qui se charge de cette mise à jour.
Avec cette méthode, il est possible de partager une ou plusieurs pages entre plusieurs processus, en configurant les tables des pages convenablement. Les pages partagées sont mappées dans l'espace d'adressage de plusieurs processus, mais pas forcément au même endroit, pas forcément dans les mêmes adresses logiques. On peut placer la page partagée à l'adresse logique 0x0FFF pour un processus, à l'adresse logique 0xFF00 pour un autre processus, etc. Par contre, les entrées de la table des pages pour ces adresses pointent vers la même adresse physique.
[[File:Vm5.png|centre|vignette|upright=2|Tables des pages de plusieurs processus.]]
===La taille des pages===
La taille des pages varie suivant le processeur et le système d'exploitation et tourne souvent autour de 4 kibioctets. Les processeurs actuels gèrent plusieurs tailles différentes pour les pages : 4 kibioctets par défaut, 2 mébioctets, voire 1 à 4 gibioctets pour les pages les plus larges. Les pages de 4 kibioctets sont les pages par défaut, les autres tailles de page sont appelées des ''pages larges''. La taille optimale pour les pages dépend de nombreux paramètres et il n'y a pas de taille qui convienne à tout le monde. Certaines applications gagnent à utiliser des pages larges, d'autres vont au contraire perdre drastiquement en performance en les utilisant.
Le désavantage principal des pages larges est qu'elles favorisent la fragmentation mémoire. Si un programme veut réserver une portion de mémoire, pour une structure de donnée quelconque, il doit réserver une portion dont la taille est multiple de la taille d'une page. Par exemple, un programme ayant besoin de 110 kibioctets allouera 28 pages de 4 kibioctets, soit 120 kibioctets : 2 kibioctets seront perdus. Par contre, avec des pages larges de 2 mébioctets, on aura une perte de 2048 - 110 = 1938 kibioctets. En somme, des morceaux de mémoire seront perdus, car les pages sont trop grandes pour les données qu'on veut y mettre. Le résultat est que le programme qui utilise les pages larges utilisent plus de mémoire et ce d'autant plus qu'il utilise des données de petite taille. Un autre désavantage est qu'elles se marient mal avec certaines techniques d'optimisations de type ''copy-on-write''.
Mais l'avantage est que la traduction des adresses est plus performante. Une taille des pages plus élevée signifie moins de pages, donc des tables des pages plus petites. Et des pages des tables plus petites n'ont pas besoin de beaucoup de niveaux de hiérarchie, voire peuvent se limiter à des tables des pages simples, ce qui rend la traduction d'adresse plus simple et plus rapide. De plus, les programmes ont une certaine localité spatiale, qui font qu'ils accèdent souvent à des données proches. La traduction d'adresse peut alors profiter de systèmes de mise en cache dont nous parlerons dans le prochain chapitre, et ces systèmes de cache marchent nettement mieux avec des pages larges.
Il faut noter que la taille des pages est presque toujours une puissance de deux. Cela a de nombreux avantages, mais n'est pas une nécessité. Par exemple, le tout premier processeur avec de la pagination, le super-ordinateur Atlas, avait des pages de 3 kibioctets. L'avantage principal est que la traduction de l'adresse physique en adresse logique est trivial avec une puissance de deux. Cela garantit que l'on peut diviser l'adresse en un numéro de page et un ''offset'' : la traduction demande juste de remplacer les bits de poids forts par le numéro de page voulu. Sans cela, la traduction d'adresse implique des divisions et des multiplications, qui sont des opérations assez couteuses.
===Les entrées de la table des pages===
Avant de poursuivre, faisons un rapide rappel sur les entrées de la table des pages. Nous venons de voir que la table des pages contient de nombreuses informations : un bit ''valid'' pour la mémoire virtuelle, des bits ''dirty'' et ''accessed'' utilisés par l'OS, des bits de protection mémoire, un bit ''global'' et un potentiellement un identifiant de processus, etc. Étudions rapidement le format de la table des pages sur un processeur x86 32 bits.
* Elle contient d'abord le numéro de page physique.
* Les bits AVL sont inutilisés et peuvent être configurés à loisir par l'OS.
* Le bit G est le bit ''global''.
* Le bit PS vaut 0 pour une page de 4 kibioctets, mais est mis à 1 pour une page de 4 mébioctets dans le cas où le processus utilise des pages larges.
* Le bit D est le bit ''dirty''.
* Le bit A est le bit ''accessed''.
* Le bit PCD indique que la page ne peut pas être cachée, dans le sens où le processeur ne peut copier son contenu dans le cache et doit toujours lire ou écrire cette page directement dans la RAM.
* Le bit PWT indique que les écritures doivent mettre à jour le cache et la page en RAM (dans le chapitre sur le cache, on verra qu'il force le cache à se comporter comme un cache ''write-through'' pour cette page).
* Le bit U/S précise si la page est accessible en mode noyau ou utilisateur.
* Le bit R/W indique si la page est accessible en écriture, toutes les pages sont par défaut accessibles en lecture.
* Le bit P est le bit ''valid''.
[[File:PDE.png|centre|vignette|upright=2.5|Table des pages des processeurs Intel 32 bits.]]
==Comparaison des différentes techniques d'abstraction mémoire==
Pour résumer, l'abstraction mémoire permet de gérer : la relocation, la protection mémoire, l'isolation des processus, la mémoire virtuelle, l'extension de l'espace d'adressage, le partage de mémoire, etc. Elles sont souvent implémentées en même temps. Ce qui fait qu'elles sont souvent confondues, alors que ce sont des concepts sont différents. Ces liens sont résumés dans le tableau ci-dessous.
{|class="wikitable"
|-
!
! colspan="5" | Avec abstraction mémoire
! rowspan="2" | Sans abstraction mémoire
|-
!
! Relocation matérielle
! Segmentation en mode réel (x86)
! Segmentation, général
! Architectures à capacités
! Pagination
|-
! Abstraction matérielle des processus
| colspan="4" | Oui, relocation matérielle
| Oui, liée à la traduction d'adresse
| Impossible
|-
! Mémoire virtuelle
| colspan="2" | Non, sauf émulation logicielle
| colspan="3" | Oui, gérée par le processeur et l'OS
| Non, sauf émulation logicielle
|-
! Extension de l'espace d'adressage
| colspan="2" | Oui : registre de base élargi
| colspan="2" | Oui : adresse de base élargie dans la table des segments
| ''Physical Adress Extension'' des processeurs 32 bits
| Commutation de banques
|-
! Protection mémoire
| Registre limite
| Aucune
| colspan="2" | Registre limite, droits d'accès aux segments
| Gestion des droits d'accès aux pages
| Possible, méthodes variées
|-
! Partage de mémoire
| colspan="2" | Non
| colspan="2" | Segment partagés
| Pages partagées
| Possible, méthodes variées
|}
===Les différents types de segmentation===
La segmentation regroupe plusieurs techniques franchement différentes, qui auraient gagné à être nommées différemment. La principale différence est l'usage de registres de relocation versus des registres de sélecteurs de segments. L'usage de registres de relocation est le fait de la relocation matérielle, mais aussi de la segmentation en mode réel des CPU x86. Par contre, l'usage de sélecteurs de segments est le fait des autres formes de segmentation, architectures à capacité inclues.
La différence entre les deux est le nombre de segments. L'usage de registres de relocation fait que le CPU ne gère qu'un petit nombre de segments de grande taille. La mémoire virtuelle est donc rarement implémentée vu que swapper des segments de grande taille est trop long, l'impact sur les performances est trop important. Sans compter que l'usage de registres de base se marie très mal avec la mémoire virtuelle. Vu qu'un segment peut être swappé ou déplacée n'importe quand, il faut invalider les registres de base au moment du swap/déplacement, ce qui n'est pas chose aisée. Aucun processeur ne gère cela, les méthodes pour n'existent tout simplement pas. L'usage de registres de base implique que la mémoire virtuelle est absente.
La protection mémoire est aussi plus limitée avec l'usage de registres de relocation. Elle se limite à des registres limite, mais la gestion des droits d'accès est limitée. En théorie, la segmentation en mode réel pourrait implémenter une version limitée de protection mémoire, avec une protection de l'espace exécutable. Mais ca n'a jamais été fait en pratique sur les processeurs x86.
Le partage de la mémoire est aussi difficile sur les architectures avec des registres de base. L'absence de table des segments fait que le partage d'un segment est basiquement impossible sans utiliser des méthodes complétement tordues, qui ne sont jamais implémentées en pratique.
===Segmentation versus pagination===
Par rapport à la pagination, la segmentation a des avantages et des inconvénients. Tous sont liés aux propriétés des segments et pages : les segments sont de grande taille et de taille variable, les pages sont petites et de taille fixe.
L'avantage principal de la segmentation est sa rapidité. Le fait que les segments sont de grande taille fait qu'on a pas besoin d'équivalent aux tables des pages inversée ou multiple, juste d'une table des segments toute simple. De plus, les échanges entre table des pages/segments et registres sont plus rares avec la segmentation. Par exemple, si un programme utilise un segment de 2 gigas, tous les accès dans le segment se feront avec une seule consultation de la table des segments. Alors qu'avec la pagination, il faudra une consultation de la table des pages chaque bloc de 4 kibioctet, au minimum.
Mais les désavantages sont nombreux. Le système d'exploitation doit agencer les segments en RAM, et c'est une tâche complexe. Le fait que les segments puisse changer de taille rend le tout encore plus complexe. Par exemple, si on colle les segments les uns à la suite des autres, changer la taille d'un segment demande de réorganiser tous les segments en RAM, ce qui demande énormément de copies RAM-RAM. Une autre possibilité est de laisser assez d'espace entre les segments, mais cet espace est alors gâché, dans le sens où on ne peut pas y placer un nouveau segment.
Swapper un segment est aussi très long, vu que les segments sont de grande taille, alors que swapper une page est très rapide.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'espace d'adressage du processeur
| prevText=L'espace d'adressage du processeur
| next=Les méthodes de synchronisation entre processeur et périphériques
| nextText=Les méthodes de synchronisation entre processeur et périphériques
}}
</noinclude>
269y24949gxmp0d26sdu7izys0k9i6d
763706
763705
2026-04-15T01:02:17Z
Mewtow
31375
/* La protection mémoire avec la relocation matérielle : le registre limite */
763706
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 envoyées à 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 sachez qu'il existe 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 les adresses de l'espace d'adressage sont 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'abstraction mémoire implémente plusieurs fonctionnalités complémentaires==
L'utilité de l'abstraction matérielle n'est pas évidente, mais sachez qu'elle est si utile que tous les processeurs modernes la prennent en charge. Elle sert notamment à implémenter la mémoire virtuelle, que nous aborderons dans ce qui suit. La plupart de ces fonctionnalités manipulent la relation entre adresses logiques et physique. Dans le cas le plus simple, une adresse logique correspond à une seule adresse physique. Mais beaucoup de fonctionnalités avancées ne respectent pas cette règle.
===L'abstraction matérielle des processus===
Les systèmes d'exploitation modernes sont dits multi-tâche, à savoir qu'ils sont capables d'exécuter plusieurs logiciels en même temps. Et ce même si un seul processeur est présent dans l'ordinateur : les logiciels sont alors exécutés à tour de rôle. Toutefois, cela amène un paquet de problèmes qu'il faut résoudre au mieux. Par exemple, les programmes exécutés doivent se partager la mémoire RAM, ce qui ne vient pas sans problèmes. Le problème principal est que les programmes ne doivent pas lire ou écrire dans les données d'un autre, sans quoi on se retrouverait rapidement avec des problèmes. Il faut donc introduire des mécanismes d{{'}}'''isolement des processus''', pour isoler les programmes les uns des autres.
Un de ces mécanismes est l{{'}}'''abstraction matérielle des processus''', une technique qui fait que chaque programme a son propre espace d'adressage. Chaque programme a l'impression d'avoir accès à tout l'espace d'adressage, de l'adresse 0 à l'adresse maximale gérée par le processeur. Évidemment, il s'agit d'une illusion maintenue justement grâce à la traduction d'adresse. Les espaces d'adressage contiennent des adresses logiques, les adresses de la RAM sont des adresses physiques, la nécessité de l'abstraction mémoire est évidente.
Implémenter l'abstraction mémoire peut se faire de plusieurs manières. Mais dans tous les cas, il faut que la correspondance adresse logique - physique change d'un programme à l'autre. Ce qui est normal, vu que les deux processus sont placés à des endroits différents en RAM physique. La conséquence est qu'avec l'abstraction mémoire, une adresse logique correspond à plusieurs adresses physiques. Une même adresse logique dans deux processus différents correspond à deux adresses phsiques différentes, une par processus. Une adresse logique dans un processus correspondra à l'adresse physique X, la même adresse dans un autre processus correspondra à l'adresse Y.
Les adresses physiques qui partagent la même adresse logique sont alors appelées des '''adresses homonymes'''. Le choix de la bonne adresse étant réalisé par un mécanisme matériel et dépend du programme en cours. Le mécanisme pour choisir la bonne adresse dépend du processeur, mais il y en a deux grands types :
* La première consiste à utiliser l'identifiant de processus CPU, vu au chapitre précédent. C'est, pour rappel, un numéro attribué à chaque processus par le processeur. L'identifiant du processus en cours d'exécution est mémorisé dans un registre du processeur. La traduction d'adresse utilise cet identifiant, en plus de l'adresse logique, pour déterminer l'adresse physique.
* La seconde solution mémorise les correspondances adresses logiques-physique dans des tables en mémoire RAM, qui sont différentes pour chaque programme. Les tables sont accédées à chaque accès mémoire, afin de déterminer l'adresse physique.
===Le partage de la mémoire===
L'isolation des processus est très importante sur les systèmes d'exploitation modernes. Cependant, il existe quelques situations où elle doit être contournée ou du moins mise en pause. Les situations sont multiples : gestion de bibliothèques partagées, communication entre processus, usage de ''threads'', etc. Elles impliquent toutes un '''partage de mémoire''', à savoir qu'une portion de mémoire RAM est partagée entre plusieurs programmes. Le partage de mémoire est une sorte de brèche de l'isolation des processus, mais qui est autorisée car elle est utile.
Un cas intéressant est celui des '''bibliothèques partagées'''. Les bibliothèques sont des collections de fonctions regroupées ensemble, dans une seule unité de code. Un programme qui utilise une bibliothèque peut appeler n’importe quelle fonction présente dans la bibliothèque. La bibliothèque peut être simplement inclue dans le programme lui-même, on parle alors de bibliothèques statiques. De telles bibliothèques fonctionnent très bien, mais avec un petit défaut pour les bibliothèques très utilisées : plusieurs programmes qui utilisent la même bibliothèque vont chacun l'inclure dans leur code, ce qui fera doublon.
Pour éviter cela, les OS modernes gèrent des bibliothèques partagées, à savoir qu'un seul exemplaire de la bibliothèque est partagé entre plusieurs programmes. Chaque programme peut exécuter une fonction de la bibliothèque quand il le souhaite, en effectuant un branchement adéquat. Mais cela implique que la bibliothèque soit présente dans l'espace d'adressage du programme en question. Une bibliothèque est donc présente dans plusieurs espaces d'adressage, alors qu'il n'y en a qu'un seul exemplaire en mémoire RAM.
[[File:Ogg vorbis libs and application dia.svg|centre|vignette|upright=2|Exemple de bibliothèques, avec Ogg vorbis.]]
D'autres situations demandent de partager de la mémoire entre deux programmes. Par exemple, les systèmes d'exploitation modernes gèrent nativement des systèmes de '''communication inter-processus''', très utilisés par les programmes modernes pour échanger des données. Et la plupart demandant de partager un bout de mémoire entre processus, même si c'est seulement temporairement. Typiquement, deux processus partagent un intervalle d'adresse où l'un écrit les données à l'autre, l'autre lisant les données envoyées.
Une dernière utilisation de la mémoire partagée est l{{'}}'''accès direct au noyau'''. Sur les systèmes d'exploitations moderne, dans l'espace d'adressage de chaque programme, les adresses hautes sont remplies avec une partie du noyau ! Évidemment, ces adresses sont accessibles uniquement en lecture, pas en écriture. Pas question de modifier le noyau de l'OS ! De plus, il s'agit d'une portion du noyau dont on sait que la consultation ne pose pas de problèmes de sécurité.
Le programme peut lire des données dans cette portion du noyau, mais aussi exécuter les fonctions du noyau qui sont dedans. L'idée est d'éviter des appels systèmes trop fréquents. Au lieu d'effectuer un véritable appel système, avec une interruption logicielle, le programme peut exécuter des appels systèmes simplifiés, de simples appels de fonctions couplés avec un changement de niveau de privilège (passage en espace noyau nécessaire).
[[File:AMD64-canonical--48-bit.png|vignette|Répartition des adresses entre noyau (jaune/orange) et programme (verte), sur les systèmes x86-64 bits, avec des adresses physiques de 48 bits.]]
L'espace d'adressage est donc séparé en deux portions : l'OS d'un côté, le programme de l'autre. La répartition des adresses entre noyau et programme varie suivant l'OS ou le processeur utilisé. Sur les PC x86 32 bits, Linux attribuait 3 gigas pour les programmes et 1 giga pour le noyau, Windows attribuait 2 gigas à chacun. Sur les systèmes x86 64 bits, l'espace d'adressage d'un programme est coupé en trois, comme illustré ci-contre : une partie basse de 2^48 octets, une partie haute de même taille, et un bloc d'adresses invalides entre les deux. Les adresses basses sont utilisées pour le programme, les adresses hautes pour le noyau, il n'y a rien entre les deux.
Avec le partage de mémoire, plusieurs adresses logiques correspondent à la même adresse physique. Tel processus verra la zone de mémoire partagée à l'adresse X, l'autre la verra à l'adresse Y. Mais il s'agira de la même portion de mémoire physique, avec une seule adresse physique. En clair, lorsque deux processus partagent une même zone de mémoire, la zone sera mappées à des adresses logiques différentes. Les adresses logiques sont alors appelées des '''adresses synonymes''', terme qui trahit le fait qu'elles correspondent à la même adresse physique.
===La mémoire virtuelle===
Toutes les adresses ne sont pas forcément occupées par de la mémoire RAM, s'il n'y a pas assez de RAM installée. Par exemple, un processeur 32 bits peut adresser 4 gibioctets de RAM, même si seulement 3 gibioctets sont installés dans l'ordinateur. L'espace d'adressage contient donc 1 gigas d'adresses inutilisées, et il faut éviter ce surplus d'adresses pose problème.
Sans mémoire virtuelle, seule la mémoire réellement installée est utilisable. Si un programme utilise trop de mémoire, il est censé se rendre compte qu'il n'a pas accès à tout l'espace d'adressage. Quand il demandera au système d'exploitation de lui réserver de la mémoire, le système d'exploitation le préviendra qu'il n'y a plus de mémoire libre. Par exemple, si un programme tente d'utiliser 4 gibioctets sur un ordinateur avec 3 gibioctets de mémoire, il ne pourra pas. Pareil s'il veut utiliser 2 gibioctets de mémoire sur un ordinateur avec 4 gibioctets, mais dont 3 gibioctets sont déjà utilisés par d'autres programmes. Dans les deux cas, l'illusion tombe à plat.
Les techniques de '''mémoire virtuelle''' font que l'espace d'adressage est utilisable au complet, même s'il n'y a pas assez de mémoire installée dans l'ordinateur ou que d'autres programmes utilisent de la RAM. Par exemple, sur un processeur 32 bits, le programme aura accès à 4 gibioctets de RAM, même si d'autres programmes utilisent la RAM, même s'il n'y a que 2 gibioctets de RAM d'installés dans l'ordinateur.
Pour cela, on utilise une partie des mémoires de masse (disques durs) d'un ordinateur en remplacement de la mémoire physique manquante. Le système d'exploitation crée sur le disque dur un fichier, appelé le ''swapfile'' ou '''fichier de ''swap''''', qui est utilisé comme mémoire RAM supplémentaire. Il mémorise le surplus de données et de programmes qui ne peut pas être mis en mémoire RAM.
[[File:Vm1.png|centre|vignette|upright=2.0|Mémoire virtuelle et fichier de Swap.]]
Une technique naïve de mémoire virtuelle serait la suivante. Avant de l'aborder, précisons qu'il s'agit d'une technique abordée à but pédagogique, mais qui n'est implémentée nulle part tellement elle est lente et inefficace. Un espace d'adressage de 4 gigas ne contient que 3 gigas de RAM, ce qui fait 1 giga d'adresses inutilisées. Les accès mémoire aux 3 gigas de RAM se font normalement, mais l'accès aux adresses inutilisées lève une exception matérielle "Memory Unavailable". La routine d'interruption de cette exception accède alors au ''swapfile'' et récupère les données associées à cette adresse. La mémoire virtuelle est alors émulée par le système d'exploitation.
Le défaut de cette méthode est que l'accès au giga manquant est toujours très lent, parce qu'il se fait depuis le disque dur. D'autres techniques de mémoire virtuelle logicielle font beaucoup mieux, mais nous allons les passer sous silence, vu qu'on peut faire mieux, avec l'aide du matériel.
L'idée est de charger les données dont le programme a besoin dans la RAM, et de déplacer les autres sur le disque dur. Par exemple, imaginons la situation suivante : un programme a besoin de 4 gigas de mémoire, mais ne dispose que de 2 gigas de mémoire installée. On peut imaginer découper l'espace d'adressage en 2 blocs de 2 gigas, qui sont chargés à la demande. Si le programme accède aux adresses basses, on charge les 2 gigas d'adresse basse en RAM. S'il accède aux adresses hautes, on charge les 2 gigas d'adresse haute dans la RAM après avoir copié les adresses basses sur le ''swapfile''.
On perd du temps dans les copies de données entre RAM et ''swapfile'', mais on gagne en performance vu que tous les accès mémoire se font en RAM. Du fait de la localité temporelle, le programme utilise les données chargées depuis le swapfile durant un bon moment avant de passer au bloc suivant. La RAM est alors utilisée comme une sorte de cache alors que les données sont placées dans une mémoire fictive représentée par l'espace d'adressage et qui correspond au disque dur.
Mais avec cette technique, la correspondance entre adresses du programme et adresses de la RAM change au cours du temps. Les adresses de la RAM correspondent d'abord aux adresses basses, puis aux adresses hautes, et ainsi de suite. On a donc besoin d'abstraction mémoire. Les correspondances entre adresse logique et physique peuvent varier avec le temps, ce qui permet de déplacer des données de la RAM vers le disque dur ou inversement. Une adresse logique peut correspondre à une adresse physique, ou bien à une donnée swappée sur le disque dur. C'est l'unité de traduction d'adresse qui se charge de faire la différence. Si une correspondance entre adresse logique et physique est trouvée, elle l'utilise pour traduire les adresses. Si aucune correspondance n'est trouvée, alors elle laisse la main au système d'exploitation pour charger la donnée en RAM. Une fois la donnée chargée en RAM, les correspondances entre adresse logique et physiques sont modifiées de manière à ce que l'adresse logique pointe vers la donnée chargée.
===L'extension d'adressage===
Une autre fonctionnalité rendue possible par l'abstraction mémoire est l{{'}}'''extension d'adressage'''. Elle permet d'utiliser plus de mémoire que l'espace d'adressage ne le permet. Par exemple, utiliser 7 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'extension d'adresse est l'exact inverse de la mémoire virtuelle. La mémoire virtuelle sert quand on a moins de mémoire que d'adresses, l'extension d'adresse sert quand on a plus de mémoire que d'adresses.
Il y a quelques chapitres, nous avions vu que c'est possible via la commutation de banques. Mais l'abstraction mémoire est une méthode alternative. Que ce soit avec la commutation de banques ou avec l'abstraction mémoire, les adresses envoyées à la mémoire doivent être plus longues que les adresses gérées par le processeur. La différence est que l'abstraction mémoire étend les adresses d'une manière différente.
Une implémentation possible de l'extension d'adressage fait usage de l'abstraction matérielle des processus. Chaque processus a son propre espace d'adressage, mais ceux-ci sont placés à des endroits différents dans la mémoire physique. Par exemple, sur un ordinateur avec 16 gigas de RAM, mais un espace d'adressage de 2 gigas, on peut remplir la RAM en lançant 8 processus différents et chaque processus aura accès à un bloc de 2 gigas de RAM, pas plus, il ne peut pas dépasser cette limite. Ainsi, chaque processus est limité par son espace d'adressage, mais on remplit la mémoire avec plusieurs processus, ce qui compense. Il s'agit là de l'implémentation la plus simple, qui a en plus l'avantage d'avoir la meilleure compatibilité logicielle. De simples changements dans le système d'exploitation suffisent à l'implémenter.
[[File:Extension de l'espace d'adressage.png|centre|vignette|upright=1.5|Extension de l'espace d'adressage]]
Un autre implémentation donne plusieurs espaces d'adressage différents à chaque processus, et a donc accès à autant de mémoire que permis par la somme de ces espaces d'adressage. Par exemple, sur un ordinateur avec 16 gigas de RAM et un espace d'adressage de 4 gigas, un programme peut utiliser toute la RAM en utilisant 4 espaces d'adressage distincts. On passe d'un espace d'adressage à l'autre en changeant la correspondance adresse logique-physique. L'inconvénient est que la compatibilité logicielle est assez mauvaise. Modifier l'OS ne suffit pas, les programmeurs doivent impérativement concevoir leurs programmes pour qu'ils utilisent explicitement plusieurs espaces d'adressage.
Les deux implémentations font usage des adresses logiques homonymes, mais à l'intérieur d'un même processus. Pour rappel, cela veut dire qu'une adresse logique correspond à des adresses physiques différentes. Rien d'étonnant vu qu'on utilise plusieurs espaces d'adressage, comme pour l'abstraction des processus, sauf que cette fois-ci, on a plusieurs espaces d'adressage par processus. Prenons l'exemple où on a 8 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'idée est qu'une adresse correspondra à une adresse dans les premiers 4 gigas, ou dans les seconds 4 gigas. L'adresse logique X correspondra d'abord à une adresse physique dans les premiers 4 gigas, puis à une adresse physique dans les seconds 4 gigas.
===La protection mémoire===
La '''protection mémoire''' regroupe des techniques très différentes les unes des autres, qui visent à améliorer la sécurité des programmes et des systèmes d'exploitation. Elles visent à empêcher de lire, d'écrire ou d'exécuter certaines portions de mémoire. Sans elle, les programmes peuvent techniquement lire ou écrire les données des autres, ce qui causent des situations non-prévues par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses.
La première technique de protection mémoire est l{{'}}'''isolation des processus''', qu'on a vue plus haut. Elle garantit que chaque programme n'a accès qu'à certaines portions dédiées de la mémoire et rend le reste de la mémoire inaccessible en lecture et en écriture. Le système d'exploitation attribue à chaque programme une ou plusieurs portions de mémoire rien que pour lui, auquel aucun autre programme ne peut accéder. Un tel programme, isolé des autres, s'appelle un '''processus''', d'où le nom de cet objectif. Toute tentative d'accès à une partie de la mémoire non autorisée déclenche une exception matérielle (rappelez-vous le chapitre sur les interruptions) qui est traitée par une routine du système d'exploitation. Généralement, le programme fautif est sauvagement arrêté et un message d'erreur est affiché à l'écran.
La '''protection de l'espace exécutable''' empêche d’exécuter quoique ce soit provenant de certaines zones de la mémoire. En effet, certaines portions de la mémoire sont censées contenir uniquement des données, sans aucun programme ou code exécutable. Cependant, des virus informatiques peuvent se cacher dedans et d’exécuter depuis celles-ci. Ou encore, des failles de sécurités peuvent permettre à un attaquant d'injecter du code exécutable malicieux dans des données, ce qui peut lui permettre de lire les données manipulées par un programme, prendre le contrôle de la machine, injecter des virus, ou autre. Pour éviter cela, le système d'exploitation peut marquer certaines zones mémoire comme n'étant pas exécutable. Toute tentative d’exécuter du code localisé dans ces zones entraîne la levée d'une exception ou d'une erreur et le système d'exploitation réagit en conséquence. Là encore, le processeur doit détecter les exécutions non autorisées.
D'autres méthodes de protection mémoire visent à limiter des actions dangereuses. Pour cela, le processeur et l'OS gèrent des '''droits d'accès''', qui interdisent certaines actions pour des programmes non-autorisés. Lorsqu'on exécute une opération interdite, le système d’exploitation et/ou le processeur réagissent en conséquence. La première technique de ce genre n'est autre que la séparation entre espace noyau et utilisateur, vue dans le chapitre sur les interruptions. Mais il y en a d'autres, comme nous le verrons dans ce chapitre.
==La MMU==
La traduction des adresses logiques en adresses physiques se fait par un circuit spécialisé appelé la '''''Memory Management Unit''''' (MMU), qui est souvent intégré directement dans l'interface mémoire. La MMU est souvent associée à une ou plusieurs mémoires caches, qui visent à accélérer la traduction d'adresses logiques en adresses physiques. En effet, nous verrons plus bas que la traduction d'adresse demande d'accéder à des tableaux, gérés par le système d'exploitation, qui sont en mémoire RAM. Aussi, les processeurs modernes incorporent des mémoires caches appelées des '''''Translation Lookaside Buffers''''', ou encore TLB. Nous nous pouvons pas parler des TLB pour le moment, car nous n'avons pas encore abordé le chapitre sur les mémoires caches, mais un chapitre entier sera dédié aux TLB d'ici peu.
[[File:MMU principle updated.png|centre|vignette|upright=2|MMU.]]
===Les MMU intégrées au processeur===
D'ordinaire, la MMU est intégrée au processeur. Et elle peut l'être de deux manières. La première en fait un circuit séparé, relié au bus d'adresse. La seconde fusionne la MMU avec l'unité de calcul d'adresse. La première solution est surtout utilisée avec une technique d'abstraction mémoire appelée la pagination, alors que l'autre l'est avec une autre méthode appelée la segmentation. La raison est que la traduction d'adresse avec la segmentation est assez simple : elle demande d'additionner le contenu d'un registre avec l'adresse logique, ce qui est le genre de calcul qu'une unité de calcul d'adresse sait déjà faire. La fusion est donc assez évidente.
Pour donner un exemple, l'Intel 8086 fusionnait l'unité de calcul d'adresse et la MMU. Précisément, il utilisait un même additionneur pour incrémenter le ''program counter'' et effectuer des calculs d'adresse liés à la segmentation. Il aurait été logique d'ajouter les pointeurs de pile avec, mais ce n'était pas possible. La raison est que le pointeur de pile ne peut pas être envoyé directement sur le bus d'adresse, vu qu'il doit passer par une phase de traduction en adresse physique liée à la segmentation.
[[File:80186 arch.png|centre|vignette|upright=2|Intel 8086, microarchitecture.]]
===Les MMU séparées du processeur, sur la carte mère===
Il a existé des processeurs avec une MMU externe, soudée sur la carte mère.
Par exemple, les processeurs Motorola 68000 et 68010 pouvaient être combinés avec une MMU de type Motorola 68451. Elle supportait des versions simplifiées de la segmentation et de la pagination. Au minimum, elle ajoutait un support de la protection mémoire contre certains accès non-autorisés. La gestion de la mémoire virtuelle proprement dit n'était possible que si le processeur utilisé était un Motorola 68010, en raison de la manière dont le 68000 gérait ses accès mémoire. La MMU 68451 gérait un espace d'adressage de 16 mébioctets, découpé en maximum 32 pages/segments. On pouvait dépasser cette limite de 32 segments/pages en combinant plusieurs 68451.
Le Motorola 68851 était une MMU qui était prévue pour fonctionner de paire avec le Motorola 68020. Elle gérait la pagination pour un espace d'adressage de 32 bits.
Les processeurs suivants, les 68030, 68040, et 68060, avaient une MMU interne au processeur.
==La relocation matérielle==
Pour rappel, les systèmes d'exploitation moderne permettent de lancer plusieurs programmes en même temps et les laissent se partager la mémoire. Dans le cas le plus simple, qui n'est pas celui des OS modernes, le système d'exploitation découpe la mémoire en blocs d'adresses contiguës qui sont appelés des '''segments''', ou encore des ''partitions mémoire''. Les segments correspondent à un bloc de mémoire RAM. C'est-à-dire qu'un segment de 259 mébioctets sera un segment continu de 259 mébioctets dans la mémoire physique comme dans la mémoire logique. Dans ce qui suit, un segment contient un programme en cours d'exécution, comme illustré ci-dessous.
[[File:CPT Memory Addressable.svg|centre|vignette|upright=2|Espace d'adressage segmenté.]]
Le système d'exploitation mémorise la position de chaque segment en mémoire, ainsi que d'autres informations annexes. Le tout est regroupé dans la '''table de segment''', un tableau dont chaque case est attribuée à un programme/segment. La table des segments est un tableau numéroté, chaque segment ayant un numéro qui précise sa position dans le tableau. Chaque case, chaque entrée, contient un '''descripteur de segment''' qui regroupe plusieurs informations sur le segment : son adresse de base, sa taille, diverses informations.
===La relocation avec la relocation matérielle : le registre de base===
Un segment peut être placé n'importe où en RAM physique et sa position en RAM change à chaque exécution. Le programme est chargé à une adresse, celle du début du segment, qui change à chaque chargement du programme. Et toutes les adresses utilisées par le programme doivent être corrigées lors du chargement du programme, généralement par l'OS. Cette correction s'appelle la '''relocation''', et elle consiste à ajouter l'adresse de début du segment à chaque adresse manipulée par le programme.
[[File:Relocation assistée par matériel.png|centre|vignette|upright=2.5|Relocation.]]
La relocation matérielle fait que la relocation est faite par le processeur, pas par l'OS. La relocation est intégrée dans le processeur par l'intégration d'un registre : le '''registre de base''', aussi appelé '''registre de relocation'''. Il mémorise l'adresse à laquelle commence le segment, la première adresse du programme. Pour effectuer la relocation, le processeur ajoute automatiquement l'adresse de base à chaque accès mémoire, en allant la chercher dans le registre de relocation.
[[File:Registre de base de segment.png|centre|vignette|upright=2|Registre de base de segment.]]
Le processeur s'occupe de la relocation des segments et le programme compilé n'en voit rien. Pour le dire autrement, les programmes manipulent des adresses logiques, qui sont traduites par le processeur en adresses physiques. La traduction se fait en ajoutant le contenu du registre de relocation à l'adresse logique. De plus, cette méthode fait que chaque programme a son propre espace d'adressage.
[[File:CPU created logical address presentation.png|centre|vignette|upright=2|Traduction d'adresse avec la relocation matérielle.]]
Le système d'exploitation mémorise les adresses de base pour chaque programme, dans la table des segments. Le registre de base est mis à jour automatiquement lors de chaque changement de segment. Pour cela, le registre de base est accessible via certaines instructions, accessibles en espace noyau, plus rarement en espace utilisateur. Le registre de segment est censé être adressé implicitement, vu qu'il est unique. Si ce n'est pas le cas, il est possible d'écrire dans ce registre de segment, qui est alors adressable.
===La protection mémoire avec la relocation matérielle : le registre limite===
Sans restrictions supplémentaires, la taille maximale d'un segment est égale à la taille complète de l'espace d'adressage. Sur les processeurs 32 bits, un segment a une taille maximale de 2^32 octets, soit 4 gibioctets. Mais il est possible de limiter la taille du segment à 2 gibioctets, 1 gibioctet, 64 Kibioctets, ou toute autre taille. La limite est définie lors de la création du segment, mais elle peut cependant évoluer au cours de l'exécution du programme, grâce à l'allocation mémoire.
Le processeur vérifie à chaque accès mémoire que celui-ci se fait bien dans le segment, qu'il ne déborde pas en-dehors. C'est possible qu'une adresse calculée sorte du segment, à la suite d'un bug ou d'une erreur de programmation, voire pire. Et le processeur doit éviter de tels '''débordements de segments'''. Pour cela, le processeur compare l'adresse accédée et vérifie qu'elle est bien dans le segment.
Faire cette vérification demande soit de mémoriser la taille du segment, soit de mémoriser l'adresse limite à ne pas dépasser (l'adresse de fin de segment). En utilisant l'adresse limite, on fait la relocation, et on compare 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. Précisons que l'adresse logique est celle avant relocation, celle qui indique la position de la donnée dans le segment, celle obtenue quand on considère que le segment commence à l'adresse zéro. On peut alors faire le test de débordement avant ou pendant la relocation. Quelques processeurs en ont profité, mais on verra cela dans la section sur la segmentation.
Pour cela, la table des segments doit être modifiée. Au lieu de ne contenir que l'adresse de base, elle contient soit l'adresse maximale du segment, soit la taille du segment. En clair, le descripteur de segment est enrichi avec l'adresse limite. 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.
Techniquement, il y a une petite différence de vitesse entre utiliser la taille et l'adresse maximale. Vérifier les débordements avec la taille demande juste de comparer la taille avec l'adresse logique, avant relocation, ce qui peut être fait en parallèle de la relocation. Par contre, l'adresse limite est comparée à une adresse physique, ce qui demande de faire la relocation avant la vérification, ce qui prend un peu plus de temps. Mais l'impact sur les performances est des plus mineurs.
[[File:Registre limite.png|centre|vignette|upright=2|Registre limite]]
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> </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>+ </code><code style="background:#DDF">0001 0010 0011 0100</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </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. Au lieu d'utiliser un additionneur séparé pour le ''program counter'' et un autre pour le calcul de l'adresse physique, un seul additionneur est utilisé pour les deux. L'idée était de partager l'additionneur, qui servait à la fois à incrémenter le ''program counter'' et pour gérer la segmentation. 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|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> </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>+ </code><code style="background:#DDF">1111 1111 1111 1111</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </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 de nos PCs, à partir du 286 d'Intel. 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. A 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. A la place, les segments sont adressés de manière indirecte. A 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.
====L'implémentation de la protection mémoire sur le 386====
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 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. A 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]]
A 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. A 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. A la place, l'entrée contient un '''bit ''global''''', qui précise que cette correspondance est valide pour tous les processus. Le bit global accélère rapidement la traduction d'adresse pour l'accès au noyau.
Un défaut de cette méthode est que le partage d'une page entre plusieurs processus est presque impossible. Impossible de partager une page avec seulement certains processus et pas d'autres : soit on partage une page avec tous les processus, soit on l'alloue avec un seul processus.
====L'usage de plusieurs tables des pages====
Une solution alternative, plus simple, utilise une table des pages par processus lancé sur l'ordinateur, une table des pages unique par espace d'adressage. À chaque changement de processus, le registre qui mémorise la position de la table des pages est modifié pour pointer sur la bonne. C'est le système d'exploitation qui se charge de cette mise à jour.
Avec cette méthode, il est possible de partager une ou plusieurs pages entre plusieurs processus, en configurant les tables des pages convenablement. Les pages partagées sont mappées dans l'espace d'adressage de plusieurs processus, mais pas forcément au même endroit, pas forcément dans les mêmes adresses logiques. On peut placer la page partagée à l'adresse logique 0x0FFF pour un processus, à l'adresse logique 0xFF00 pour un autre processus, etc. Par contre, les entrées de la table des pages pour ces adresses pointent vers la même adresse physique.
[[File:Vm5.png|centre|vignette|upright=2|Tables des pages de plusieurs processus.]]
===La taille des pages===
La taille des pages varie suivant le processeur et le système d'exploitation et tourne souvent autour de 4 kibioctets. Les processeurs actuels gèrent plusieurs tailles différentes pour les pages : 4 kibioctets par défaut, 2 mébioctets, voire 1 à 4 gibioctets pour les pages les plus larges. Les pages de 4 kibioctets sont les pages par défaut, les autres tailles de page sont appelées des ''pages larges''. La taille optimale pour les pages dépend de nombreux paramètres et il n'y a pas de taille qui convienne à tout le monde. Certaines applications gagnent à utiliser des pages larges, d'autres vont au contraire perdre drastiquement en performance en les utilisant.
Le désavantage principal des pages larges est qu'elles favorisent la fragmentation mémoire. Si un programme veut réserver une portion de mémoire, pour une structure de donnée quelconque, il doit réserver une portion dont la taille est multiple de la taille d'une page. Par exemple, un programme ayant besoin de 110 kibioctets allouera 28 pages de 4 kibioctets, soit 120 kibioctets : 2 kibioctets seront perdus. Par contre, avec des pages larges de 2 mébioctets, on aura une perte de 2048 - 110 = 1938 kibioctets. En somme, des morceaux de mémoire seront perdus, car les pages sont trop grandes pour les données qu'on veut y mettre. Le résultat est que le programme qui utilise les pages larges utilisent plus de mémoire et ce d'autant plus qu'il utilise des données de petite taille. Un autre désavantage est qu'elles se marient mal avec certaines techniques d'optimisations de type ''copy-on-write''.
Mais l'avantage est que la traduction des adresses est plus performante. Une taille des pages plus élevée signifie moins de pages, donc des tables des pages plus petites. Et des pages des tables plus petites n'ont pas besoin de beaucoup de niveaux de hiérarchie, voire peuvent se limiter à des tables des pages simples, ce qui rend la traduction d'adresse plus simple et plus rapide. De plus, les programmes ont une certaine localité spatiale, qui font qu'ils accèdent souvent à des données proches. La traduction d'adresse peut alors profiter de systèmes de mise en cache dont nous parlerons dans le prochain chapitre, et ces systèmes de cache marchent nettement mieux avec des pages larges.
Il faut noter que la taille des pages est presque toujours une puissance de deux. Cela a de nombreux avantages, mais n'est pas une nécessité. Par exemple, le tout premier processeur avec de la pagination, le super-ordinateur Atlas, avait des pages de 3 kibioctets. L'avantage principal est que la traduction de l'adresse physique en adresse logique est trivial avec une puissance de deux. Cela garantit que l'on peut diviser l'adresse en un numéro de page et un ''offset'' : la traduction demande juste de remplacer les bits de poids forts par le numéro de page voulu. Sans cela, la traduction d'adresse implique des divisions et des multiplications, qui sont des opérations assez couteuses.
===Les entrées de la table des pages===
Avant de poursuivre, faisons un rapide rappel sur les entrées de la table des pages. Nous venons de voir que la table des pages contient de nombreuses informations : un bit ''valid'' pour la mémoire virtuelle, des bits ''dirty'' et ''accessed'' utilisés par l'OS, des bits de protection mémoire, un bit ''global'' et un potentiellement un identifiant de processus, etc. Étudions rapidement le format de la table des pages sur un processeur x86 32 bits.
* Elle contient d'abord le numéro de page physique.
* Les bits AVL sont inutilisés et peuvent être configurés à loisir par l'OS.
* Le bit G est le bit ''global''.
* Le bit PS vaut 0 pour une page de 4 kibioctets, mais est mis à 1 pour une page de 4 mébioctets dans le cas où le processus utilise des pages larges.
* Le bit D est le bit ''dirty''.
* Le bit A est le bit ''accessed''.
* Le bit PCD indique que la page ne peut pas être cachée, dans le sens où le processeur ne peut copier son contenu dans le cache et doit toujours lire ou écrire cette page directement dans la RAM.
* Le bit PWT indique que les écritures doivent mettre à jour le cache et la page en RAM (dans le chapitre sur le cache, on verra qu'il force le cache à se comporter comme un cache ''write-through'' pour cette page).
* Le bit U/S précise si la page est accessible en mode noyau ou utilisateur.
* Le bit R/W indique si la page est accessible en écriture, toutes les pages sont par défaut accessibles en lecture.
* Le bit P est le bit ''valid''.
[[File:PDE.png|centre|vignette|upright=2.5|Table des pages des processeurs Intel 32 bits.]]
==Comparaison des différentes techniques d'abstraction mémoire==
Pour résumer, l'abstraction mémoire permet de gérer : la relocation, la protection mémoire, l'isolation des processus, la mémoire virtuelle, l'extension de l'espace d'adressage, le partage de mémoire, etc. Elles sont souvent implémentées en même temps. Ce qui fait qu'elles sont souvent confondues, alors que ce sont des concepts sont différents. Ces liens sont résumés dans le tableau ci-dessous.
{|class="wikitable"
|-
!
! colspan="5" | Avec abstraction mémoire
! rowspan="2" | Sans abstraction mémoire
|-
!
! Relocation matérielle
! Segmentation en mode réel (x86)
! Segmentation, général
! Architectures à capacités
! Pagination
|-
! Abstraction matérielle des processus
| colspan="4" | Oui, relocation matérielle
| Oui, liée à la traduction d'adresse
| Impossible
|-
! Mémoire virtuelle
| colspan="2" | Non, sauf émulation logicielle
| colspan="3" | Oui, gérée par le processeur et l'OS
| Non, sauf émulation logicielle
|-
! Extension de l'espace d'adressage
| colspan="2" | Oui : registre de base élargi
| colspan="2" | Oui : adresse de base élargie dans la table des segments
| ''Physical Adress Extension'' des processeurs 32 bits
| Commutation de banques
|-
! Protection mémoire
| Registre limite
| Aucune
| colspan="2" | Registre limite, droits d'accès aux segments
| Gestion des droits d'accès aux pages
| Possible, méthodes variées
|-
! Partage de mémoire
| colspan="2" | Non
| colspan="2" | Segment partagés
| Pages partagées
| Possible, méthodes variées
|}
===Les différents types de segmentation===
La segmentation regroupe plusieurs techniques franchement différentes, qui auraient gagné à être nommées différemment. La principale différence est l'usage de registres de relocation versus des registres de sélecteurs de segments. L'usage de registres de relocation est le fait de la relocation matérielle, mais aussi de la segmentation en mode réel des CPU x86. Par contre, l'usage de sélecteurs de segments est le fait des autres formes de segmentation, architectures à capacité inclues.
La différence entre les deux est le nombre de segments. L'usage de registres de relocation fait que le CPU ne gère qu'un petit nombre de segments de grande taille. La mémoire virtuelle est donc rarement implémentée vu que swapper des segments de grande taille est trop long, l'impact sur les performances est trop important. Sans compter que l'usage de registres de base se marie très mal avec la mémoire virtuelle. Vu qu'un segment peut être swappé ou déplacée n'importe quand, il faut invalider les registres de base au moment du swap/déplacement, ce qui n'est pas chose aisée. Aucun processeur ne gère cela, les méthodes pour n'existent tout simplement pas. L'usage de registres de base implique que la mémoire virtuelle est absente.
La protection mémoire est aussi plus limitée avec l'usage de registres de relocation. Elle se limite à des registres limite, mais la gestion des droits d'accès est limitée. En théorie, la segmentation en mode réel pourrait implémenter une version limitée de protection mémoire, avec une protection de l'espace exécutable. Mais ca n'a jamais été fait en pratique sur les processeurs x86.
Le partage de la mémoire est aussi difficile sur les architectures avec des registres de base. L'absence de table des segments fait que le partage d'un segment est basiquement impossible sans utiliser des méthodes complétement tordues, qui ne sont jamais implémentées en pratique.
===Segmentation versus pagination===
Par rapport à la pagination, la segmentation a des avantages et des inconvénients. Tous sont liés aux propriétés des segments et pages : les segments sont de grande taille et de taille variable, les pages sont petites et de taille fixe.
L'avantage principal de la segmentation est sa rapidité. Le fait que les segments sont de grande taille fait qu'on a pas besoin d'équivalent aux tables des pages inversée ou multiple, juste d'une table des segments toute simple. De plus, les échanges entre table des pages/segments et registres sont plus rares avec la segmentation. Par exemple, si un programme utilise un segment de 2 gigas, tous les accès dans le segment se feront avec une seule consultation de la table des segments. Alors qu'avec la pagination, il faudra une consultation de la table des pages chaque bloc de 4 kibioctet, au minimum.
Mais les désavantages sont nombreux. Le système d'exploitation doit agencer les segments en RAM, et c'est une tâche complexe. Le fait que les segments puisse changer de taille rend le tout encore plus complexe. Par exemple, si on colle les segments les uns à la suite des autres, changer la taille d'un segment demande de réorganiser tous les segments en RAM, ce qui demande énormément de copies RAM-RAM. Une autre possibilité est de laisser assez d'espace entre les segments, mais cet espace est alors gâché, dans le sens où on ne peut pas y placer un nouveau segment.
Swapper un segment est aussi très long, vu que les segments sont de grande taille, alors que swapper une page est très rapide.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'espace d'adressage du processeur
| prevText=L'espace d'adressage du processeur
| next=Les méthodes de synchronisation entre processeur et périphériques
| nextText=Les méthodes de synchronisation entre processeur et périphériques
}}
</noinclude>
hhkdr1khyggweitzm28fe05y04a4iol
763707
763706
2026-04-15T01:03:44Z
Mewtow
31375
/* La protection mémoire avec la relocation matérielle : le registre limite */
763707
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 envoyées à 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 sachez qu'il existe 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 les adresses de l'espace d'adressage sont 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'abstraction mémoire implémente plusieurs fonctionnalités complémentaires==
L'utilité de l'abstraction matérielle n'est pas évidente, mais sachez qu'elle est si utile que tous les processeurs modernes la prennent en charge. Elle sert notamment à implémenter la mémoire virtuelle, que nous aborderons dans ce qui suit. La plupart de ces fonctionnalités manipulent la relation entre adresses logiques et physique. Dans le cas le plus simple, une adresse logique correspond à une seule adresse physique. Mais beaucoup de fonctionnalités avancées ne respectent pas cette règle.
===L'abstraction matérielle des processus===
Les systèmes d'exploitation modernes sont dits multi-tâche, à savoir qu'ils sont capables d'exécuter plusieurs logiciels en même temps. Et ce même si un seul processeur est présent dans l'ordinateur : les logiciels sont alors exécutés à tour de rôle. Toutefois, cela amène un paquet de problèmes qu'il faut résoudre au mieux. Par exemple, les programmes exécutés doivent se partager la mémoire RAM, ce qui ne vient pas sans problèmes. Le problème principal est que les programmes ne doivent pas lire ou écrire dans les données d'un autre, sans quoi on se retrouverait rapidement avec des problèmes. Il faut donc introduire des mécanismes d{{'}}'''isolement des processus''', pour isoler les programmes les uns des autres.
Un de ces mécanismes est l{{'}}'''abstraction matérielle des processus''', une technique qui fait que chaque programme a son propre espace d'adressage. Chaque programme a l'impression d'avoir accès à tout l'espace d'adressage, de l'adresse 0 à l'adresse maximale gérée par le processeur. Évidemment, il s'agit d'une illusion maintenue justement grâce à la traduction d'adresse. Les espaces d'adressage contiennent des adresses logiques, les adresses de la RAM sont des adresses physiques, la nécessité de l'abstraction mémoire est évidente.
Implémenter l'abstraction mémoire peut se faire de plusieurs manières. Mais dans tous les cas, il faut que la correspondance adresse logique - physique change d'un programme à l'autre. Ce qui est normal, vu que les deux processus sont placés à des endroits différents en RAM physique. La conséquence est qu'avec l'abstraction mémoire, une adresse logique correspond à plusieurs adresses physiques. Une même adresse logique dans deux processus différents correspond à deux adresses phsiques différentes, une par processus. Une adresse logique dans un processus correspondra à l'adresse physique X, la même adresse dans un autre processus correspondra à l'adresse Y.
Les adresses physiques qui partagent la même adresse logique sont alors appelées des '''adresses homonymes'''. Le choix de la bonne adresse étant réalisé par un mécanisme matériel et dépend du programme en cours. Le mécanisme pour choisir la bonne adresse dépend du processeur, mais il y en a deux grands types :
* La première consiste à utiliser l'identifiant de processus CPU, vu au chapitre précédent. C'est, pour rappel, un numéro attribué à chaque processus par le processeur. L'identifiant du processus en cours d'exécution est mémorisé dans un registre du processeur. La traduction d'adresse utilise cet identifiant, en plus de l'adresse logique, pour déterminer l'adresse physique.
* La seconde solution mémorise les correspondances adresses logiques-physique dans des tables en mémoire RAM, qui sont différentes pour chaque programme. Les tables sont accédées à chaque accès mémoire, afin de déterminer l'adresse physique.
===Le partage de la mémoire===
L'isolation des processus est très importante sur les systèmes d'exploitation modernes. Cependant, il existe quelques situations où elle doit être contournée ou du moins mise en pause. Les situations sont multiples : gestion de bibliothèques partagées, communication entre processus, usage de ''threads'', etc. Elles impliquent toutes un '''partage de mémoire''', à savoir qu'une portion de mémoire RAM est partagée entre plusieurs programmes. Le partage de mémoire est une sorte de brèche de l'isolation des processus, mais qui est autorisée car elle est utile.
Un cas intéressant est celui des '''bibliothèques partagées'''. Les bibliothèques sont des collections de fonctions regroupées ensemble, dans une seule unité de code. Un programme qui utilise une bibliothèque peut appeler n’importe quelle fonction présente dans la bibliothèque. La bibliothèque peut être simplement inclue dans le programme lui-même, on parle alors de bibliothèques statiques. De telles bibliothèques fonctionnent très bien, mais avec un petit défaut pour les bibliothèques très utilisées : plusieurs programmes qui utilisent la même bibliothèque vont chacun l'inclure dans leur code, ce qui fera doublon.
Pour éviter cela, les OS modernes gèrent des bibliothèques partagées, à savoir qu'un seul exemplaire de la bibliothèque est partagé entre plusieurs programmes. Chaque programme peut exécuter une fonction de la bibliothèque quand il le souhaite, en effectuant un branchement adéquat. Mais cela implique que la bibliothèque soit présente dans l'espace d'adressage du programme en question. Une bibliothèque est donc présente dans plusieurs espaces d'adressage, alors qu'il n'y en a qu'un seul exemplaire en mémoire RAM.
[[File:Ogg vorbis libs and application dia.svg|centre|vignette|upright=2|Exemple de bibliothèques, avec Ogg vorbis.]]
D'autres situations demandent de partager de la mémoire entre deux programmes. Par exemple, les systèmes d'exploitation modernes gèrent nativement des systèmes de '''communication inter-processus''', très utilisés par les programmes modernes pour échanger des données. Et la plupart demandant de partager un bout de mémoire entre processus, même si c'est seulement temporairement. Typiquement, deux processus partagent un intervalle d'adresse où l'un écrit les données à l'autre, l'autre lisant les données envoyées.
Une dernière utilisation de la mémoire partagée est l{{'}}'''accès direct au noyau'''. Sur les systèmes d'exploitations moderne, dans l'espace d'adressage de chaque programme, les adresses hautes sont remplies avec une partie du noyau ! Évidemment, ces adresses sont accessibles uniquement en lecture, pas en écriture. Pas question de modifier le noyau de l'OS ! De plus, il s'agit d'une portion du noyau dont on sait que la consultation ne pose pas de problèmes de sécurité.
Le programme peut lire des données dans cette portion du noyau, mais aussi exécuter les fonctions du noyau qui sont dedans. L'idée est d'éviter des appels systèmes trop fréquents. Au lieu d'effectuer un véritable appel système, avec une interruption logicielle, le programme peut exécuter des appels systèmes simplifiés, de simples appels de fonctions couplés avec un changement de niveau de privilège (passage en espace noyau nécessaire).
[[File:AMD64-canonical--48-bit.png|vignette|Répartition des adresses entre noyau (jaune/orange) et programme (verte), sur les systèmes x86-64 bits, avec des adresses physiques de 48 bits.]]
L'espace d'adressage est donc séparé en deux portions : l'OS d'un côté, le programme de l'autre. La répartition des adresses entre noyau et programme varie suivant l'OS ou le processeur utilisé. Sur les PC x86 32 bits, Linux attribuait 3 gigas pour les programmes et 1 giga pour le noyau, Windows attribuait 2 gigas à chacun. Sur les systèmes x86 64 bits, l'espace d'adressage d'un programme est coupé en trois, comme illustré ci-contre : une partie basse de 2^48 octets, une partie haute de même taille, et un bloc d'adresses invalides entre les deux. Les adresses basses sont utilisées pour le programme, les adresses hautes pour le noyau, il n'y a rien entre les deux.
Avec le partage de mémoire, plusieurs adresses logiques correspondent à la même adresse physique. Tel processus verra la zone de mémoire partagée à l'adresse X, l'autre la verra à l'adresse Y. Mais il s'agira de la même portion de mémoire physique, avec une seule adresse physique. En clair, lorsque deux processus partagent une même zone de mémoire, la zone sera mappées à des adresses logiques différentes. Les adresses logiques sont alors appelées des '''adresses synonymes''', terme qui trahit le fait qu'elles correspondent à la même adresse physique.
===La mémoire virtuelle===
Toutes les adresses ne sont pas forcément occupées par de la mémoire RAM, s'il n'y a pas assez de RAM installée. Par exemple, un processeur 32 bits peut adresser 4 gibioctets de RAM, même si seulement 3 gibioctets sont installés dans l'ordinateur. L'espace d'adressage contient donc 1 gigas d'adresses inutilisées, et il faut éviter ce surplus d'adresses pose problème.
Sans mémoire virtuelle, seule la mémoire réellement installée est utilisable. Si un programme utilise trop de mémoire, il est censé se rendre compte qu'il n'a pas accès à tout l'espace d'adressage. Quand il demandera au système d'exploitation de lui réserver de la mémoire, le système d'exploitation le préviendra qu'il n'y a plus de mémoire libre. Par exemple, si un programme tente d'utiliser 4 gibioctets sur un ordinateur avec 3 gibioctets de mémoire, il ne pourra pas. Pareil s'il veut utiliser 2 gibioctets de mémoire sur un ordinateur avec 4 gibioctets, mais dont 3 gibioctets sont déjà utilisés par d'autres programmes. Dans les deux cas, l'illusion tombe à plat.
Les techniques de '''mémoire virtuelle''' font que l'espace d'adressage est utilisable au complet, même s'il n'y a pas assez de mémoire installée dans l'ordinateur ou que d'autres programmes utilisent de la RAM. Par exemple, sur un processeur 32 bits, le programme aura accès à 4 gibioctets de RAM, même si d'autres programmes utilisent la RAM, même s'il n'y a que 2 gibioctets de RAM d'installés dans l'ordinateur.
Pour cela, on utilise une partie des mémoires de masse (disques durs) d'un ordinateur en remplacement de la mémoire physique manquante. Le système d'exploitation crée sur le disque dur un fichier, appelé le ''swapfile'' ou '''fichier de ''swap''''', qui est utilisé comme mémoire RAM supplémentaire. Il mémorise le surplus de données et de programmes qui ne peut pas être mis en mémoire RAM.
[[File:Vm1.png|centre|vignette|upright=2.0|Mémoire virtuelle et fichier de Swap.]]
Une technique naïve de mémoire virtuelle serait la suivante. Avant de l'aborder, précisons qu'il s'agit d'une technique abordée à but pédagogique, mais qui n'est implémentée nulle part tellement elle est lente et inefficace. Un espace d'adressage de 4 gigas ne contient que 3 gigas de RAM, ce qui fait 1 giga d'adresses inutilisées. Les accès mémoire aux 3 gigas de RAM se font normalement, mais l'accès aux adresses inutilisées lève une exception matérielle "Memory Unavailable". La routine d'interruption de cette exception accède alors au ''swapfile'' et récupère les données associées à cette adresse. La mémoire virtuelle est alors émulée par le système d'exploitation.
Le défaut de cette méthode est que l'accès au giga manquant est toujours très lent, parce qu'il se fait depuis le disque dur. D'autres techniques de mémoire virtuelle logicielle font beaucoup mieux, mais nous allons les passer sous silence, vu qu'on peut faire mieux, avec l'aide du matériel.
L'idée est de charger les données dont le programme a besoin dans la RAM, et de déplacer les autres sur le disque dur. Par exemple, imaginons la situation suivante : un programme a besoin de 4 gigas de mémoire, mais ne dispose que de 2 gigas de mémoire installée. On peut imaginer découper l'espace d'adressage en 2 blocs de 2 gigas, qui sont chargés à la demande. Si le programme accède aux adresses basses, on charge les 2 gigas d'adresse basse en RAM. S'il accède aux adresses hautes, on charge les 2 gigas d'adresse haute dans la RAM après avoir copié les adresses basses sur le ''swapfile''.
On perd du temps dans les copies de données entre RAM et ''swapfile'', mais on gagne en performance vu que tous les accès mémoire se font en RAM. Du fait de la localité temporelle, le programme utilise les données chargées depuis le swapfile durant un bon moment avant de passer au bloc suivant. La RAM est alors utilisée comme une sorte de cache alors que les données sont placées dans une mémoire fictive représentée par l'espace d'adressage et qui correspond au disque dur.
Mais avec cette technique, la correspondance entre adresses du programme et adresses de la RAM change au cours du temps. Les adresses de la RAM correspondent d'abord aux adresses basses, puis aux adresses hautes, et ainsi de suite. On a donc besoin d'abstraction mémoire. Les correspondances entre adresse logique et physique peuvent varier avec le temps, ce qui permet de déplacer des données de la RAM vers le disque dur ou inversement. Une adresse logique peut correspondre à une adresse physique, ou bien à une donnée swappée sur le disque dur. C'est l'unité de traduction d'adresse qui se charge de faire la différence. Si une correspondance entre adresse logique et physique est trouvée, elle l'utilise pour traduire les adresses. Si aucune correspondance n'est trouvée, alors elle laisse la main au système d'exploitation pour charger la donnée en RAM. Une fois la donnée chargée en RAM, les correspondances entre adresse logique et physiques sont modifiées de manière à ce que l'adresse logique pointe vers la donnée chargée.
===L'extension d'adressage===
Une autre fonctionnalité rendue possible par l'abstraction mémoire est l{{'}}'''extension d'adressage'''. Elle permet d'utiliser plus de mémoire que l'espace d'adressage ne le permet. Par exemple, utiliser 7 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'extension d'adresse est l'exact inverse de la mémoire virtuelle. La mémoire virtuelle sert quand on a moins de mémoire que d'adresses, l'extension d'adresse sert quand on a plus de mémoire que d'adresses.
Il y a quelques chapitres, nous avions vu que c'est possible via la commutation de banques. Mais l'abstraction mémoire est une méthode alternative. Que ce soit avec la commutation de banques ou avec l'abstraction mémoire, les adresses envoyées à la mémoire doivent être plus longues que les adresses gérées par le processeur. La différence est que l'abstraction mémoire étend les adresses d'une manière différente.
Une implémentation possible de l'extension d'adressage fait usage de l'abstraction matérielle des processus. Chaque processus a son propre espace d'adressage, mais ceux-ci sont placés à des endroits différents dans la mémoire physique. Par exemple, sur un ordinateur avec 16 gigas de RAM, mais un espace d'adressage de 2 gigas, on peut remplir la RAM en lançant 8 processus différents et chaque processus aura accès à un bloc de 2 gigas de RAM, pas plus, il ne peut pas dépasser cette limite. Ainsi, chaque processus est limité par son espace d'adressage, mais on remplit la mémoire avec plusieurs processus, ce qui compense. Il s'agit là de l'implémentation la plus simple, qui a en plus l'avantage d'avoir la meilleure compatibilité logicielle. De simples changements dans le système d'exploitation suffisent à l'implémenter.
[[File:Extension de l'espace d'adressage.png|centre|vignette|upright=1.5|Extension de l'espace d'adressage]]
Un autre implémentation donne plusieurs espaces d'adressage différents à chaque processus, et a donc accès à autant de mémoire que permis par la somme de ces espaces d'adressage. Par exemple, sur un ordinateur avec 16 gigas de RAM et un espace d'adressage de 4 gigas, un programme peut utiliser toute la RAM en utilisant 4 espaces d'adressage distincts. On passe d'un espace d'adressage à l'autre en changeant la correspondance adresse logique-physique. L'inconvénient est que la compatibilité logicielle est assez mauvaise. Modifier l'OS ne suffit pas, les programmeurs doivent impérativement concevoir leurs programmes pour qu'ils utilisent explicitement plusieurs espaces d'adressage.
Les deux implémentations font usage des adresses logiques homonymes, mais à l'intérieur d'un même processus. Pour rappel, cela veut dire qu'une adresse logique correspond à des adresses physiques différentes. Rien d'étonnant vu qu'on utilise plusieurs espaces d'adressage, comme pour l'abstraction des processus, sauf que cette fois-ci, on a plusieurs espaces d'adressage par processus. Prenons l'exemple où on a 8 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'idée est qu'une adresse correspondra à une adresse dans les premiers 4 gigas, ou dans les seconds 4 gigas. L'adresse logique X correspondra d'abord à une adresse physique dans les premiers 4 gigas, puis à une adresse physique dans les seconds 4 gigas.
===La protection mémoire===
La '''protection mémoire''' regroupe des techniques très différentes les unes des autres, qui visent à améliorer la sécurité des programmes et des systèmes d'exploitation. Elles visent à empêcher de lire, d'écrire ou d'exécuter certaines portions de mémoire. Sans elle, les programmes peuvent techniquement lire ou écrire les données des autres, ce qui causent des situations non-prévues par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses.
La première technique de protection mémoire est l{{'}}'''isolation des processus''', qu'on a vue plus haut. Elle garantit que chaque programme n'a accès qu'à certaines portions dédiées de la mémoire et rend le reste de la mémoire inaccessible en lecture et en écriture. Le système d'exploitation attribue à chaque programme une ou plusieurs portions de mémoire rien que pour lui, auquel aucun autre programme ne peut accéder. Un tel programme, isolé des autres, s'appelle un '''processus''', d'où le nom de cet objectif. Toute tentative d'accès à une partie de la mémoire non autorisée déclenche une exception matérielle (rappelez-vous le chapitre sur les interruptions) qui est traitée par une routine du système d'exploitation. Généralement, le programme fautif est sauvagement arrêté et un message d'erreur est affiché à l'écran.
La '''protection de l'espace exécutable''' empêche d’exécuter quoique ce soit provenant de certaines zones de la mémoire. En effet, certaines portions de la mémoire sont censées contenir uniquement des données, sans aucun programme ou code exécutable. Cependant, des virus informatiques peuvent se cacher dedans et d’exécuter depuis celles-ci. Ou encore, des failles de sécurités peuvent permettre à un attaquant d'injecter du code exécutable malicieux dans des données, ce qui peut lui permettre de lire les données manipulées par un programme, prendre le contrôle de la machine, injecter des virus, ou autre. Pour éviter cela, le système d'exploitation peut marquer certaines zones mémoire comme n'étant pas exécutable. Toute tentative d’exécuter du code localisé dans ces zones entraîne la levée d'une exception ou d'une erreur et le système d'exploitation réagit en conséquence. Là encore, le processeur doit détecter les exécutions non autorisées.
D'autres méthodes de protection mémoire visent à limiter des actions dangereuses. Pour cela, le processeur et l'OS gèrent des '''droits d'accès''', qui interdisent certaines actions pour des programmes non-autorisés. Lorsqu'on exécute une opération interdite, le système d’exploitation et/ou le processeur réagissent en conséquence. La première technique de ce genre n'est autre que la séparation entre espace noyau et utilisateur, vue dans le chapitre sur les interruptions. Mais il y en a d'autres, comme nous le verrons dans ce chapitre.
==La MMU==
La traduction des adresses logiques en adresses physiques se fait par un circuit spécialisé appelé la '''''Memory Management Unit''''' (MMU), qui est souvent intégré directement dans l'interface mémoire. La MMU est souvent associée à une ou plusieurs mémoires caches, qui visent à accélérer la traduction d'adresses logiques en adresses physiques. En effet, nous verrons plus bas que la traduction d'adresse demande d'accéder à des tableaux, gérés par le système d'exploitation, qui sont en mémoire RAM. Aussi, les processeurs modernes incorporent des mémoires caches appelées des '''''Translation Lookaside Buffers''''', ou encore TLB. Nous nous pouvons pas parler des TLB pour le moment, car nous n'avons pas encore abordé le chapitre sur les mémoires caches, mais un chapitre entier sera dédié aux TLB d'ici peu.
[[File:MMU principle updated.png|centre|vignette|upright=2|MMU.]]
===Les MMU intégrées au processeur===
D'ordinaire, la MMU est intégrée au processeur. Et elle peut l'être de deux manières. La première en fait un circuit séparé, relié au bus d'adresse. La seconde fusionne la MMU avec l'unité de calcul d'adresse. La première solution est surtout utilisée avec une technique d'abstraction mémoire appelée la pagination, alors que l'autre l'est avec une autre méthode appelée la segmentation. La raison est que la traduction d'adresse avec la segmentation est assez simple : elle demande d'additionner le contenu d'un registre avec l'adresse logique, ce qui est le genre de calcul qu'une unité de calcul d'adresse sait déjà faire. La fusion est donc assez évidente.
Pour donner un exemple, l'Intel 8086 fusionnait l'unité de calcul d'adresse et la MMU. Précisément, il utilisait un même additionneur pour incrémenter le ''program counter'' et effectuer des calculs d'adresse liés à la segmentation. Il aurait été logique d'ajouter les pointeurs de pile avec, mais ce n'était pas possible. La raison est que le pointeur de pile ne peut pas être envoyé directement sur le bus d'adresse, vu qu'il doit passer par une phase de traduction en adresse physique liée à la segmentation.
[[File:80186 arch.png|centre|vignette|upright=2|Intel 8086, microarchitecture.]]
===Les MMU séparées du processeur, sur la carte mère===
Il a existé des processeurs avec une MMU externe, soudée sur la carte mère.
Par exemple, les processeurs Motorola 68000 et 68010 pouvaient être combinés avec une MMU de type Motorola 68451. Elle supportait des versions simplifiées de la segmentation et de la pagination. Au minimum, elle ajoutait un support de la protection mémoire contre certains accès non-autorisés. La gestion de la mémoire virtuelle proprement dit n'était possible que si le processeur utilisé était un Motorola 68010, en raison de la manière dont le 68000 gérait ses accès mémoire. La MMU 68451 gérait un espace d'adressage de 16 mébioctets, découpé en maximum 32 pages/segments. On pouvait dépasser cette limite de 32 segments/pages en combinant plusieurs 68451.
Le Motorola 68851 était une MMU qui était prévue pour fonctionner de paire avec le Motorola 68020. Elle gérait la pagination pour un espace d'adressage de 32 bits.
Les processeurs suivants, les 68030, 68040, et 68060, avaient une MMU interne au processeur.
==La relocation matérielle==
Pour rappel, les systèmes d'exploitation moderne permettent de lancer plusieurs programmes en même temps et les laissent se partager la mémoire. Dans le cas le plus simple, qui n'est pas celui des OS modernes, le système d'exploitation découpe la mémoire en blocs d'adresses contiguës qui sont appelés des '''segments''', ou encore des ''partitions mémoire''. Les segments correspondent à un bloc de mémoire RAM. C'est-à-dire qu'un segment de 259 mébioctets sera un segment continu de 259 mébioctets dans la mémoire physique comme dans la mémoire logique. Dans ce qui suit, un segment contient un programme en cours d'exécution, comme illustré ci-dessous.
[[File:CPT Memory Addressable.svg|centre|vignette|upright=2|Espace d'adressage segmenté.]]
Le système d'exploitation mémorise la position de chaque segment en mémoire, ainsi que d'autres informations annexes. Le tout est regroupé dans la '''table de segment''', un tableau dont chaque case est attribuée à un programme/segment. La table des segments est un tableau numéroté, chaque segment ayant un numéro qui précise sa position dans le tableau. Chaque case, chaque entrée, contient un '''descripteur de segment''' qui regroupe plusieurs informations sur le segment : son adresse de base, sa taille, diverses informations.
===La relocation avec la relocation matérielle : le registre de base===
Un segment peut être placé n'importe où en RAM physique et sa position en RAM change à chaque exécution. Le programme est chargé à une adresse, celle du début du segment, qui change à chaque chargement du programme. Et toutes les adresses utilisées par le programme doivent être corrigées lors du chargement du programme, généralement par l'OS. Cette correction s'appelle la '''relocation''', et elle consiste à ajouter l'adresse de début du segment à chaque adresse manipulée par le programme.
[[File:Relocation assistée par matériel.png|centre|vignette|upright=2.5|Relocation.]]
La relocation matérielle fait que la relocation est faite par le processeur, pas par l'OS. La relocation est intégrée dans le processeur par l'intégration d'un registre : le '''registre de base''', aussi appelé '''registre de relocation'''. Il mémorise l'adresse à laquelle commence le segment, la première adresse du programme. Pour effectuer la relocation, le processeur ajoute automatiquement l'adresse de base à chaque accès mémoire, en allant la chercher dans le registre de relocation.
[[File:Registre de base de segment.png|centre|vignette|upright=2|Registre de base de segment.]]
Le processeur s'occupe de la relocation des segments et le programme compilé n'en voit rien. Pour le dire autrement, les programmes manipulent des adresses logiques, qui sont traduites par le processeur en adresses physiques. La traduction se fait en ajoutant le contenu du registre de relocation à l'adresse logique. De plus, cette méthode fait que chaque programme a son propre espace d'adressage.
[[File:CPU created logical address presentation.png|centre|vignette|upright=2|Traduction d'adresse avec la relocation matérielle.]]
Le système d'exploitation mémorise les adresses de base pour chaque programme, dans la table des segments. Le registre de base est mis à jour automatiquement lors de chaque changement de segment. Pour cela, le registre de base est accessible via certaines instructions, accessibles en espace noyau, plus rarement en espace utilisateur. Le registre de segment est censé être adressé implicitement, vu qu'il est unique. Si ce n'est pas le cas, il est possible d'écrire dans ce registre de segment, qui est alors adressable.
===La protection mémoire avec la relocation matérielle : le registre limite===
Sans restrictions supplémentaires, la taille maximale d'un segment est égale à la taille complète de l'espace d'adressage. Sur les processeurs 32 bits, un segment a une taille maximale de 2^32 octets, soit 4 gibioctets. Mais il est possible de limiter la taille du segment à 2 gibioctets, 1 gibioctet, 64 Kibioctets, ou toute autre taille. La limite est définie lors de la création du segment, mais elle peut cependant évoluer au cours de l'exécution du programme, grâce à l'allocation mémoire.
Le processeur vérifie à chaque accès mémoire que celui-ci se fait bien dans le segment, qu'il ne déborde pas en-dehors. C'est possible qu'une adresse calculée sorte du segment, à la suite d'un bug ou d'une erreur de programmation, voire pire. Et le processeur doit éviter de tels '''débordements de segments'''. Pour cela, le processeur compare l'adresse accédée et vérifie qu'elle est bien dans le segment.
Faire cette vérification demande d'utiliser soit la taille du segment, soit l'adresse limite à ne pas dépasser (l'adresse de fin de segment). En utilisant l'adresse limite, on fait la relocation, et on compare 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. Précisons que l'adresse logique est celle avant relocation, celle qui indique la position de la donnée dans le segment, celle obtenue quand on considère que le segment commence à l'adresse zéro. On peut alors faire le test de débordement avant ou pendant la relocation. Quelques processeurs en ont profité, mais on verra cela dans la section sur la segmentation.
Et cela demande de mémoriser la taille du segment, ou l'adresse limite à ne pas dépasser (l'adresse de fin de segment). La table des segments doit être modifiée. En plus l'adresse de base, elle doit mémoriser soit l'adresse maximale du segment, soit la taille du segment. En clair, le descripteur de segment est enrichi avec l'adresse limite. 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.
Techniquement, il y a une petite différence de vitesse entre utiliser la taille et l'adresse maximale. Vérifier les débordements avec la taille demande juste de comparer la taille avec l'adresse logique, avant relocation, ce qui peut être fait en parallèle de la relocation. Par contre, l'adresse limite est comparée à une adresse physique, ce qui demande de faire la relocation avant la vérification, ce qui prend un peu plus de temps. Mais l'impact sur les performances est des plus mineurs.
[[File:Registre limite.png|centre|vignette|upright=2|Registre limite]]
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> </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>+ </code><code style="background:#DDF">0001 0010 0011 0100</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </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. Au lieu d'utiliser un additionneur séparé pour le ''program counter'' et un autre pour le calcul de l'adresse physique, un seul additionneur est utilisé pour les deux. L'idée était de partager l'additionneur, qui servait à la fois à incrémenter le ''program counter'' et pour gérer la segmentation. 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|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> </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>+ </code><code style="background:#DDF">1111 1111 1111 1111</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </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 de nos PCs, à partir du 286 d'Intel. 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. A 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. A la place, les segments sont adressés de manière indirecte. A 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.
====L'implémentation de la protection mémoire sur le 386====
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 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. A 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]]
A 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. A 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. A la place, l'entrée contient un '''bit ''global''''', qui précise que cette correspondance est valide pour tous les processus. Le bit global accélère rapidement la traduction d'adresse pour l'accès au noyau.
Un défaut de cette méthode est que le partage d'une page entre plusieurs processus est presque impossible. Impossible de partager une page avec seulement certains processus et pas d'autres : soit on partage une page avec tous les processus, soit on l'alloue avec un seul processus.
====L'usage de plusieurs tables des pages====
Une solution alternative, plus simple, utilise une table des pages par processus lancé sur l'ordinateur, une table des pages unique par espace d'adressage. À chaque changement de processus, le registre qui mémorise la position de la table des pages est modifié pour pointer sur la bonne. C'est le système d'exploitation qui se charge de cette mise à jour.
Avec cette méthode, il est possible de partager une ou plusieurs pages entre plusieurs processus, en configurant les tables des pages convenablement. Les pages partagées sont mappées dans l'espace d'adressage de plusieurs processus, mais pas forcément au même endroit, pas forcément dans les mêmes adresses logiques. On peut placer la page partagée à l'adresse logique 0x0FFF pour un processus, à l'adresse logique 0xFF00 pour un autre processus, etc. Par contre, les entrées de la table des pages pour ces adresses pointent vers la même adresse physique.
[[File:Vm5.png|centre|vignette|upright=2|Tables des pages de plusieurs processus.]]
===La taille des pages===
La taille des pages varie suivant le processeur et le système d'exploitation et tourne souvent autour de 4 kibioctets. Les processeurs actuels gèrent plusieurs tailles différentes pour les pages : 4 kibioctets par défaut, 2 mébioctets, voire 1 à 4 gibioctets pour les pages les plus larges. Les pages de 4 kibioctets sont les pages par défaut, les autres tailles de page sont appelées des ''pages larges''. La taille optimale pour les pages dépend de nombreux paramètres et il n'y a pas de taille qui convienne à tout le monde. Certaines applications gagnent à utiliser des pages larges, d'autres vont au contraire perdre drastiquement en performance en les utilisant.
Le désavantage principal des pages larges est qu'elles favorisent la fragmentation mémoire. Si un programme veut réserver une portion de mémoire, pour une structure de donnée quelconque, il doit réserver une portion dont la taille est multiple de la taille d'une page. Par exemple, un programme ayant besoin de 110 kibioctets allouera 28 pages de 4 kibioctets, soit 120 kibioctets : 2 kibioctets seront perdus. Par contre, avec des pages larges de 2 mébioctets, on aura une perte de 2048 - 110 = 1938 kibioctets. En somme, des morceaux de mémoire seront perdus, car les pages sont trop grandes pour les données qu'on veut y mettre. Le résultat est que le programme qui utilise les pages larges utilisent plus de mémoire et ce d'autant plus qu'il utilise des données de petite taille. Un autre désavantage est qu'elles se marient mal avec certaines techniques d'optimisations de type ''copy-on-write''.
Mais l'avantage est que la traduction des adresses est plus performante. Une taille des pages plus élevée signifie moins de pages, donc des tables des pages plus petites. Et des pages des tables plus petites n'ont pas besoin de beaucoup de niveaux de hiérarchie, voire peuvent se limiter à des tables des pages simples, ce qui rend la traduction d'adresse plus simple et plus rapide. De plus, les programmes ont une certaine localité spatiale, qui font qu'ils accèdent souvent à des données proches. La traduction d'adresse peut alors profiter de systèmes de mise en cache dont nous parlerons dans le prochain chapitre, et ces systèmes de cache marchent nettement mieux avec des pages larges.
Il faut noter que la taille des pages est presque toujours une puissance de deux. Cela a de nombreux avantages, mais n'est pas une nécessité. Par exemple, le tout premier processeur avec de la pagination, le super-ordinateur Atlas, avait des pages de 3 kibioctets. L'avantage principal est que la traduction de l'adresse physique en adresse logique est trivial avec une puissance de deux. Cela garantit que l'on peut diviser l'adresse en un numéro de page et un ''offset'' : la traduction demande juste de remplacer les bits de poids forts par le numéro de page voulu. Sans cela, la traduction d'adresse implique des divisions et des multiplications, qui sont des opérations assez couteuses.
===Les entrées de la table des pages===
Avant de poursuivre, faisons un rapide rappel sur les entrées de la table des pages. Nous venons de voir que la table des pages contient de nombreuses informations : un bit ''valid'' pour la mémoire virtuelle, des bits ''dirty'' et ''accessed'' utilisés par l'OS, des bits de protection mémoire, un bit ''global'' et un potentiellement un identifiant de processus, etc. Étudions rapidement le format de la table des pages sur un processeur x86 32 bits.
* Elle contient d'abord le numéro de page physique.
* Les bits AVL sont inutilisés et peuvent être configurés à loisir par l'OS.
* Le bit G est le bit ''global''.
* Le bit PS vaut 0 pour une page de 4 kibioctets, mais est mis à 1 pour une page de 4 mébioctets dans le cas où le processus utilise des pages larges.
* Le bit D est le bit ''dirty''.
* Le bit A est le bit ''accessed''.
* Le bit PCD indique que la page ne peut pas être cachée, dans le sens où le processeur ne peut copier son contenu dans le cache et doit toujours lire ou écrire cette page directement dans la RAM.
* Le bit PWT indique que les écritures doivent mettre à jour le cache et la page en RAM (dans le chapitre sur le cache, on verra qu'il force le cache à se comporter comme un cache ''write-through'' pour cette page).
* Le bit U/S précise si la page est accessible en mode noyau ou utilisateur.
* Le bit R/W indique si la page est accessible en écriture, toutes les pages sont par défaut accessibles en lecture.
* Le bit P est le bit ''valid''.
[[File:PDE.png|centre|vignette|upright=2.5|Table des pages des processeurs Intel 32 bits.]]
==Comparaison des différentes techniques d'abstraction mémoire==
Pour résumer, l'abstraction mémoire permet de gérer : la relocation, la protection mémoire, l'isolation des processus, la mémoire virtuelle, l'extension de l'espace d'adressage, le partage de mémoire, etc. Elles sont souvent implémentées en même temps. Ce qui fait qu'elles sont souvent confondues, alors que ce sont des concepts sont différents. Ces liens sont résumés dans le tableau ci-dessous.
{|class="wikitable"
|-
!
! colspan="5" | Avec abstraction mémoire
! rowspan="2" | Sans abstraction mémoire
|-
!
! Relocation matérielle
! Segmentation en mode réel (x86)
! Segmentation, général
! Architectures à capacités
! Pagination
|-
! Abstraction matérielle des processus
| colspan="4" | Oui, relocation matérielle
| Oui, liée à la traduction d'adresse
| Impossible
|-
! Mémoire virtuelle
| colspan="2" | Non, sauf émulation logicielle
| colspan="3" | Oui, gérée par le processeur et l'OS
| Non, sauf émulation logicielle
|-
! Extension de l'espace d'adressage
| colspan="2" | Oui : registre de base élargi
| colspan="2" | Oui : adresse de base élargie dans la table des segments
| ''Physical Adress Extension'' des processeurs 32 bits
| Commutation de banques
|-
! Protection mémoire
| Registre limite
| Aucune
| colspan="2" | Registre limite, droits d'accès aux segments
| Gestion des droits d'accès aux pages
| Possible, méthodes variées
|-
! Partage de mémoire
| colspan="2" | Non
| colspan="2" | Segment partagés
| Pages partagées
| Possible, méthodes variées
|}
===Les différents types de segmentation===
La segmentation regroupe plusieurs techniques franchement différentes, qui auraient gagné à être nommées différemment. La principale différence est l'usage de registres de relocation versus des registres de sélecteurs de segments. L'usage de registres de relocation est le fait de la relocation matérielle, mais aussi de la segmentation en mode réel des CPU x86. Par contre, l'usage de sélecteurs de segments est le fait des autres formes de segmentation, architectures à capacité inclues.
La différence entre les deux est le nombre de segments. L'usage de registres de relocation fait que le CPU ne gère qu'un petit nombre de segments de grande taille. La mémoire virtuelle est donc rarement implémentée vu que swapper des segments de grande taille est trop long, l'impact sur les performances est trop important. Sans compter que l'usage de registres de base se marie très mal avec la mémoire virtuelle. Vu qu'un segment peut être swappé ou déplacée n'importe quand, il faut invalider les registres de base au moment du swap/déplacement, ce qui n'est pas chose aisée. Aucun processeur ne gère cela, les méthodes pour n'existent tout simplement pas. L'usage de registres de base implique que la mémoire virtuelle est absente.
La protection mémoire est aussi plus limitée avec l'usage de registres de relocation. Elle se limite à des registres limite, mais la gestion des droits d'accès est limitée. En théorie, la segmentation en mode réel pourrait implémenter une version limitée de protection mémoire, avec une protection de l'espace exécutable. Mais ca n'a jamais été fait en pratique sur les processeurs x86.
Le partage de la mémoire est aussi difficile sur les architectures avec des registres de base. L'absence de table des segments fait que le partage d'un segment est basiquement impossible sans utiliser des méthodes complétement tordues, qui ne sont jamais implémentées en pratique.
===Segmentation versus pagination===
Par rapport à la pagination, la segmentation a des avantages et des inconvénients. Tous sont liés aux propriétés des segments et pages : les segments sont de grande taille et de taille variable, les pages sont petites et de taille fixe.
L'avantage principal de la segmentation est sa rapidité. Le fait que les segments sont de grande taille fait qu'on a pas besoin d'équivalent aux tables des pages inversée ou multiple, juste d'une table des segments toute simple. De plus, les échanges entre table des pages/segments et registres sont plus rares avec la segmentation. Par exemple, si un programme utilise un segment de 2 gigas, tous les accès dans le segment se feront avec une seule consultation de la table des segments. Alors qu'avec la pagination, il faudra une consultation de la table des pages chaque bloc de 4 kibioctet, au minimum.
Mais les désavantages sont nombreux. Le système d'exploitation doit agencer les segments en RAM, et c'est une tâche complexe. Le fait que les segments puisse changer de taille rend le tout encore plus complexe. Par exemple, si on colle les segments les uns à la suite des autres, changer la taille d'un segment demande de réorganiser tous les segments en RAM, ce qui demande énormément de copies RAM-RAM. Une autre possibilité est de laisser assez d'espace entre les segments, mais cet espace est alors gâché, dans le sens où on ne peut pas y placer un nouveau segment.
Swapper un segment est aussi très long, vu que les segments sont de grande taille, alors que swapper une page est très rapide.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'espace d'adressage du processeur
| prevText=L'espace d'adressage du processeur
| next=Les méthodes de synchronisation entre processeur et périphériques
| nextText=Les méthodes de synchronisation entre processeur et périphériques
}}
</noinclude>
29mhvqjcc02kp688w6d5qz92walz3bc
763708
763707
2026-04-15T01:04:02Z
Mewtow
31375
/* La protection mémoire avec la relocation matérielle : le registre limite */
763708
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 envoyées à 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 sachez qu'il existe 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 les adresses de l'espace d'adressage sont 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'abstraction mémoire implémente plusieurs fonctionnalités complémentaires==
L'utilité de l'abstraction matérielle n'est pas évidente, mais sachez qu'elle est si utile que tous les processeurs modernes la prennent en charge. Elle sert notamment à implémenter la mémoire virtuelle, que nous aborderons dans ce qui suit. La plupart de ces fonctionnalités manipulent la relation entre adresses logiques et physique. Dans le cas le plus simple, une adresse logique correspond à une seule adresse physique. Mais beaucoup de fonctionnalités avancées ne respectent pas cette règle.
===L'abstraction matérielle des processus===
Les systèmes d'exploitation modernes sont dits multi-tâche, à savoir qu'ils sont capables d'exécuter plusieurs logiciels en même temps. Et ce même si un seul processeur est présent dans l'ordinateur : les logiciels sont alors exécutés à tour de rôle. Toutefois, cela amène un paquet de problèmes qu'il faut résoudre au mieux. Par exemple, les programmes exécutés doivent se partager la mémoire RAM, ce qui ne vient pas sans problèmes. Le problème principal est que les programmes ne doivent pas lire ou écrire dans les données d'un autre, sans quoi on se retrouverait rapidement avec des problèmes. Il faut donc introduire des mécanismes d{{'}}'''isolement des processus''', pour isoler les programmes les uns des autres.
Un de ces mécanismes est l{{'}}'''abstraction matérielle des processus''', une technique qui fait que chaque programme a son propre espace d'adressage. Chaque programme a l'impression d'avoir accès à tout l'espace d'adressage, de l'adresse 0 à l'adresse maximale gérée par le processeur. Évidemment, il s'agit d'une illusion maintenue justement grâce à la traduction d'adresse. Les espaces d'adressage contiennent des adresses logiques, les adresses de la RAM sont des adresses physiques, la nécessité de l'abstraction mémoire est évidente.
Implémenter l'abstraction mémoire peut se faire de plusieurs manières. Mais dans tous les cas, il faut que la correspondance adresse logique - physique change d'un programme à l'autre. Ce qui est normal, vu que les deux processus sont placés à des endroits différents en RAM physique. La conséquence est qu'avec l'abstraction mémoire, une adresse logique correspond à plusieurs adresses physiques. Une même adresse logique dans deux processus différents correspond à deux adresses phsiques différentes, une par processus. Une adresse logique dans un processus correspondra à l'adresse physique X, la même adresse dans un autre processus correspondra à l'adresse Y.
Les adresses physiques qui partagent la même adresse logique sont alors appelées des '''adresses homonymes'''. Le choix de la bonne adresse étant réalisé par un mécanisme matériel et dépend du programme en cours. Le mécanisme pour choisir la bonne adresse dépend du processeur, mais il y en a deux grands types :
* La première consiste à utiliser l'identifiant de processus CPU, vu au chapitre précédent. C'est, pour rappel, un numéro attribué à chaque processus par le processeur. L'identifiant du processus en cours d'exécution est mémorisé dans un registre du processeur. La traduction d'adresse utilise cet identifiant, en plus de l'adresse logique, pour déterminer l'adresse physique.
* La seconde solution mémorise les correspondances adresses logiques-physique dans des tables en mémoire RAM, qui sont différentes pour chaque programme. Les tables sont accédées à chaque accès mémoire, afin de déterminer l'adresse physique.
===Le partage de la mémoire===
L'isolation des processus est très importante sur les systèmes d'exploitation modernes. Cependant, il existe quelques situations où elle doit être contournée ou du moins mise en pause. Les situations sont multiples : gestion de bibliothèques partagées, communication entre processus, usage de ''threads'', etc. Elles impliquent toutes un '''partage de mémoire''', à savoir qu'une portion de mémoire RAM est partagée entre plusieurs programmes. Le partage de mémoire est une sorte de brèche de l'isolation des processus, mais qui est autorisée car elle est utile.
Un cas intéressant est celui des '''bibliothèques partagées'''. Les bibliothèques sont des collections de fonctions regroupées ensemble, dans une seule unité de code. Un programme qui utilise une bibliothèque peut appeler n’importe quelle fonction présente dans la bibliothèque. La bibliothèque peut être simplement inclue dans le programme lui-même, on parle alors de bibliothèques statiques. De telles bibliothèques fonctionnent très bien, mais avec un petit défaut pour les bibliothèques très utilisées : plusieurs programmes qui utilisent la même bibliothèque vont chacun l'inclure dans leur code, ce qui fera doublon.
Pour éviter cela, les OS modernes gèrent des bibliothèques partagées, à savoir qu'un seul exemplaire de la bibliothèque est partagé entre plusieurs programmes. Chaque programme peut exécuter une fonction de la bibliothèque quand il le souhaite, en effectuant un branchement adéquat. Mais cela implique que la bibliothèque soit présente dans l'espace d'adressage du programme en question. Une bibliothèque est donc présente dans plusieurs espaces d'adressage, alors qu'il n'y en a qu'un seul exemplaire en mémoire RAM.
[[File:Ogg vorbis libs and application dia.svg|centre|vignette|upright=2|Exemple de bibliothèques, avec Ogg vorbis.]]
D'autres situations demandent de partager de la mémoire entre deux programmes. Par exemple, les systèmes d'exploitation modernes gèrent nativement des systèmes de '''communication inter-processus''', très utilisés par les programmes modernes pour échanger des données. Et la plupart demandant de partager un bout de mémoire entre processus, même si c'est seulement temporairement. Typiquement, deux processus partagent un intervalle d'adresse où l'un écrit les données à l'autre, l'autre lisant les données envoyées.
Une dernière utilisation de la mémoire partagée est l{{'}}'''accès direct au noyau'''. Sur les systèmes d'exploitations moderne, dans l'espace d'adressage de chaque programme, les adresses hautes sont remplies avec une partie du noyau ! Évidemment, ces adresses sont accessibles uniquement en lecture, pas en écriture. Pas question de modifier le noyau de l'OS ! De plus, il s'agit d'une portion du noyau dont on sait que la consultation ne pose pas de problèmes de sécurité.
Le programme peut lire des données dans cette portion du noyau, mais aussi exécuter les fonctions du noyau qui sont dedans. L'idée est d'éviter des appels systèmes trop fréquents. Au lieu d'effectuer un véritable appel système, avec une interruption logicielle, le programme peut exécuter des appels systèmes simplifiés, de simples appels de fonctions couplés avec un changement de niveau de privilège (passage en espace noyau nécessaire).
[[File:AMD64-canonical--48-bit.png|vignette|Répartition des adresses entre noyau (jaune/orange) et programme (verte), sur les systèmes x86-64 bits, avec des adresses physiques de 48 bits.]]
L'espace d'adressage est donc séparé en deux portions : l'OS d'un côté, le programme de l'autre. La répartition des adresses entre noyau et programme varie suivant l'OS ou le processeur utilisé. Sur les PC x86 32 bits, Linux attribuait 3 gigas pour les programmes et 1 giga pour le noyau, Windows attribuait 2 gigas à chacun. Sur les systèmes x86 64 bits, l'espace d'adressage d'un programme est coupé en trois, comme illustré ci-contre : une partie basse de 2^48 octets, une partie haute de même taille, et un bloc d'adresses invalides entre les deux. Les adresses basses sont utilisées pour le programme, les adresses hautes pour le noyau, il n'y a rien entre les deux.
Avec le partage de mémoire, plusieurs adresses logiques correspondent à la même adresse physique. Tel processus verra la zone de mémoire partagée à l'adresse X, l'autre la verra à l'adresse Y. Mais il s'agira de la même portion de mémoire physique, avec une seule adresse physique. En clair, lorsque deux processus partagent une même zone de mémoire, la zone sera mappées à des adresses logiques différentes. Les adresses logiques sont alors appelées des '''adresses synonymes''', terme qui trahit le fait qu'elles correspondent à la même adresse physique.
===La mémoire virtuelle===
Toutes les adresses ne sont pas forcément occupées par de la mémoire RAM, s'il n'y a pas assez de RAM installée. Par exemple, un processeur 32 bits peut adresser 4 gibioctets de RAM, même si seulement 3 gibioctets sont installés dans l'ordinateur. L'espace d'adressage contient donc 1 gigas d'adresses inutilisées, et il faut éviter ce surplus d'adresses pose problème.
Sans mémoire virtuelle, seule la mémoire réellement installée est utilisable. Si un programme utilise trop de mémoire, il est censé se rendre compte qu'il n'a pas accès à tout l'espace d'adressage. Quand il demandera au système d'exploitation de lui réserver de la mémoire, le système d'exploitation le préviendra qu'il n'y a plus de mémoire libre. Par exemple, si un programme tente d'utiliser 4 gibioctets sur un ordinateur avec 3 gibioctets de mémoire, il ne pourra pas. Pareil s'il veut utiliser 2 gibioctets de mémoire sur un ordinateur avec 4 gibioctets, mais dont 3 gibioctets sont déjà utilisés par d'autres programmes. Dans les deux cas, l'illusion tombe à plat.
Les techniques de '''mémoire virtuelle''' font que l'espace d'adressage est utilisable au complet, même s'il n'y a pas assez de mémoire installée dans l'ordinateur ou que d'autres programmes utilisent de la RAM. Par exemple, sur un processeur 32 bits, le programme aura accès à 4 gibioctets de RAM, même si d'autres programmes utilisent la RAM, même s'il n'y a que 2 gibioctets de RAM d'installés dans l'ordinateur.
Pour cela, on utilise une partie des mémoires de masse (disques durs) d'un ordinateur en remplacement de la mémoire physique manquante. Le système d'exploitation crée sur le disque dur un fichier, appelé le ''swapfile'' ou '''fichier de ''swap''''', qui est utilisé comme mémoire RAM supplémentaire. Il mémorise le surplus de données et de programmes qui ne peut pas être mis en mémoire RAM.
[[File:Vm1.png|centre|vignette|upright=2.0|Mémoire virtuelle et fichier de Swap.]]
Une technique naïve de mémoire virtuelle serait la suivante. Avant de l'aborder, précisons qu'il s'agit d'une technique abordée à but pédagogique, mais qui n'est implémentée nulle part tellement elle est lente et inefficace. Un espace d'adressage de 4 gigas ne contient que 3 gigas de RAM, ce qui fait 1 giga d'adresses inutilisées. Les accès mémoire aux 3 gigas de RAM se font normalement, mais l'accès aux adresses inutilisées lève une exception matérielle "Memory Unavailable". La routine d'interruption de cette exception accède alors au ''swapfile'' et récupère les données associées à cette adresse. La mémoire virtuelle est alors émulée par le système d'exploitation.
Le défaut de cette méthode est que l'accès au giga manquant est toujours très lent, parce qu'il se fait depuis le disque dur. D'autres techniques de mémoire virtuelle logicielle font beaucoup mieux, mais nous allons les passer sous silence, vu qu'on peut faire mieux, avec l'aide du matériel.
L'idée est de charger les données dont le programme a besoin dans la RAM, et de déplacer les autres sur le disque dur. Par exemple, imaginons la situation suivante : un programme a besoin de 4 gigas de mémoire, mais ne dispose que de 2 gigas de mémoire installée. On peut imaginer découper l'espace d'adressage en 2 blocs de 2 gigas, qui sont chargés à la demande. Si le programme accède aux adresses basses, on charge les 2 gigas d'adresse basse en RAM. S'il accède aux adresses hautes, on charge les 2 gigas d'adresse haute dans la RAM après avoir copié les adresses basses sur le ''swapfile''.
On perd du temps dans les copies de données entre RAM et ''swapfile'', mais on gagne en performance vu que tous les accès mémoire se font en RAM. Du fait de la localité temporelle, le programme utilise les données chargées depuis le swapfile durant un bon moment avant de passer au bloc suivant. La RAM est alors utilisée comme une sorte de cache alors que les données sont placées dans une mémoire fictive représentée par l'espace d'adressage et qui correspond au disque dur.
Mais avec cette technique, la correspondance entre adresses du programme et adresses de la RAM change au cours du temps. Les adresses de la RAM correspondent d'abord aux adresses basses, puis aux adresses hautes, et ainsi de suite. On a donc besoin d'abstraction mémoire. Les correspondances entre adresse logique et physique peuvent varier avec le temps, ce qui permet de déplacer des données de la RAM vers le disque dur ou inversement. Une adresse logique peut correspondre à une adresse physique, ou bien à une donnée swappée sur le disque dur. C'est l'unité de traduction d'adresse qui se charge de faire la différence. Si une correspondance entre adresse logique et physique est trouvée, elle l'utilise pour traduire les adresses. Si aucune correspondance n'est trouvée, alors elle laisse la main au système d'exploitation pour charger la donnée en RAM. Une fois la donnée chargée en RAM, les correspondances entre adresse logique et physiques sont modifiées de manière à ce que l'adresse logique pointe vers la donnée chargée.
===L'extension d'adressage===
Une autre fonctionnalité rendue possible par l'abstraction mémoire est l{{'}}'''extension d'adressage'''. Elle permet d'utiliser plus de mémoire que l'espace d'adressage ne le permet. Par exemple, utiliser 7 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'extension d'adresse est l'exact inverse de la mémoire virtuelle. La mémoire virtuelle sert quand on a moins de mémoire que d'adresses, l'extension d'adresse sert quand on a plus de mémoire que d'adresses.
Il y a quelques chapitres, nous avions vu que c'est possible via la commutation de banques. Mais l'abstraction mémoire est une méthode alternative. Que ce soit avec la commutation de banques ou avec l'abstraction mémoire, les adresses envoyées à la mémoire doivent être plus longues que les adresses gérées par le processeur. La différence est que l'abstraction mémoire étend les adresses d'une manière différente.
Une implémentation possible de l'extension d'adressage fait usage de l'abstraction matérielle des processus. Chaque processus a son propre espace d'adressage, mais ceux-ci sont placés à des endroits différents dans la mémoire physique. Par exemple, sur un ordinateur avec 16 gigas de RAM, mais un espace d'adressage de 2 gigas, on peut remplir la RAM en lançant 8 processus différents et chaque processus aura accès à un bloc de 2 gigas de RAM, pas plus, il ne peut pas dépasser cette limite. Ainsi, chaque processus est limité par son espace d'adressage, mais on remplit la mémoire avec plusieurs processus, ce qui compense. Il s'agit là de l'implémentation la plus simple, qui a en plus l'avantage d'avoir la meilleure compatibilité logicielle. De simples changements dans le système d'exploitation suffisent à l'implémenter.
[[File:Extension de l'espace d'adressage.png|centre|vignette|upright=1.5|Extension de l'espace d'adressage]]
Un autre implémentation donne plusieurs espaces d'adressage différents à chaque processus, et a donc accès à autant de mémoire que permis par la somme de ces espaces d'adressage. Par exemple, sur un ordinateur avec 16 gigas de RAM et un espace d'adressage de 4 gigas, un programme peut utiliser toute la RAM en utilisant 4 espaces d'adressage distincts. On passe d'un espace d'adressage à l'autre en changeant la correspondance adresse logique-physique. L'inconvénient est que la compatibilité logicielle est assez mauvaise. Modifier l'OS ne suffit pas, les programmeurs doivent impérativement concevoir leurs programmes pour qu'ils utilisent explicitement plusieurs espaces d'adressage.
Les deux implémentations font usage des adresses logiques homonymes, mais à l'intérieur d'un même processus. Pour rappel, cela veut dire qu'une adresse logique correspond à des adresses physiques différentes. Rien d'étonnant vu qu'on utilise plusieurs espaces d'adressage, comme pour l'abstraction des processus, sauf que cette fois-ci, on a plusieurs espaces d'adressage par processus. Prenons l'exemple où on a 8 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'idée est qu'une adresse correspondra à une adresse dans les premiers 4 gigas, ou dans les seconds 4 gigas. L'adresse logique X correspondra d'abord à une adresse physique dans les premiers 4 gigas, puis à une adresse physique dans les seconds 4 gigas.
===La protection mémoire===
La '''protection mémoire''' regroupe des techniques très différentes les unes des autres, qui visent à améliorer la sécurité des programmes et des systèmes d'exploitation. Elles visent à empêcher de lire, d'écrire ou d'exécuter certaines portions de mémoire. Sans elle, les programmes peuvent techniquement lire ou écrire les données des autres, ce qui causent des situations non-prévues par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses.
La première technique de protection mémoire est l{{'}}'''isolation des processus''', qu'on a vue plus haut. Elle garantit que chaque programme n'a accès qu'à certaines portions dédiées de la mémoire et rend le reste de la mémoire inaccessible en lecture et en écriture. Le système d'exploitation attribue à chaque programme une ou plusieurs portions de mémoire rien que pour lui, auquel aucun autre programme ne peut accéder. Un tel programme, isolé des autres, s'appelle un '''processus''', d'où le nom de cet objectif. Toute tentative d'accès à une partie de la mémoire non autorisée déclenche une exception matérielle (rappelez-vous le chapitre sur les interruptions) qui est traitée par une routine du système d'exploitation. Généralement, le programme fautif est sauvagement arrêté et un message d'erreur est affiché à l'écran.
La '''protection de l'espace exécutable''' empêche d’exécuter quoique ce soit provenant de certaines zones de la mémoire. En effet, certaines portions de la mémoire sont censées contenir uniquement des données, sans aucun programme ou code exécutable. Cependant, des virus informatiques peuvent se cacher dedans et d’exécuter depuis celles-ci. Ou encore, des failles de sécurités peuvent permettre à un attaquant d'injecter du code exécutable malicieux dans des données, ce qui peut lui permettre de lire les données manipulées par un programme, prendre le contrôle de la machine, injecter des virus, ou autre. Pour éviter cela, le système d'exploitation peut marquer certaines zones mémoire comme n'étant pas exécutable. Toute tentative d’exécuter du code localisé dans ces zones entraîne la levée d'une exception ou d'une erreur et le système d'exploitation réagit en conséquence. Là encore, le processeur doit détecter les exécutions non autorisées.
D'autres méthodes de protection mémoire visent à limiter des actions dangereuses. Pour cela, le processeur et l'OS gèrent des '''droits d'accès''', qui interdisent certaines actions pour des programmes non-autorisés. Lorsqu'on exécute une opération interdite, le système d’exploitation et/ou le processeur réagissent en conséquence. La première technique de ce genre n'est autre que la séparation entre espace noyau et utilisateur, vue dans le chapitre sur les interruptions. Mais il y en a d'autres, comme nous le verrons dans ce chapitre.
==La MMU==
La traduction des adresses logiques en adresses physiques se fait par un circuit spécialisé appelé la '''''Memory Management Unit''''' (MMU), qui est souvent intégré directement dans l'interface mémoire. La MMU est souvent associée à une ou plusieurs mémoires caches, qui visent à accélérer la traduction d'adresses logiques en adresses physiques. En effet, nous verrons plus bas que la traduction d'adresse demande d'accéder à des tableaux, gérés par le système d'exploitation, qui sont en mémoire RAM. Aussi, les processeurs modernes incorporent des mémoires caches appelées des '''''Translation Lookaside Buffers''''', ou encore TLB. Nous nous pouvons pas parler des TLB pour le moment, car nous n'avons pas encore abordé le chapitre sur les mémoires caches, mais un chapitre entier sera dédié aux TLB d'ici peu.
[[File:MMU principle updated.png|centre|vignette|upright=2|MMU.]]
===Les MMU intégrées au processeur===
D'ordinaire, la MMU est intégrée au processeur. Et elle peut l'être de deux manières. La première en fait un circuit séparé, relié au bus d'adresse. La seconde fusionne la MMU avec l'unité de calcul d'adresse. La première solution est surtout utilisée avec une technique d'abstraction mémoire appelée la pagination, alors que l'autre l'est avec une autre méthode appelée la segmentation. La raison est que la traduction d'adresse avec la segmentation est assez simple : elle demande d'additionner le contenu d'un registre avec l'adresse logique, ce qui est le genre de calcul qu'une unité de calcul d'adresse sait déjà faire. La fusion est donc assez évidente.
Pour donner un exemple, l'Intel 8086 fusionnait l'unité de calcul d'adresse et la MMU. Précisément, il utilisait un même additionneur pour incrémenter le ''program counter'' et effectuer des calculs d'adresse liés à la segmentation. Il aurait été logique d'ajouter les pointeurs de pile avec, mais ce n'était pas possible. La raison est que le pointeur de pile ne peut pas être envoyé directement sur le bus d'adresse, vu qu'il doit passer par une phase de traduction en adresse physique liée à la segmentation.
[[File:80186 arch.png|centre|vignette|upright=2|Intel 8086, microarchitecture.]]
===Les MMU séparées du processeur, sur la carte mère===
Il a existé des processeurs avec une MMU externe, soudée sur la carte mère.
Par exemple, les processeurs Motorola 68000 et 68010 pouvaient être combinés avec une MMU de type Motorola 68451. Elle supportait des versions simplifiées de la segmentation et de la pagination. Au minimum, elle ajoutait un support de la protection mémoire contre certains accès non-autorisés. La gestion de la mémoire virtuelle proprement dit n'était possible que si le processeur utilisé était un Motorola 68010, en raison de la manière dont le 68000 gérait ses accès mémoire. La MMU 68451 gérait un espace d'adressage de 16 mébioctets, découpé en maximum 32 pages/segments. On pouvait dépasser cette limite de 32 segments/pages en combinant plusieurs 68451.
Le Motorola 68851 était une MMU qui était prévue pour fonctionner de paire avec le Motorola 68020. Elle gérait la pagination pour un espace d'adressage de 32 bits.
Les processeurs suivants, les 68030, 68040, et 68060, avaient une MMU interne au processeur.
==La relocation matérielle==
Pour rappel, les systèmes d'exploitation moderne permettent de lancer plusieurs programmes en même temps et les laissent se partager la mémoire. Dans le cas le plus simple, qui n'est pas celui des OS modernes, le système d'exploitation découpe la mémoire en blocs d'adresses contiguës qui sont appelés des '''segments''', ou encore des ''partitions mémoire''. Les segments correspondent à un bloc de mémoire RAM. C'est-à-dire qu'un segment de 259 mébioctets sera un segment continu de 259 mébioctets dans la mémoire physique comme dans la mémoire logique. Dans ce qui suit, un segment contient un programme en cours d'exécution, comme illustré ci-dessous.
[[File:CPT Memory Addressable.svg|centre|vignette|upright=2|Espace d'adressage segmenté.]]
Le système d'exploitation mémorise la position de chaque segment en mémoire, ainsi que d'autres informations annexes. Le tout est regroupé dans la '''table de segment''', un tableau dont chaque case est attribuée à un programme/segment. La table des segments est un tableau numéroté, chaque segment ayant un numéro qui précise sa position dans le tableau. Chaque case, chaque entrée, contient un '''descripteur de segment''' qui regroupe plusieurs informations sur le segment : son adresse de base, sa taille, diverses informations.
===La relocation avec la relocation matérielle : le registre de base===
Un segment peut être placé n'importe où en RAM physique et sa position en RAM change à chaque exécution. Le programme est chargé à une adresse, celle du début du segment, qui change à chaque chargement du programme. Et toutes les adresses utilisées par le programme doivent être corrigées lors du chargement du programme, généralement par l'OS. Cette correction s'appelle la '''relocation''', et elle consiste à ajouter l'adresse de début du segment à chaque adresse manipulée par le programme.
[[File:Relocation assistée par matériel.png|centre|vignette|upright=2.5|Relocation.]]
La relocation matérielle fait que la relocation est faite par le processeur, pas par l'OS. La relocation est intégrée dans le processeur par l'intégration d'un registre : le '''registre de base''', aussi appelé '''registre de relocation'''. Il mémorise l'adresse à laquelle commence le segment, la première adresse du programme. Pour effectuer la relocation, le processeur ajoute automatiquement l'adresse de base à chaque accès mémoire, en allant la chercher dans le registre de relocation.
[[File:Registre de base de segment.png|centre|vignette|upright=2|Registre de base de segment.]]
Le processeur s'occupe de la relocation des segments et le programme compilé n'en voit rien. Pour le dire autrement, les programmes manipulent des adresses logiques, qui sont traduites par le processeur en adresses physiques. La traduction se fait en ajoutant le contenu du registre de relocation à l'adresse logique. De plus, cette méthode fait que chaque programme a son propre espace d'adressage.
[[File:CPU created logical address presentation.png|centre|vignette|upright=2|Traduction d'adresse avec la relocation matérielle.]]
Le système d'exploitation mémorise les adresses de base pour chaque programme, dans la table des segments. Le registre de base est mis à jour automatiquement lors de chaque changement de segment. Pour cela, le registre de base est accessible via certaines instructions, accessibles en espace noyau, plus rarement en espace utilisateur. Le registre de segment est censé être adressé implicitement, vu qu'il est unique. Si ce n'est pas le cas, il est possible d'écrire dans ce registre de segment, qui est alors adressable.
===La protection mémoire avec la relocation matérielle : le registre limite===
Sans restrictions supplémentaires, la taille maximale d'un segment est égale à la taille complète de l'espace d'adressage. Sur les processeurs 32 bits, un segment a une taille maximale de 2^32 octets, soit 4 gibioctets. Mais il est possible de limiter la taille du segment à 2 gibioctets, 1 gibioctet, 64 Kibioctets, ou toute autre taille. La limite est définie lors de la création du segment, mais elle peut cependant évoluer au cours de l'exécution du programme, grâce à l'allocation mémoire.
Le processeur vérifie à chaque accès mémoire que celui-ci se fait bien dans le segment, qu'il ne déborde pas en-dehors. C'est possible qu'une adresse calculée sorte du segment, à la suite d'un bug ou d'une erreur de programmation, voire pire. Et le processeur doit éviter de tels '''débordements de segments'''. Pour cela, le processeur compare l'adresse accédée et vérifie qu'elle est bien dans le segment. Faire cette vérification demande d'utiliser soit la taille du segment, soit l'adresse limite à ne pas dépasser (l'adresse de fin de segment).
En utilisant l'adresse limite, on fait la relocation, et on compare 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. Précisons que l'adresse logique est celle avant relocation, celle qui indique la position de la donnée dans le segment, celle obtenue quand on considère que le segment commence à l'adresse zéro. On peut alors faire le test de débordement avant ou pendant la relocation. Quelques processeurs en ont profité, mais on verra cela dans la section sur la segmentation.
Et cela demande de mémoriser la taille du segment, ou l'adresse limite à ne pas dépasser (l'adresse de fin de segment). La table des segments doit être modifiée. En plus l'adresse de base, elle doit mémoriser soit l'adresse maximale du segment, soit la taille du segment. En clair, le descripteur de segment est enrichi avec l'adresse limite. 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.
Techniquement, il y a une petite différence de vitesse entre utiliser la taille et l'adresse maximale. Vérifier les débordements avec la taille demande juste de comparer la taille avec l'adresse logique, avant relocation, ce qui peut être fait en parallèle de la relocation. Par contre, l'adresse limite est comparée à une adresse physique, ce qui demande de faire la relocation avant la vérification, ce qui prend un peu plus de temps. Mais l'impact sur les performances est des plus mineurs.
[[File:Registre limite.png|centre|vignette|upright=2|Registre limite]]
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> </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>+ </code><code style="background:#DDF">0001 0010 0011 0100</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </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. Au lieu d'utiliser un additionneur séparé pour le ''program counter'' et un autre pour le calcul de l'adresse physique, un seul additionneur est utilisé pour les deux. L'idée était de partager l'additionneur, qui servait à la fois à incrémenter le ''program counter'' et pour gérer la segmentation. 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|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> </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>+ </code><code style="background:#DDF">1111 1111 1111 1111</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </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 de nos PCs, à partir du 286 d'Intel. 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. A 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. A la place, les segments sont adressés de manière indirecte. A 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.
====L'implémentation de la protection mémoire sur le 386====
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 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. A 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]]
A 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. A 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. A la place, l'entrée contient un '''bit ''global''''', qui précise que cette correspondance est valide pour tous les processus. Le bit global accélère rapidement la traduction d'adresse pour l'accès au noyau.
Un défaut de cette méthode est que le partage d'une page entre plusieurs processus est presque impossible. Impossible de partager une page avec seulement certains processus et pas d'autres : soit on partage une page avec tous les processus, soit on l'alloue avec un seul processus.
====L'usage de plusieurs tables des pages====
Une solution alternative, plus simple, utilise une table des pages par processus lancé sur l'ordinateur, une table des pages unique par espace d'adressage. À chaque changement de processus, le registre qui mémorise la position de la table des pages est modifié pour pointer sur la bonne. C'est le système d'exploitation qui se charge de cette mise à jour.
Avec cette méthode, il est possible de partager une ou plusieurs pages entre plusieurs processus, en configurant les tables des pages convenablement. Les pages partagées sont mappées dans l'espace d'adressage de plusieurs processus, mais pas forcément au même endroit, pas forcément dans les mêmes adresses logiques. On peut placer la page partagée à l'adresse logique 0x0FFF pour un processus, à l'adresse logique 0xFF00 pour un autre processus, etc. Par contre, les entrées de la table des pages pour ces adresses pointent vers la même adresse physique.
[[File:Vm5.png|centre|vignette|upright=2|Tables des pages de plusieurs processus.]]
===La taille des pages===
La taille des pages varie suivant le processeur et le système d'exploitation et tourne souvent autour de 4 kibioctets. Les processeurs actuels gèrent plusieurs tailles différentes pour les pages : 4 kibioctets par défaut, 2 mébioctets, voire 1 à 4 gibioctets pour les pages les plus larges. Les pages de 4 kibioctets sont les pages par défaut, les autres tailles de page sont appelées des ''pages larges''. La taille optimale pour les pages dépend de nombreux paramètres et il n'y a pas de taille qui convienne à tout le monde. Certaines applications gagnent à utiliser des pages larges, d'autres vont au contraire perdre drastiquement en performance en les utilisant.
Le désavantage principal des pages larges est qu'elles favorisent la fragmentation mémoire. Si un programme veut réserver une portion de mémoire, pour une structure de donnée quelconque, il doit réserver une portion dont la taille est multiple de la taille d'une page. Par exemple, un programme ayant besoin de 110 kibioctets allouera 28 pages de 4 kibioctets, soit 120 kibioctets : 2 kibioctets seront perdus. Par contre, avec des pages larges de 2 mébioctets, on aura une perte de 2048 - 110 = 1938 kibioctets. En somme, des morceaux de mémoire seront perdus, car les pages sont trop grandes pour les données qu'on veut y mettre. Le résultat est que le programme qui utilise les pages larges utilisent plus de mémoire et ce d'autant plus qu'il utilise des données de petite taille. Un autre désavantage est qu'elles se marient mal avec certaines techniques d'optimisations de type ''copy-on-write''.
Mais l'avantage est que la traduction des adresses est plus performante. Une taille des pages plus élevée signifie moins de pages, donc des tables des pages plus petites. Et des pages des tables plus petites n'ont pas besoin de beaucoup de niveaux de hiérarchie, voire peuvent se limiter à des tables des pages simples, ce qui rend la traduction d'adresse plus simple et plus rapide. De plus, les programmes ont une certaine localité spatiale, qui font qu'ils accèdent souvent à des données proches. La traduction d'adresse peut alors profiter de systèmes de mise en cache dont nous parlerons dans le prochain chapitre, et ces systèmes de cache marchent nettement mieux avec des pages larges.
Il faut noter que la taille des pages est presque toujours une puissance de deux. Cela a de nombreux avantages, mais n'est pas une nécessité. Par exemple, le tout premier processeur avec de la pagination, le super-ordinateur Atlas, avait des pages de 3 kibioctets. L'avantage principal est que la traduction de l'adresse physique en adresse logique est trivial avec une puissance de deux. Cela garantit que l'on peut diviser l'adresse en un numéro de page et un ''offset'' : la traduction demande juste de remplacer les bits de poids forts par le numéro de page voulu. Sans cela, la traduction d'adresse implique des divisions et des multiplications, qui sont des opérations assez couteuses.
===Les entrées de la table des pages===
Avant de poursuivre, faisons un rapide rappel sur les entrées de la table des pages. Nous venons de voir que la table des pages contient de nombreuses informations : un bit ''valid'' pour la mémoire virtuelle, des bits ''dirty'' et ''accessed'' utilisés par l'OS, des bits de protection mémoire, un bit ''global'' et un potentiellement un identifiant de processus, etc. Étudions rapidement le format de la table des pages sur un processeur x86 32 bits.
* Elle contient d'abord le numéro de page physique.
* Les bits AVL sont inutilisés et peuvent être configurés à loisir par l'OS.
* Le bit G est le bit ''global''.
* Le bit PS vaut 0 pour une page de 4 kibioctets, mais est mis à 1 pour une page de 4 mébioctets dans le cas où le processus utilise des pages larges.
* Le bit D est le bit ''dirty''.
* Le bit A est le bit ''accessed''.
* Le bit PCD indique que la page ne peut pas être cachée, dans le sens où le processeur ne peut copier son contenu dans le cache et doit toujours lire ou écrire cette page directement dans la RAM.
* Le bit PWT indique que les écritures doivent mettre à jour le cache et la page en RAM (dans le chapitre sur le cache, on verra qu'il force le cache à se comporter comme un cache ''write-through'' pour cette page).
* Le bit U/S précise si la page est accessible en mode noyau ou utilisateur.
* Le bit R/W indique si la page est accessible en écriture, toutes les pages sont par défaut accessibles en lecture.
* Le bit P est le bit ''valid''.
[[File:PDE.png|centre|vignette|upright=2.5|Table des pages des processeurs Intel 32 bits.]]
==Comparaison des différentes techniques d'abstraction mémoire==
Pour résumer, l'abstraction mémoire permet de gérer : la relocation, la protection mémoire, l'isolation des processus, la mémoire virtuelle, l'extension de l'espace d'adressage, le partage de mémoire, etc. Elles sont souvent implémentées en même temps. Ce qui fait qu'elles sont souvent confondues, alors que ce sont des concepts sont différents. Ces liens sont résumés dans le tableau ci-dessous.
{|class="wikitable"
|-
!
! colspan="5" | Avec abstraction mémoire
! rowspan="2" | Sans abstraction mémoire
|-
!
! Relocation matérielle
! Segmentation en mode réel (x86)
! Segmentation, général
! Architectures à capacités
! Pagination
|-
! Abstraction matérielle des processus
| colspan="4" | Oui, relocation matérielle
| Oui, liée à la traduction d'adresse
| Impossible
|-
! Mémoire virtuelle
| colspan="2" | Non, sauf émulation logicielle
| colspan="3" | Oui, gérée par le processeur et l'OS
| Non, sauf émulation logicielle
|-
! Extension de l'espace d'adressage
| colspan="2" | Oui : registre de base élargi
| colspan="2" | Oui : adresse de base élargie dans la table des segments
| ''Physical Adress Extension'' des processeurs 32 bits
| Commutation de banques
|-
! Protection mémoire
| Registre limite
| Aucune
| colspan="2" | Registre limite, droits d'accès aux segments
| Gestion des droits d'accès aux pages
| Possible, méthodes variées
|-
! Partage de mémoire
| colspan="2" | Non
| colspan="2" | Segment partagés
| Pages partagées
| Possible, méthodes variées
|}
===Les différents types de segmentation===
La segmentation regroupe plusieurs techniques franchement différentes, qui auraient gagné à être nommées différemment. La principale différence est l'usage de registres de relocation versus des registres de sélecteurs de segments. L'usage de registres de relocation est le fait de la relocation matérielle, mais aussi de la segmentation en mode réel des CPU x86. Par contre, l'usage de sélecteurs de segments est le fait des autres formes de segmentation, architectures à capacité inclues.
La différence entre les deux est le nombre de segments. L'usage de registres de relocation fait que le CPU ne gère qu'un petit nombre de segments de grande taille. La mémoire virtuelle est donc rarement implémentée vu que swapper des segments de grande taille est trop long, l'impact sur les performances est trop important. Sans compter que l'usage de registres de base se marie très mal avec la mémoire virtuelle. Vu qu'un segment peut être swappé ou déplacée n'importe quand, il faut invalider les registres de base au moment du swap/déplacement, ce qui n'est pas chose aisée. Aucun processeur ne gère cela, les méthodes pour n'existent tout simplement pas. L'usage de registres de base implique que la mémoire virtuelle est absente.
La protection mémoire est aussi plus limitée avec l'usage de registres de relocation. Elle se limite à des registres limite, mais la gestion des droits d'accès est limitée. En théorie, la segmentation en mode réel pourrait implémenter une version limitée de protection mémoire, avec une protection de l'espace exécutable. Mais ca n'a jamais été fait en pratique sur les processeurs x86.
Le partage de la mémoire est aussi difficile sur les architectures avec des registres de base. L'absence de table des segments fait que le partage d'un segment est basiquement impossible sans utiliser des méthodes complétement tordues, qui ne sont jamais implémentées en pratique.
===Segmentation versus pagination===
Par rapport à la pagination, la segmentation a des avantages et des inconvénients. Tous sont liés aux propriétés des segments et pages : les segments sont de grande taille et de taille variable, les pages sont petites et de taille fixe.
L'avantage principal de la segmentation est sa rapidité. Le fait que les segments sont de grande taille fait qu'on a pas besoin d'équivalent aux tables des pages inversée ou multiple, juste d'une table des segments toute simple. De plus, les échanges entre table des pages/segments et registres sont plus rares avec la segmentation. Par exemple, si un programme utilise un segment de 2 gigas, tous les accès dans le segment se feront avec une seule consultation de la table des segments. Alors qu'avec la pagination, il faudra une consultation de la table des pages chaque bloc de 4 kibioctet, au minimum.
Mais les désavantages sont nombreux. Le système d'exploitation doit agencer les segments en RAM, et c'est une tâche complexe. Le fait que les segments puisse changer de taille rend le tout encore plus complexe. Par exemple, si on colle les segments les uns à la suite des autres, changer la taille d'un segment demande de réorganiser tous les segments en RAM, ce qui demande énormément de copies RAM-RAM. Une autre possibilité est de laisser assez d'espace entre les segments, mais cet espace est alors gâché, dans le sens où on ne peut pas y placer un nouveau segment.
Swapper un segment est aussi très long, vu que les segments sont de grande taille, alors que swapper une page est très rapide.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'espace d'adressage du processeur
| prevText=L'espace d'adressage du processeur
| next=Les méthodes de synchronisation entre processeur et périphériques
| nextText=Les méthodes de synchronisation entre processeur et périphériques
}}
</noinclude>
mdd4imfitwhvitri37u2u0vbz4jhmir
763709
763708
2026-04-15T01:22:36Z
Mewtow
31375
/* Le Hardware task switching des CPU x86 */
763709
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 envoyées à 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 sachez qu'il existe 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 les adresses de l'espace d'adressage sont 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'abstraction mémoire implémente plusieurs fonctionnalités complémentaires==
L'utilité de l'abstraction matérielle n'est pas évidente, mais sachez qu'elle est si utile que tous les processeurs modernes la prennent en charge. Elle sert notamment à implémenter la mémoire virtuelle, que nous aborderons dans ce qui suit. La plupart de ces fonctionnalités manipulent la relation entre adresses logiques et physique. Dans le cas le plus simple, une adresse logique correspond à une seule adresse physique. Mais beaucoup de fonctionnalités avancées ne respectent pas cette règle.
===L'abstraction matérielle des processus===
Les systèmes d'exploitation modernes sont dits multi-tâche, à savoir qu'ils sont capables d'exécuter plusieurs logiciels en même temps. Et ce même si un seul processeur est présent dans l'ordinateur : les logiciels sont alors exécutés à tour de rôle. Toutefois, cela amène un paquet de problèmes qu'il faut résoudre au mieux. Par exemple, les programmes exécutés doivent se partager la mémoire RAM, ce qui ne vient pas sans problèmes. Le problème principal est que les programmes ne doivent pas lire ou écrire dans les données d'un autre, sans quoi on se retrouverait rapidement avec des problèmes. Il faut donc introduire des mécanismes d{{'}}'''isolement des processus''', pour isoler les programmes les uns des autres.
Un de ces mécanismes est l{{'}}'''abstraction matérielle des processus''', une technique qui fait que chaque programme a son propre espace d'adressage. Chaque programme a l'impression d'avoir accès à tout l'espace d'adressage, de l'adresse 0 à l'adresse maximale gérée par le processeur. Évidemment, il s'agit d'une illusion maintenue justement grâce à la traduction d'adresse. Les espaces d'adressage contiennent des adresses logiques, les adresses de la RAM sont des adresses physiques, la nécessité de l'abstraction mémoire est évidente.
Implémenter l'abstraction mémoire peut se faire de plusieurs manières. Mais dans tous les cas, il faut que la correspondance adresse logique - physique change d'un programme à l'autre. Ce qui est normal, vu que les deux processus sont placés à des endroits différents en RAM physique. La conséquence est qu'avec l'abstraction mémoire, une adresse logique correspond à plusieurs adresses physiques. Une même adresse logique dans deux processus différents correspond à deux adresses phsiques différentes, une par processus. Une adresse logique dans un processus correspondra à l'adresse physique X, la même adresse dans un autre processus correspondra à l'adresse Y.
Les adresses physiques qui partagent la même adresse logique sont alors appelées des '''adresses homonymes'''. Le choix de la bonne adresse étant réalisé par un mécanisme matériel et dépend du programme en cours. Le mécanisme pour choisir la bonne adresse dépend du processeur, mais il y en a deux grands types :
* La première consiste à utiliser l'identifiant de processus CPU, vu au chapitre précédent. C'est, pour rappel, un numéro attribué à chaque processus par le processeur. L'identifiant du processus en cours d'exécution est mémorisé dans un registre du processeur. La traduction d'adresse utilise cet identifiant, en plus de l'adresse logique, pour déterminer l'adresse physique.
* La seconde solution mémorise les correspondances adresses logiques-physique dans des tables en mémoire RAM, qui sont différentes pour chaque programme. Les tables sont accédées à chaque accès mémoire, afin de déterminer l'adresse physique.
===Le partage de la mémoire===
L'isolation des processus est très importante sur les systèmes d'exploitation modernes. Cependant, il existe quelques situations où elle doit être contournée ou du moins mise en pause. Les situations sont multiples : gestion de bibliothèques partagées, communication entre processus, usage de ''threads'', etc. Elles impliquent toutes un '''partage de mémoire''', à savoir qu'une portion de mémoire RAM est partagée entre plusieurs programmes. Le partage de mémoire est une sorte de brèche de l'isolation des processus, mais qui est autorisée car elle est utile.
Un cas intéressant est celui des '''bibliothèques partagées'''. Les bibliothèques sont des collections de fonctions regroupées ensemble, dans une seule unité de code. Un programme qui utilise une bibliothèque peut appeler n’importe quelle fonction présente dans la bibliothèque. La bibliothèque peut être simplement inclue dans le programme lui-même, on parle alors de bibliothèques statiques. De telles bibliothèques fonctionnent très bien, mais avec un petit défaut pour les bibliothèques très utilisées : plusieurs programmes qui utilisent la même bibliothèque vont chacun l'inclure dans leur code, ce qui fera doublon.
Pour éviter cela, les OS modernes gèrent des bibliothèques partagées, à savoir qu'un seul exemplaire de la bibliothèque est partagé entre plusieurs programmes. Chaque programme peut exécuter une fonction de la bibliothèque quand il le souhaite, en effectuant un branchement adéquat. Mais cela implique que la bibliothèque soit présente dans l'espace d'adressage du programme en question. Une bibliothèque est donc présente dans plusieurs espaces d'adressage, alors qu'il n'y en a qu'un seul exemplaire en mémoire RAM.
[[File:Ogg vorbis libs and application dia.svg|centre|vignette|upright=2|Exemple de bibliothèques, avec Ogg vorbis.]]
D'autres situations demandent de partager de la mémoire entre deux programmes. Par exemple, les systèmes d'exploitation modernes gèrent nativement des systèmes de '''communication inter-processus''', très utilisés par les programmes modernes pour échanger des données. Et la plupart demandant de partager un bout de mémoire entre processus, même si c'est seulement temporairement. Typiquement, deux processus partagent un intervalle d'adresse où l'un écrit les données à l'autre, l'autre lisant les données envoyées.
Une dernière utilisation de la mémoire partagée est l{{'}}'''accès direct au noyau'''. Sur les systèmes d'exploitations moderne, dans l'espace d'adressage de chaque programme, les adresses hautes sont remplies avec une partie du noyau ! Évidemment, ces adresses sont accessibles uniquement en lecture, pas en écriture. Pas question de modifier le noyau de l'OS ! De plus, il s'agit d'une portion du noyau dont on sait que la consultation ne pose pas de problèmes de sécurité.
Le programme peut lire des données dans cette portion du noyau, mais aussi exécuter les fonctions du noyau qui sont dedans. L'idée est d'éviter des appels systèmes trop fréquents. Au lieu d'effectuer un véritable appel système, avec une interruption logicielle, le programme peut exécuter des appels systèmes simplifiés, de simples appels de fonctions couplés avec un changement de niveau de privilège (passage en espace noyau nécessaire).
[[File:AMD64-canonical--48-bit.png|vignette|Répartition des adresses entre noyau (jaune/orange) et programme (verte), sur les systèmes x86-64 bits, avec des adresses physiques de 48 bits.]]
L'espace d'adressage est donc séparé en deux portions : l'OS d'un côté, le programme de l'autre. La répartition des adresses entre noyau et programme varie suivant l'OS ou le processeur utilisé. Sur les PC x86 32 bits, Linux attribuait 3 gigas pour les programmes et 1 giga pour le noyau, Windows attribuait 2 gigas à chacun. Sur les systèmes x86 64 bits, l'espace d'adressage d'un programme est coupé en trois, comme illustré ci-contre : une partie basse de 2^48 octets, une partie haute de même taille, et un bloc d'adresses invalides entre les deux. Les adresses basses sont utilisées pour le programme, les adresses hautes pour le noyau, il n'y a rien entre les deux.
Avec le partage de mémoire, plusieurs adresses logiques correspondent à la même adresse physique. Tel processus verra la zone de mémoire partagée à l'adresse X, l'autre la verra à l'adresse Y. Mais il s'agira de la même portion de mémoire physique, avec une seule adresse physique. En clair, lorsque deux processus partagent une même zone de mémoire, la zone sera mappées à des adresses logiques différentes. Les adresses logiques sont alors appelées des '''adresses synonymes''', terme qui trahit le fait qu'elles correspondent à la même adresse physique.
===La mémoire virtuelle===
Toutes les adresses ne sont pas forcément occupées par de la mémoire RAM, s'il n'y a pas assez de RAM installée. Par exemple, un processeur 32 bits peut adresser 4 gibioctets de RAM, même si seulement 3 gibioctets sont installés dans l'ordinateur. L'espace d'adressage contient donc 1 gigas d'adresses inutilisées, et il faut éviter ce surplus d'adresses pose problème.
Sans mémoire virtuelle, seule la mémoire réellement installée est utilisable. Si un programme utilise trop de mémoire, il est censé se rendre compte qu'il n'a pas accès à tout l'espace d'adressage. Quand il demandera au système d'exploitation de lui réserver de la mémoire, le système d'exploitation le préviendra qu'il n'y a plus de mémoire libre. Par exemple, si un programme tente d'utiliser 4 gibioctets sur un ordinateur avec 3 gibioctets de mémoire, il ne pourra pas. Pareil s'il veut utiliser 2 gibioctets de mémoire sur un ordinateur avec 4 gibioctets, mais dont 3 gibioctets sont déjà utilisés par d'autres programmes. Dans les deux cas, l'illusion tombe à plat.
Les techniques de '''mémoire virtuelle''' font que l'espace d'adressage est utilisable au complet, même s'il n'y a pas assez de mémoire installée dans l'ordinateur ou que d'autres programmes utilisent de la RAM. Par exemple, sur un processeur 32 bits, le programme aura accès à 4 gibioctets de RAM, même si d'autres programmes utilisent la RAM, même s'il n'y a que 2 gibioctets de RAM d'installés dans l'ordinateur.
Pour cela, on utilise une partie des mémoires de masse (disques durs) d'un ordinateur en remplacement de la mémoire physique manquante. Le système d'exploitation crée sur le disque dur un fichier, appelé le ''swapfile'' ou '''fichier de ''swap''''', qui est utilisé comme mémoire RAM supplémentaire. Il mémorise le surplus de données et de programmes qui ne peut pas être mis en mémoire RAM.
[[File:Vm1.png|centre|vignette|upright=2.0|Mémoire virtuelle et fichier de Swap.]]
Une technique naïve de mémoire virtuelle serait la suivante. Avant de l'aborder, précisons qu'il s'agit d'une technique abordée à but pédagogique, mais qui n'est implémentée nulle part tellement elle est lente et inefficace. Un espace d'adressage de 4 gigas ne contient que 3 gigas de RAM, ce qui fait 1 giga d'adresses inutilisées. Les accès mémoire aux 3 gigas de RAM se font normalement, mais l'accès aux adresses inutilisées lève une exception matérielle "Memory Unavailable". La routine d'interruption de cette exception accède alors au ''swapfile'' et récupère les données associées à cette adresse. La mémoire virtuelle est alors émulée par le système d'exploitation.
Le défaut de cette méthode est que l'accès au giga manquant est toujours très lent, parce qu'il se fait depuis le disque dur. D'autres techniques de mémoire virtuelle logicielle font beaucoup mieux, mais nous allons les passer sous silence, vu qu'on peut faire mieux, avec l'aide du matériel.
L'idée est de charger les données dont le programme a besoin dans la RAM, et de déplacer les autres sur le disque dur. Par exemple, imaginons la situation suivante : un programme a besoin de 4 gigas de mémoire, mais ne dispose que de 2 gigas de mémoire installée. On peut imaginer découper l'espace d'adressage en 2 blocs de 2 gigas, qui sont chargés à la demande. Si le programme accède aux adresses basses, on charge les 2 gigas d'adresse basse en RAM. S'il accède aux adresses hautes, on charge les 2 gigas d'adresse haute dans la RAM après avoir copié les adresses basses sur le ''swapfile''.
On perd du temps dans les copies de données entre RAM et ''swapfile'', mais on gagne en performance vu que tous les accès mémoire se font en RAM. Du fait de la localité temporelle, le programme utilise les données chargées depuis le swapfile durant un bon moment avant de passer au bloc suivant. La RAM est alors utilisée comme une sorte de cache alors que les données sont placées dans une mémoire fictive représentée par l'espace d'adressage et qui correspond au disque dur.
Mais avec cette technique, la correspondance entre adresses du programme et adresses de la RAM change au cours du temps. Les adresses de la RAM correspondent d'abord aux adresses basses, puis aux adresses hautes, et ainsi de suite. On a donc besoin d'abstraction mémoire. Les correspondances entre adresse logique et physique peuvent varier avec le temps, ce qui permet de déplacer des données de la RAM vers le disque dur ou inversement. Une adresse logique peut correspondre à une adresse physique, ou bien à une donnée swappée sur le disque dur. C'est l'unité de traduction d'adresse qui se charge de faire la différence. Si une correspondance entre adresse logique et physique est trouvée, elle l'utilise pour traduire les adresses. Si aucune correspondance n'est trouvée, alors elle laisse la main au système d'exploitation pour charger la donnée en RAM. Une fois la donnée chargée en RAM, les correspondances entre adresse logique et physiques sont modifiées de manière à ce que l'adresse logique pointe vers la donnée chargée.
===L'extension d'adressage===
Une autre fonctionnalité rendue possible par l'abstraction mémoire est l{{'}}'''extension d'adressage'''. Elle permet d'utiliser plus de mémoire que l'espace d'adressage ne le permet. Par exemple, utiliser 7 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'extension d'adresse est l'exact inverse de la mémoire virtuelle. La mémoire virtuelle sert quand on a moins de mémoire que d'adresses, l'extension d'adresse sert quand on a plus de mémoire que d'adresses.
Il y a quelques chapitres, nous avions vu que c'est possible via la commutation de banques. Mais l'abstraction mémoire est une méthode alternative. Que ce soit avec la commutation de banques ou avec l'abstraction mémoire, les adresses envoyées à la mémoire doivent être plus longues que les adresses gérées par le processeur. La différence est que l'abstraction mémoire étend les adresses d'une manière différente.
Une implémentation possible de l'extension d'adressage fait usage de l'abstraction matérielle des processus. Chaque processus a son propre espace d'adressage, mais ceux-ci sont placés à des endroits différents dans la mémoire physique. Par exemple, sur un ordinateur avec 16 gigas de RAM, mais un espace d'adressage de 2 gigas, on peut remplir la RAM en lançant 8 processus différents et chaque processus aura accès à un bloc de 2 gigas de RAM, pas plus, il ne peut pas dépasser cette limite. Ainsi, chaque processus est limité par son espace d'adressage, mais on remplit la mémoire avec plusieurs processus, ce qui compense. Il s'agit là de l'implémentation la plus simple, qui a en plus l'avantage d'avoir la meilleure compatibilité logicielle. De simples changements dans le système d'exploitation suffisent à l'implémenter.
[[File:Extension de l'espace d'adressage.png|centre|vignette|upright=1.5|Extension de l'espace d'adressage]]
Un autre implémentation donne plusieurs espaces d'adressage différents à chaque processus, et a donc accès à autant de mémoire que permis par la somme de ces espaces d'adressage. Par exemple, sur un ordinateur avec 16 gigas de RAM et un espace d'adressage de 4 gigas, un programme peut utiliser toute la RAM en utilisant 4 espaces d'adressage distincts. On passe d'un espace d'adressage à l'autre en changeant la correspondance adresse logique-physique. L'inconvénient est que la compatibilité logicielle est assez mauvaise. Modifier l'OS ne suffit pas, les programmeurs doivent impérativement concevoir leurs programmes pour qu'ils utilisent explicitement plusieurs espaces d'adressage.
Les deux implémentations font usage des adresses logiques homonymes, mais à l'intérieur d'un même processus. Pour rappel, cela veut dire qu'une adresse logique correspond à des adresses physiques différentes. Rien d'étonnant vu qu'on utilise plusieurs espaces d'adressage, comme pour l'abstraction des processus, sauf que cette fois-ci, on a plusieurs espaces d'adressage par processus. Prenons l'exemple où on a 8 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'idée est qu'une adresse correspondra à une adresse dans les premiers 4 gigas, ou dans les seconds 4 gigas. L'adresse logique X correspondra d'abord à une adresse physique dans les premiers 4 gigas, puis à une adresse physique dans les seconds 4 gigas.
===La protection mémoire===
La '''protection mémoire''' regroupe des techniques très différentes les unes des autres, qui visent à améliorer la sécurité des programmes et des systèmes d'exploitation. Elles visent à empêcher de lire, d'écrire ou d'exécuter certaines portions de mémoire. Sans elle, les programmes peuvent techniquement lire ou écrire les données des autres, ce qui causent des situations non-prévues par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses.
La première technique de protection mémoire est l{{'}}'''isolation des processus''', qu'on a vue plus haut. Elle garantit que chaque programme n'a accès qu'à certaines portions dédiées de la mémoire et rend le reste de la mémoire inaccessible en lecture et en écriture. Le système d'exploitation attribue à chaque programme une ou plusieurs portions de mémoire rien que pour lui, auquel aucun autre programme ne peut accéder. Un tel programme, isolé des autres, s'appelle un '''processus''', d'où le nom de cet objectif. Toute tentative d'accès à une partie de la mémoire non autorisée déclenche une exception matérielle (rappelez-vous le chapitre sur les interruptions) qui est traitée par une routine du système d'exploitation. Généralement, le programme fautif est sauvagement arrêté et un message d'erreur est affiché à l'écran.
La '''protection de l'espace exécutable''' empêche d’exécuter quoique ce soit provenant de certaines zones de la mémoire. En effet, certaines portions de la mémoire sont censées contenir uniquement des données, sans aucun programme ou code exécutable. Cependant, des virus informatiques peuvent se cacher dedans et d’exécuter depuis celles-ci. Ou encore, des failles de sécurités peuvent permettre à un attaquant d'injecter du code exécutable malicieux dans des données, ce qui peut lui permettre de lire les données manipulées par un programme, prendre le contrôle de la machine, injecter des virus, ou autre. Pour éviter cela, le système d'exploitation peut marquer certaines zones mémoire comme n'étant pas exécutable. Toute tentative d’exécuter du code localisé dans ces zones entraîne la levée d'une exception ou d'une erreur et le système d'exploitation réagit en conséquence. Là encore, le processeur doit détecter les exécutions non autorisées.
D'autres méthodes de protection mémoire visent à limiter des actions dangereuses. Pour cela, le processeur et l'OS gèrent des '''droits d'accès''', qui interdisent certaines actions pour des programmes non-autorisés. Lorsqu'on exécute une opération interdite, le système d’exploitation et/ou le processeur réagissent en conséquence. La première technique de ce genre n'est autre que la séparation entre espace noyau et utilisateur, vue dans le chapitre sur les interruptions. Mais il y en a d'autres, comme nous le verrons dans ce chapitre.
==La MMU==
La traduction des adresses logiques en adresses physiques se fait par un circuit spécialisé appelé la '''''Memory Management Unit''''' (MMU), qui est souvent intégré directement dans l'interface mémoire. La MMU est souvent associée à une ou plusieurs mémoires caches, qui visent à accélérer la traduction d'adresses logiques en adresses physiques. En effet, nous verrons plus bas que la traduction d'adresse demande d'accéder à des tableaux, gérés par le système d'exploitation, qui sont en mémoire RAM. Aussi, les processeurs modernes incorporent des mémoires caches appelées des '''''Translation Lookaside Buffers''''', ou encore TLB. Nous nous pouvons pas parler des TLB pour le moment, car nous n'avons pas encore abordé le chapitre sur les mémoires caches, mais un chapitre entier sera dédié aux TLB d'ici peu.
[[File:MMU principle updated.png|centre|vignette|upright=2|MMU.]]
===Les MMU intégrées au processeur===
D'ordinaire, la MMU est intégrée au processeur. Et elle peut l'être de deux manières. La première en fait un circuit séparé, relié au bus d'adresse. La seconde fusionne la MMU avec l'unité de calcul d'adresse. La première solution est surtout utilisée avec une technique d'abstraction mémoire appelée la pagination, alors que l'autre l'est avec une autre méthode appelée la segmentation. La raison est que la traduction d'adresse avec la segmentation est assez simple : elle demande d'additionner le contenu d'un registre avec l'adresse logique, ce qui est le genre de calcul qu'une unité de calcul d'adresse sait déjà faire. La fusion est donc assez évidente.
Pour donner un exemple, l'Intel 8086 fusionnait l'unité de calcul d'adresse et la MMU. Précisément, il utilisait un même additionneur pour incrémenter le ''program counter'' et effectuer des calculs d'adresse liés à la segmentation. Il aurait été logique d'ajouter les pointeurs de pile avec, mais ce n'était pas possible. La raison est que le pointeur de pile ne peut pas être envoyé directement sur le bus d'adresse, vu qu'il doit passer par une phase de traduction en adresse physique liée à la segmentation.
[[File:80186 arch.png|centre|vignette|upright=2|Intel 8086, microarchitecture.]]
===Les MMU séparées du processeur, sur la carte mère===
Il a existé des processeurs avec une MMU externe, soudée sur la carte mère.
Par exemple, les processeurs Motorola 68000 et 68010 pouvaient être combinés avec une MMU de type Motorola 68451. Elle supportait des versions simplifiées de la segmentation et de la pagination. Au minimum, elle ajoutait un support de la protection mémoire contre certains accès non-autorisés. La gestion de la mémoire virtuelle proprement dit n'était possible que si le processeur utilisé était un Motorola 68010, en raison de la manière dont le 68000 gérait ses accès mémoire. La MMU 68451 gérait un espace d'adressage de 16 mébioctets, découpé en maximum 32 pages/segments. On pouvait dépasser cette limite de 32 segments/pages en combinant plusieurs 68451.
Le Motorola 68851 était une MMU qui était prévue pour fonctionner de paire avec le Motorola 68020. Elle gérait la pagination pour un espace d'adressage de 32 bits.
Les processeurs suivants, les 68030, 68040, et 68060, avaient une MMU interne au processeur.
==La relocation matérielle==
Pour rappel, les systèmes d'exploitation moderne permettent de lancer plusieurs programmes en même temps et les laissent se partager la mémoire. Dans le cas le plus simple, qui n'est pas celui des OS modernes, le système d'exploitation découpe la mémoire en blocs d'adresses contiguës qui sont appelés des '''segments''', ou encore des ''partitions mémoire''. Les segments correspondent à un bloc de mémoire RAM. C'est-à-dire qu'un segment de 259 mébioctets sera un segment continu de 259 mébioctets dans la mémoire physique comme dans la mémoire logique. Dans ce qui suit, un segment contient un programme en cours d'exécution, comme illustré ci-dessous.
[[File:CPT Memory Addressable.svg|centre|vignette|upright=2|Espace d'adressage segmenté.]]
Le système d'exploitation mémorise la position de chaque segment en mémoire, ainsi que d'autres informations annexes. Le tout est regroupé dans la '''table de segment''', un tableau dont chaque case est attribuée à un programme/segment. La table des segments est un tableau numéroté, chaque segment ayant un numéro qui précise sa position dans le tableau. Chaque case, chaque entrée, contient un '''descripteur de segment''' qui regroupe plusieurs informations sur le segment : son adresse de base, sa taille, diverses informations.
===La relocation avec la relocation matérielle : le registre de base===
Un segment peut être placé n'importe où en RAM physique et sa position en RAM change à chaque exécution. Le programme est chargé à une adresse, celle du début du segment, qui change à chaque chargement du programme. Et toutes les adresses utilisées par le programme doivent être corrigées lors du chargement du programme, généralement par l'OS. Cette correction s'appelle la '''relocation''', et elle consiste à ajouter l'adresse de début du segment à chaque adresse manipulée par le programme.
[[File:Relocation assistée par matériel.png|centre|vignette|upright=2.5|Relocation.]]
La relocation matérielle fait que la relocation est faite par le processeur, pas par l'OS. La relocation est intégrée dans le processeur par l'intégration d'un registre : le '''registre de base''', aussi appelé '''registre de relocation'''. Il mémorise l'adresse à laquelle commence le segment, la première adresse du programme. Pour effectuer la relocation, le processeur ajoute automatiquement l'adresse de base à chaque accès mémoire, en allant la chercher dans le registre de relocation.
[[File:Registre de base de segment.png|centre|vignette|upright=2|Registre de base de segment.]]
Le processeur s'occupe de la relocation des segments et le programme compilé n'en voit rien. Pour le dire autrement, les programmes manipulent des adresses logiques, qui sont traduites par le processeur en adresses physiques. La traduction se fait en ajoutant le contenu du registre de relocation à l'adresse logique. De plus, cette méthode fait que chaque programme a son propre espace d'adressage.
[[File:CPU created logical address presentation.png|centre|vignette|upright=2|Traduction d'adresse avec la relocation matérielle.]]
Le système d'exploitation mémorise les adresses de base pour chaque programme, dans la table des segments. Le registre de base est mis à jour automatiquement lors de chaque changement de segment. Pour cela, le registre de base est accessible via certaines instructions, accessibles en espace noyau, plus rarement en espace utilisateur. Le registre de segment est censé être adressé implicitement, vu qu'il est unique. Si ce n'est pas le cas, il est possible d'écrire dans ce registre de segment, qui est alors adressable.
===La protection mémoire avec la relocation matérielle : le registre limite===
Sans restrictions supplémentaires, la taille maximale d'un segment est égale à la taille complète de l'espace d'adressage. Sur les processeurs 32 bits, un segment a une taille maximale de 2^32 octets, soit 4 gibioctets. Mais il est possible de limiter la taille du segment à 2 gibioctets, 1 gibioctet, 64 Kibioctets, ou toute autre taille. La limite est définie lors de la création du segment, mais elle peut cependant évoluer au cours de l'exécution du programme, grâce à l'allocation mémoire.
Le processeur vérifie à chaque accès mémoire que celui-ci se fait bien dans le segment, qu'il ne déborde pas en-dehors. C'est possible qu'une adresse calculée sorte du segment, à la suite d'un bug ou d'une erreur de programmation, voire pire. Et le processeur doit éviter de tels '''débordements de segments'''. Pour cela, le processeur compare l'adresse accédée et vérifie qu'elle est bien dans le segment. Faire cette vérification demande d'utiliser soit la taille du segment, soit l'adresse limite à ne pas dépasser (l'adresse de fin de segment).
En utilisant l'adresse limite, on fait la relocation, et on compare 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. Précisons que l'adresse logique est celle avant relocation, celle qui indique la position de la donnée dans le segment, celle obtenue quand on considère que le segment commence à l'adresse zéro. On peut alors faire le test de débordement avant ou pendant la relocation. Quelques processeurs en ont profité, mais on verra cela dans la section sur la segmentation.
Et cela demande de mémoriser la taille du segment, ou l'adresse limite à ne pas dépasser (l'adresse de fin de segment). La table des segments doit être modifiée. En plus l'adresse de base, elle doit mémoriser soit l'adresse maximale du segment, soit la taille du segment. En clair, le descripteur de segment est enrichi avec l'adresse limite. 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.
Techniquement, il y a une petite différence de vitesse entre utiliser la taille et l'adresse maximale. Vérifier les débordements avec la taille demande juste de comparer la taille avec l'adresse logique, avant relocation, ce qui peut être fait en parallèle de la relocation. Par contre, l'adresse limite est comparée à une adresse physique, ce qui demande de faire la relocation avant la vérification, ce qui prend un peu plus de temps. Mais l'impact sur les performances est des plus mineurs.
[[File:Registre limite.png|centre|vignette|upright=2|Registre limite]]
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> </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>+ </code><code style="background:#DDF">0001 0010 0011 0100</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </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. Au lieu d'utiliser un additionneur séparé pour le ''program counter'' et un autre pour le calcul de l'adresse physique, un seul additionneur est utilisé pour les deux. L'idée était de partager l'additionneur, qui servait à la fois à incrémenter le ''program counter'' et pour gérer la segmentation. 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|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> </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>+ </code><code style="background:#DDF">1111 1111 1111 1111</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </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 de nos PCs, à partir du 286 d'Intel. 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. A 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. A la place, les segments sont adressés de manière indirecte. A 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.
====L'implémentation de la protection mémoire sur le 386====
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 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 cache de descripteur de segment====
Pour améliorer les performances, le 386 intégrait 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 ce ache 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.
Un point important est que 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.
Il faut noter que ce cache avait un petit problème : il n'était pas cohérent avec la mémoire RAM. Par cohérent, on veut dire que si on modifie la table des segments en mémoire RAM, la copie du descripteur dans le cache n'est pas mise à jour. Le seul moyen pour la mettre à jour est de recharger de force le descripteur, ce qui demande de faire des manipulations assez complexes.
Les deux dernières propriétés étaient à l'origine d'une fonctionnalité non-prévue, celle 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'' 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.
La seule difficulté était de charger des descripteurs configurés dans le cache de descripteur. Il fallait pour cela utiliser des instructions non-documentées, comme l'instruction LOADALL.
====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. A 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]]
A 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. A 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. A la place, l'entrée contient un '''bit ''global''''', qui précise que cette correspondance est valide pour tous les processus. Le bit global accélère rapidement la traduction d'adresse pour l'accès au noyau.
Un défaut de cette méthode est que le partage d'une page entre plusieurs processus est presque impossible. Impossible de partager une page avec seulement certains processus et pas d'autres : soit on partage une page avec tous les processus, soit on l'alloue avec un seul processus.
====L'usage de plusieurs tables des pages====
Une solution alternative, plus simple, utilise une table des pages par processus lancé sur l'ordinateur, une table des pages unique par espace d'adressage. À chaque changement de processus, le registre qui mémorise la position de la table des pages est modifié pour pointer sur la bonne. C'est le système d'exploitation qui se charge de cette mise à jour.
Avec cette méthode, il est possible de partager une ou plusieurs pages entre plusieurs processus, en configurant les tables des pages convenablement. Les pages partagées sont mappées dans l'espace d'adressage de plusieurs processus, mais pas forcément au même endroit, pas forcément dans les mêmes adresses logiques. On peut placer la page partagée à l'adresse logique 0x0FFF pour un processus, à l'adresse logique 0xFF00 pour un autre processus, etc. Par contre, les entrées de la table des pages pour ces adresses pointent vers la même adresse physique.
[[File:Vm5.png|centre|vignette|upright=2|Tables des pages de plusieurs processus.]]
===La taille des pages===
La taille des pages varie suivant le processeur et le système d'exploitation et tourne souvent autour de 4 kibioctets. Les processeurs actuels gèrent plusieurs tailles différentes pour les pages : 4 kibioctets par défaut, 2 mébioctets, voire 1 à 4 gibioctets pour les pages les plus larges. Les pages de 4 kibioctets sont les pages par défaut, les autres tailles de page sont appelées des ''pages larges''. La taille optimale pour les pages dépend de nombreux paramètres et il n'y a pas de taille qui convienne à tout le monde. Certaines applications gagnent à utiliser des pages larges, d'autres vont au contraire perdre drastiquement en performance en les utilisant.
Le désavantage principal des pages larges est qu'elles favorisent la fragmentation mémoire. Si un programme veut réserver une portion de mémoire, pour une structure de donnée quelconque, il doit réserver une portion dont la taille est multiple de la taille d'une page. Par exemple, un programme ayant besoin de 110 kibioctets allouera 28 pages de 4 kibioctets, soit 120 kibioctets : 2 kibioctets seront perdus. Par contre, avec des pages larges de 2 mébioctets, on aura une perte de 2048 - 110 = 1938 kibioctets. En somme, des morceaux de mémoire seront perdus, car les pages sont trop grandes pour les données qu'on veut y mettre. Le résultat est que le programme qui utilise les pages larges utilisent plus de mémoire et ce d'autant plus qu'il utilise des données de petite taille. Un autre désavantage est qu'elles se marient mal avec certaines techniques d'optimisations de type ''copy-on-write''.
Mais l'avantage est que la traduction des adresses est plus performante. Une taille des pages plus élevée signifie moins de pages, donc des tables des pages plus petites. Et des pages des tables plus petites n'ont pas besoin de beaucoup de niveaux de hiérarchie, voire peuvent se limiter à des tables des pages simples, ce qui rend la traduction d'adresse plus simple et plus rapide. De plus, les programmes ont une certaine localité spatiale, qui font qu'ils accèdent souvent à des données proches. La traduction d'adresse peut alors profiter de systèmes de mise en cache dont nous parlerons dans le prochain chapitre, et ces systèmes de cache marchent nettement mieux avec des pages larges.
Il faut noter que la taille des pages est presque toujours une puissance de deux. Cela a de nombreux avantages, mais n'est pas une nécessité. Par exemple, le tout premier processeur avec de la pagination, le super-ordinateur Atlas, avait des pages de 3 kibioctets. L'avantage principal est que la traduction de l'adresse physique en adresse logique est trivial avec une puissance de deux. Cela garantit que l'on peut diviser l'adresse en un numéro de page et un ''offset'' : la traduction demande juste de remplacer les bits de poids forts par le numéro de page voulu. Sans cela, la traduction d'adresse implique des divisions et des multiplications, qui sont des opérations assez couteuses.
===Les entrées de la table des pages===
Avant de poursuivre, faisons un rapide rappel sur les entrées de la table des pages. Nous venons de voir que la table des pages contient de nombreuses informations : un bit ''valid'' pour la mémoire virtuelle, des bits ''dirty'' et ''accessed'' utilisés par l'OS, des bits de protection mémoire, un bit ''global'' et un potentiellement un identifiant de processus, etc. Étudions rapidement le format de la table des pages sur un processeur x86 32 bits.
* Elle contient d'abord le numéro de page physique.
* Les bits AVL sont inutilisés et peuvent être configurés à loisir par l'OS.
* Le bit G est le bit ''global''.
* Le bit PS vaut 0 pour une page de 4 kibioctets, mais est mis à 1 pour une page de 4 mébioctets dans le cas où le processus utilise des pages larges.
* Le bit D est le bit ''dirty''.
* Le bit A est le bit ''accessed''.
* Le bit PCD indique que la page ne peut pas être cachée, dans le sens où le processeur ne peut copier son contenu dans le cache et doit toujours lire ou écrire cette page directement dans la RAM.
* Le bit PWT indique que les écritures doivent mettre à jour le cache et la page en RAM (dans le chapitre sur le cache, on verra qu'il force le cache à se comporter comme un cache ''write-through'' pour cette page).
* Le bit U/S précise si la page est accessible en mode noyau ou utilisateur.
* Le bit R/W indique si la page est accessible en écriture, toutes les pages sont par défaut accessibles en lecture.
* Le bit P est le bit ''valid''.
[[File:PDE.png|centre|vignette|upright=2.5|Table des pages des processeurs Intel 32 bits.]]
==Comparaison des différentes techniques d'abstraction mémoire==
Pour résumer, l'abstraction mémoire permet de gérer : la relocation, la protection mémoire, l'isolation des processus, la mémoire virtuelle, l'extension de l'espace d'adressage, le partage de mémoire, etc. Elles sont souvent implémentées en même temps. Ce qui fait qu'elles sont souvent confondues, alors que ce sont des concepts sont différents. Ces liens sont résumés dans le tableau ci-dessous.
{|class="wikitable"
|-
!
! colspan="5" | Avec abstraction mémoire
! rowspan="2" | Sans abstraction mémoire
|-
!
! Relocation matérielle
! Segmentation en mode réel (x86)
! Segmentation, général
! Architectures à capacités
! Pagination
|-
! Abstraction matérielle des processus
| colspan="4" | Oui, relocation matérielle
| Oui, liée à la traduction d'adresse
| Impossible
|-
! Mémoire virtuelle
| colspan="2" | Non, sauf émulation logicielle
| colspan="3" | Oui, gérée par le processeur et l'OS
| Non, sauf émulation logicielle
|-
! Extension de l'espace d'adressage
| colspan="2" | Oui : registre de base élargi
| colspan="2" | Oui : adresse de base élargie dans la table des segments
| ''Physical Adress Extension'' des processeurs 32 bits
| Commutation de banques
|-
! Protection mémoire
| Registre limite
| Aucune
| colspan="2" | Registre limite, droits d'accès aux segments
| Gestion des droits d'accès aux pages
| Possible, méthodes variées
|-
! Partage de mémoire
| colspan="2" | Non
| colspan="2" | Segment partagés
| Pages partagées
| Possible, méthodes variées
|}
===Les différents types de segmentation===
La segmentation regroupe plusieurs techniques franchement différentes, qui auraient gagné à être nommées différemment. La principale différence est l'usage de registres de relocation versus des registres de sélecteurs de segments. L'usage de registres de relocation est le fait de la relocation matérielle, mais aussi de la segmentation en mode réel des CPU x86. Par contre, l'usage de sélecteurs de segments est le fait des autres formes de segmentation, architectures à capacité inclues.
La différence entre les deux est le nombre de segments. L'usage de registres de relocation fait que le CPU ne gère qu'un petit nombre de segments de grande taille. La mémoire virtuelle est donc rarement implémentée vu que swapper des segments de grande taille est trop long, l'impact sur les performances est trop important. Sans compter que l'usage de registres de base se marie très mal avec la mémoire virtuelle. Vu qu'un segment peut être swappé ou déplacée n'importe quand, il faut invalider les registres de base au moment du swap/déplacement, ce qui n'est pas chose aisée. Aucun processeur ne gère cela, les méthodes pour n'existent tout simplement pas. L'usage de registres de base implique que la mémoire virtuelle est absente.
La protection mémoire est aussi plus limitée avec l'usage de registres de relocation. Elle se limite à des registres limite, mais la gestion des droits d'accès est limitée. En théorie, la segmentation en mode réel pourrait implémenter une version limitée de protection mémoire, avec une protection de l'espace exécutable. Mais ca n'a jamais été fait en pratique sur les processeurs x86.
Le partage de la mémoire est aussi difficile sur les architectures avec des registres de base. L'absence de table des segments fait que le partage d'un segment est basiquement impossible sans utiliser des méthodes complétement tordues, qui ne sont jamais implémentées en pratique.
===Segmentation versus pagination===
Par rapport à la pagination, la segmentation a des avantages et des inconvénients. Tous sont liés aux propriétés des segments et pages : les segments sont de grande taille et de taille variable, les pages sont petites et de taille fixe.
L'avantage principal de la segmentation est sa rapidité. Le fait que les segments sont de grande taille fait qu'on a pas besoin d'équivalent aux tables des pages inversée ou multiple, juste d'une table des segments toute simple. De plus, les échanges entre table des pages/segments et registres sont plus rares avec la segmentation. Par exemple, si un programme utilise un segment de 2 gigas, tous les accès dans le segment se feront avec une seule consultation de la table des segments. Alors qu'avec la pagination, il faudra une consultation de la table des pages chaque bloc de 4 kibioctet, au minimum.
Mais les désavantages sont nombreux. Le système d'exploitation doit agencer les segments en RAM, et c'est une tâche complexe. Le fait que les segments puisse changer de taille rend le tout encore plus complexe. Par exemple, si on colle les segments les uns à la suite des autres, changer la taille d'un segment demande de réorganiser tous les segments en RAM, ce qui demande énormément de copies RAM-RAM. Une autre possibilité est de laisser assez d'espace entre les segments, mais cet espace est alors gâché, dans le sens où on ne peut pas y placer un nouveau segment.
Swapper un segment est aussi très long, vu que les segments sont de grande taille, alors que swapper une page est très rapide.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'espace d'adressage du processeur
| prevText=L'espace d'adressage du processeur
| next=Les méthodes de synchronisation entre processeur et périphériques
| nextText=Les méthodes de synchronisation entre processeur et périphériques
}}
</noinclude>
k94dj3n80npo661i7874aiigkjiqveq
763714
763709
2026-04-15T10:29:01Z
Mewtow
31375
/* La protection mémoire avec la relocation matérielle : le registre limite */
763714
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 envoyées à 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 sachez qu'il existe 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 les adresses de l'espace d'adressage sont 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'abstraction mémoire implémente plusieurs fonctionnalités complémentaires==
L'utilité de l'abstraction matérielle n'est pas évidente, mais sachez qu'elle est si utile que tous les processeurs modernes la prennent en charge. Elle sert notamment à implémenter la mémoire virtuelle, que nous aborderons dans ce qui suit. La plupart de ces fonctionnalités manipulent la relation entre adresses logiques et physique. Dans le cas le plus simple, une adresse logique correspond à une seule adresse physique. Mais beaucoup de fonctionnalités avancées ne respectent pas cette règle.
===L'abstraction matérielle des processus===
Les systèmes d'exploitation modernes sont dits multi-tâche, à savoir qu'ils sont capables d'exécuter plusieurs logiciels en même temps. Et ce même si un seul processeur est présent dans l'ordinateur : les logiciels sont alors exécutés à tour de rôle. Toutefois, cela amène un paquet de problèmes qu'il faut résoudre au mieux. Par exemple, les programmes exécutés doivent se partager la mémoire RAM, ce qui ne vient pas sans problèmes. Le problème principal est que les programmes ne doivent pas lire ou écrire dans les données d'un autre, sans quoi on se retrouverait rapidement avec des problèmes. Il faut donc introduire des mécanismes d{{'}}'''isolement des processus''', pour isoler les programmes les uns des autres.
Un de ces mécanismes est l{{'}}'''abstraction matérielle des processus''', une technique qui fait que chaque programme a son propre espace d'adressage. Chaque programme a l'impression d'avoir accès à tout l'espace d'adressage, de l'adresse 0 à l'adresse maximale gérée par le processeur. Évidemment, il s'agit d'une illusion maintenue justement grâce à la traduction d'adresse. Les espaces d'adressage contiennent des adresses logiques, les adresses de la RAM sont des adresses physiques, la nécessité de l'abstraction mémoire est évidente.
Implémenter l'abstraction mémoire peut se faire de plusieurs manières. Mais dans tous les cas, il faut que la correspondance adresse logique - physique change d'un programme à l'autre. Ce qui est normal, vu que les deux processus sont placés à des endroits différents en RAM physique. La conséquence est qu'avec l'abstraction mémoire, une adresse logique correspond à plusieurs adresses physiques. Une même adresse logique dans deux processus différents correspond à deux adresses phsiques différentes, une par processus. Une adresse logique dans un processus correspondra à l'adresse physique X, la même adresse dans un autre processus correspondra à l'adresse Y.
Les adresses physiques qui partagent la même adresse logique sont alors appelées des '''adresses homonymes'''. Le choix de la bonne adresse étant réalisé par un mécanisme matériel et dépend du programme en cours. Le mécanisme pour choisir la bonne adresse dépend du processeur, mais il y en a deux grands types :
* La première consiste à utiliser l'identifiant de processus CPU, vu au chapitre précédent. C'est, pour rappel, un numéro attribué à chaque processus par le processeur. L'identifiant du processus en cours d'exécution est mémorisé dans un registre du processeur. La traduction d'adresse utilise cet identifiant, en plus de l'adresse logique, pour déterminer l'adresse physique.
* La seconde solution mémorise les correspondances adresses logiques-physique dans des tables en mémoire RAM, qui sont différentes pour chaque programme. Les tables sont accédées à chaque accès mémoire, afin de déterminer l'adresse physique.
===Le partage de la mémoire===
L'isolation des processus est très importante sur les systèmes d'exploitation modernes. Cependant, il existe quelques situations où elle doit être contournée ou du moins mise en pause. Les situations sont multiples : gestion de bibliothèques partagées, communication entre processus, usage de ''threads'', etc. Elles impliquent toutes un '''partage de mémoire''', à savoir qu'une portion de mémoire RAM est partagée entre plusieurs programmes. Le partage de mémoire est une sorte de brèche de l'isolation des processus, mais qui est autorisée car elle est utile.
Un cas intéressant est celui des '''bibliothèques partagées'''. Les bibliothèques sont des collections de fonctions regroupées ensemble, dans une seule unité de code. Un programme qui utilise une bibliothèque peut appeler n’importe quelle fonction présente dans la bibliothèque. La bibliothèque peut être simplement inclue dans le programme lui-même, on parle alors de bibliothèques statiques. De telles bibliothèques fonctionnent très bien, mais avec un petit défaut pour les bibliothèques très utilisées : plusieurs programmes qui utilisent la même bibliothèque vont chacun l'inclure dans leur code, ce qui fera doublon.
Pour éviter cela, les OS modernes gèrent des bibliothèques partagées, à savoir qu'un seul exemplaire de la bibliothèque est partagé entre plusieurs programmes. Chaque programme peut exécuter une fonction de la bibliothèque quand il le souhaite, en effectuant un branchement adéquat. Mais cela implique que la bibliothèque soit présente dans l'espace d'adressage du programme en question. Une bibliothèque est donc présente dans plusieurs espaces d'adressage, alors qu'il n'y en a qu'un seul exemplaire en mémoire RAM.
[[File:Ogg vorbis libs and application dia.svg|centre|vignette|upright=2|Exemple de bibliothèques, avec Ogg vorbis.]]
D'autres situations demandent de partager de la mémoire entre deux programmes. Par exemple, les systèmes d'exploitation modernes gèrent nativement des systèmes de '''communication inter-processus''', très utilisés par les programmes modernes pour échanger des données. Et la plupart demandant de partager un bout de mémoire entre processus, même si c'est seulement temporairement. Typiquement, deux processus partagent un intervalle d'adresse où l'un écrit les données à l'autre, l'autre lisant les données envoyées.
Une dernière utilisation de la mémoire partagée est l{{'}}'''accès direct au noyau'''. Sur les systèmes d'exploitations moderne, dans l'espace d'adressage de chaque programme, les adresses hautes sont remplies avec une partie du noyau ! Évidemment, ces adresses sont accessibles uniquement en lecture, pas en écriture. Pas question de modifier le noyau de l'OS ! De plus, il s'agit d'une portion du noyau dont on sait que la consultation ne pose pas de problèmes de sécurité.
Le programme peut lire des données dans cette portion du noyau, mais aussi exécuter les fonctions du noyau qui sont dedans. L'idée est d'éviter des appels systèmes trop fréquents. Au lieu d'effectuer un véritable appel système, avec une interruption logicielle, le programme peut exécuter des appels systèmes simplifiés, de simples appels de fonctions couplés avec un changement de niveau de privilège (passage en espace noyau nécessaire).
[[File:AMD64-canonical--48-bit.png|vignette|Répartition des adresses entre noyau (jaune/orange) et programme (verte), sur les systèmes x86-64 bits, avec des adresses physiques de 48 bits.]]
L'espace d'adressage est donc séparé en deux portions : l'OS d'un côté, le programme de l'autre. La répartition des adresses entre noyau et programme varie suivant l'OS ou le processeur utilisé. Sur les PC x86 32 bits, Linux attribuait 3 gigas pour les programmes et 1 giga pour le noyau, Windows attribuait 2 gigas à chacun. Sur les systèmes x86 64 bits, l'espace d'adressage d'un programme est coupé en trois, comme illustré ci-contre : une partie basse de 2^48 octets, une partie haute de même taille, et un bloc d'adresses invalides entre les deux. Les adresses basses sont utilisées pour le programme, les adresses hautes pour le noyau, il n'y a rien entre les deux.
Avec le partage de mémoire, plusieurs adresses logiques correspondent à la même adresse physique. Tel processus verra la zone de mémoire partagée à l'adresse X, l'autre la verra à l'adresse Y. Mais il s'agira de la même portion de mémoire physique, avec une seule adresse physique. En clair, lorsque deux processus partagent une même zone de mémoire, la zone sera mappées à des adresses logiques différentes. Les adresses logiques sont alors appelées des '''adresses synonymes''', terme qui trahit le fait qu'elles correspondent à la même adresse physique.
===La mémoire virtuelle===
Toutes les adresses ne sont pas forcément occupées par de la mémoire RAM, s'il n'y a pas assez de RAM installée. Par exemple, un processeur 32 bits peut adresser 4 gibioctets de RAM, même si seulement 3 gibioctets sont installés dans l'ordinateur. L'espace d'adressage contient donc 1 gigas d'adresses inutilisées, et il faut éviter ce surplus d'adresses pose problème.
Sans mémoire virtuelle, seule la mémoire réellement installée est utilisable. Si un programme utilise trop de mémoire, il est censé se rendre compte qu'il n'a pas accès à tout l'espace d'adressage. Quand il demandera au système d'exploitation de lui réserver de la mémoire, le système d'exploitation le préviendra qu'il n'y a plus de mémoire libre. Par exemple, si un programme tente d'utiliser 4 gibioctets sur un ordinateur avec 3 gibioctets de mémoire, il ne pourra pas. Pareil s'il veut utiliser 2 gibioctets de mémoire sur un ordinateur avec 4 gibioctets, mais dont 3 gibioctets sont déjà utilisés par d'autres programmes. Dans les deux cas, l'illusion tombe à plat.
Les techniques de '''mémoire virtuelle''' font que l'espace d'adressage est utilisable au complet, même s'il n'y a pas assez de mémoire installée dans l'ordinateur ou que d'autres programmes utilisent de la RAM. Par exemple, sur un processeur 32 bits, le programme aura accès à 4 gibioctets de RAM, même si d'autres programmes utilisent la RAM, même s'il n'y a que 2 gibioctets de RAM d'installés dans l'ordinateur.
Pour cela, on utilise une partie des mémoires de masse (disques durs) d'un ordinateur en remplacement de la mémoire physique manquante. Le système d'exploitation crée sur le disque dur un fichier, appelé le ''swapfile'' ou '''fichier de ''swap''''', qui est utilisé comme mémoire RAM supplémentaire. Il mémorise le surplus de données et de programmes qui ne peut pas être mis en mémoire RAM.
[[File:Vm1.png|centre|vignette|upright=2.0|Mémoire virtuelle et fichier de Swap.]]
Une technique naïve de mémoire virtuelle serait la suivante. Avant de l'aborder, précisons qu'il s'agit d'une technique abordée à but pédagogique, mais qui n'est implémentée nulle part tellement elle est lente et inefficace. Un espace d'adressage de 4 gigas ne contient que 3 gigas de RAM, ce qui fait 1 giga d'adresses inutilisées. Les accès mémoire aux 3 gigas de RAM se font normalement, mais l'accès aux adresses inutilisées lève une exception matérielle "Memory Unavailable". La routine d'interruption de cette exception accède alors au ''swapfile'' et récupère les données associées à cette adresse. La mémoire virtuelle est alors émulée par le système d'exploitation.
Le défaut de cette méthode est que l'accès au giga manquant est toujours très lent, parce qu'il se fait depuis le disque dur. D'autres techniques de mémoire virtuelle logicielle font beaucoup mieux, mais nous allons les passer sous silence, vu qu'on peut faire mieux, avec l'aide du matériel.
L'idée est de charger les données dont le programme a besoin dans la RAM, et de déplacer les autres sur le disque dur. Par exemple, imaginons la situation suivante : un programme a besoin de 4 gigas de mémoire, mais ne dispose que de 2 gigas de mémoire installée. On peut imaginer découper l'espace d'adressage en 2 blocs de 2 gigas, qui sont chargés à la demande. Si le programme accède aux adresses basses, on charge les 2 gigas d'adresse basse en RAM. S'il accède aux adresses hautes, on charge les 2 gigas d'adresse haute dans la RAM après avoir copié les adresses basses sur le ''swapfile''.
On perd du temps dans les copies de données entre RAM et ''swapfile'', mais on gagne en performance vu que tous les accès mémoire se font en RAM. Du fait de la localité temporelle, le programme utilise les données chargées depuis le swapfile durant un bon moment avant de passer au bloc suivant. La RAM est alors utilisée comme une sorte de cache alors que les données sont placées dans une mémoire fictive représentée par l'espace d'adressage et qui correspond au disque dur.
Mais avec cette technique, la correspondance entre adresses du programme et adresses de la RAM change au cours du temps. Les adresses de la RAM correspondent d'abord aux adresses basses, puis aux adresses hautes, et ainsi de suite. On a donc besoin d'abstraction mémoire. Les correspondances entre adresse logique et physique peuvent varier avec le temps, ce qui permet de déplacer des données de la RAM vers le disque dur ou inversement. Une adresse logique peut correspondre à une adresse physique, ou bien à une donnée swappée sur le disque dur. C'est l'unité de traduction d'adresse qui se charge de faire la différence. Si une correspondance entre adresse logique et physique est trouvée, elle l'utilise pour traduire les adresses. Si aucune correspondance n'est trouvée, alors elle laisse la main au système d'exploitation pour charger la donnée en RAM. Une fois la donnée chargée en RAM, les correspondances entre adresse logique et physiques sont modifiées de manière à ce que l'adresse logique pointe vers la donnée chargée.
===L'extension d'adressage===
Une autre fonctionnalité rendue possible par l'abstraction mémoire est l{{'}}'''extension d'adressage'''. Elle permet d'utiliser plus de mémoire que l'espace d'adressage ne le permet. Par exemple, utiliser 7 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'extension d'adresse est l'exact inverse de la mémoire virtuelle. La mémoire virtuelle sert quand on a moins de mémoire que d'adresses, l'extension d'adresse sert quand on a plus de mémoire que d'adresses.
Il y a quelques chapitres, nous avions vu que c'est possible via la commutation de banques. Mais l'abstraction mémoire est une méthode alternative. Que ce soit avec la commutation de banques ou avec l'abstraction mémoire, les adresses envoyées à la mémoire doivent être plus longues que les adresses gérées par le processeur. La différence est que l'abstraction mémoire étend les adresses d'une manière différente.
Une implémentation possible de l'extension d'adressage fait usage de l'abstraction matérielle des processus. Chaque processus a son propre espace d'adressage, mais ceux-ci sont placés à des endroits différents dans la mémoire physique. Par exemple, sur un ordinateur avec 16 gigas de RAM, mais un espace d'adressage de 2 gigas, on peut remplir la RAM en lançant 8 processus différents et chaque processus aura accès à un bloc de 2 gigas de RAM, pas plus, il ne peut pas dépasser cette limite. Ainsi, chaque processus est limité par son espace d'adressage, mais on remplit la mémoire avec plusieurs processus, ce qui compense. Il s'agit là de l'implémentation la plus simple, qui a en plus l'avantage d'avoir la meilleure compatibilité logicielle. De simples changements dans le système d'exploitation suffisent à l'implémenter.
[[File:Extension de l'espace d'adressage.png|centre|vignette|upright=1.5|Extension de l'espace d'adressage]]
Un autre implémentation donne plusieurs espaces d'adressage différents à chaque processus, et a donc accès à autant de mémoire que permis par la somme de ces espaces d'adressage. Par exemple, sur un ordinateur avec 16 gigas de RAM et un espace d'adressage de 4 gigas, un programme peut utiliser toute la RAM en utilisant 4 espaces d'adressage distincts. On passe d'un espace d'adressage à l'autre en changeant la correspondance adresse logique-physique. L'inconvénient est que la compatibilité logicielle est assez mauvaise. Modifier l'OS ne suffit pas, les programmeurs doivent impérativement concevoir leurs programmes pour qu'ils utilisent explicitement plusieurs espaces d'adressage.
Les deux implémentations font usage des adresses logiques homonymes, mais à l'intérieur d'un même processus. Pour rappel, cela veut dire qu'une adresse logique correspond à des adresses physiques différentes. Rien d'étonnant vu qu'on utilise plusieurs espaces d'adressage, comme pour l'abstraction des processus, sauf que cette fois-ci, on a plusieurs espaces d'adressage par processus. Prenons l'exemple où on a 8 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'idée est qu'une adresse correspondra à une adresse dans les premiers 4 gigas, ou dans les seconds 4 gigas. L'adresse logique X correspondra d'abord à une adresse physique dans les premiers 4 gigas, puis à une adresse physique dans les seconds 4 gigas.
===La protection mémoire===
La '''protection mémoire''' regroupe des techniques très différentes les unes des autres, qui visent à améliorer la sécurité des programmes et des systèmes d'exploitation. Elles visent à empêcher de lire, d'écrire ou d'exécuter certaines portions de mémoire. Sans elle, les programmes peuvent techniquement lire ou écrire les données des autres, ce qui causent des situations non-prévues par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses.
La première technique de protection mémoire est l{{'}}'''isolation des processus''', qu'on a vue plus haut. Elle garantit que chaque programme n'a accès qu'à certaines portions dédiées de la mémoire et rend le reste de la mémoire inaccessible en lecture et en écriture. Le système d'exploitation attribue à chaque programme une ou plusieurs portions de mémoire rien que pour lui, auquel aucun autre programme ne peut accéder. Un tel programme, isolé des autres, s'appelle un '''processus''', d'où le nom de cet objectif. Toute tentative d'accès à une partie de la mémoire non autorisée déclenche une exception matérielle (rappelez-vous le chapitre sur les interruptions) qui est traitée par une routine du système d'exploitation. Généralement, le programme fautif est sauvagement arrêté et un message d'erreur est affiché à l'écran.
La '''protection de l'espace exécutable''' empêche d’exécuter quoique ce soit provenant de certaines zones de la mémoire. En effet, certaines portions de la mémoire sont censées contenir uniquement des données, sans aucun programme ou code exécutable. Cependant, des virus informatiques peuvent se cacher dedans et d’exécuter depuis celles-ci. Ou encore, des failles de sécurités peuvent permettre à un attaquant d'injecter du code exécutable malicieux dans des données, ce qui peut lui permettre de lire les données manipulées par un programme, prendre le contrôle de la machine, injecter des virus, ou autre. Pour éviter cela, le système d'exploitation peut marquer certaines zones mémoire comme n'étant pas exécutable. Toute tentative d’exécuter du code localisé dans ces zones entraîne la levée d'une exception ou d'une erreur et le système d'exploitation réagit en conséquence. Là encore, le processeur doit détecter les exécutions non autorisées.
D'autres méthodes de protection mémoire visent à limiter des actions dangereuses. Pour cela, le processeur et l'OS gèrent des '''droits d'accès''', qui interdisent certaines actions pour des programmes non-autorisés. Lorsqu'on exécute une opération interdite, le système d’exploitation et/ou le processeur réagissent en conséquence. La première technique de ce genre n'est autre que la séparation entre espace noyau et utilisateur, vue dans le chapitre sur les interruptions. Mais il y en a d'autres, comme nous le verrons dans ce chapitre.
==La MMU==
La traduction des adresses logiques en adresses physiques se fait par un circuit spécialisé appelé la '''''Memory Management Unit''''' (MMU), qui est souvent intégré directement dans l'interface mémoire. La MMU est souvent associée à une ou plusieurs mémoires caches, qui visent à accélérer la traduction d'adresses logiques en adresses physiques. En effet, nous verrons plus bas que la traduction d'adresse demande d'accéder à des tableaux, gérés par le système d'exploitation, qui sont en mémoire RAM. Aussi, les processeurs modernes incorporent des mémoires caches appelées des '''''Translation Lookaside Buffers''''', ou encore TLB. Nous nous pouvons pas parler des TLB pour le moment, car nous n'avons pas encore abordé le chapitre sur les mémoires caches, mais un chapitre entier sera dédié aux TLB d'ici peu.
[[File:MMU principle updated.png|centre|vignette|upright=2|MMU.]]
===Les MMU intégrées au processeur===
D'ordinaire, la MMU est intégrée au processeur. Et elle peut l'être de deux manières. La première en fait un circuit séparé, relié au bus d'adresse. La seconde fusionne la MMU avec l'unité de calcul d'adresse. La première solution est surtout utilisée avec une technique d'abstraction mémoire appelée la pagination, alors que l'autre l'est avec une autre méthode appelée la segmentation. La raison est que la traduction d'adresse avec la segmentation est assez simple : elle demande d'additionner le contenu d'un registre avec l'adresse logique, ce qui est le genre de calcul qu'une unité de calcul d'adresse sait déjà faire. La fusion est donc assez évidente.
Pour donner un exemple, l'Intel 8086 fusionnait l'unité de calcul d'adresse et la MMU. Précisément, il utilisait un même additionneur pour incrémenter le ''program counter'' et effectuer des calculs d'adresse liés à la segmentation. Il aurait été logique d'ajouter les pointeurs de pile avec, mais ce n'était pas possible. La raison est que le pointeur de pile ne peut pas être envoyé directement sur le bus d'adresse, vu qu'il doit passer par une phase de traduction en adresse physique liée à la segmentation.
[[File:80186 arch.png|centre|vignette|upright=2|Intel 8086, microarchitecture.]]
===Les MMU séparées du processeur, sur la carte mère===
Il a existé des processeurs avec une MMU externe, soudée sur la carte mère.
Par exemple, les processeurs Motorola 68000 et 68010 pouvaient être combinés avec une MMU de type Motorola 68451. Elle supportait des versions simplifiées de la segmentation et de la pagination. Au minimum, elle ajoutait un support de la protection mémoire contre certains accès non-autorisés. La gestion de la mémoire virtuelle proprement dit n'était possible que si le processeur utilisé était un Motorola 68010, en raison de la manière dont le 68000 gérait ses accès mémoire. La MMU 68451 gérait un espace d'adressage de 16 mébioctets, découpé en maximum 32 pages/segments. On pouvait dépasser cette limite de 32 segments/pages en combinant plusieurs 68451.
Le Motorola 68851 était une MMU qui était prévue pour fonctionner de paire avec le Motorola 68020. Elle gérait la pagination pour un espace d'adressage de 32 bits.
Les processeurs suivants, les 68030, 68040, et 68060, avaient une MMU interne au processeur.
==La relocation matérielle==
Pour rappel, les systèmes d'exploitation moderne permettent de lancer plusieurs programmes en même temps et les laissent se partager la mémoire. Dans le cas le plus simple, qui n'est pas celui des OS modernes, le système d'exploitation découpe la mémoire en blocs d'adresses contiguës qui sont appelés des '''segments''', ou encore des ''partitions mémoire''. Les segments correspondent à un bloc de mémoire RAM. C'est-à-dire qu'un segment de 259 mébioctets sera un segment continu de 259 mébioctets dans la mémoire physique comme dans la mémoire logique. Dans ce qui suit, un segment contient un programme en cours d'exécution, comme illustré ci-dessous.
[[File:CPT Memory Addressable.svg|centre|vignette|upright=2|Espace d'adressage segmenté.]]
Le système d'exploitation mémorise la position de chaque segment en mémoire, ainsi que d'autres informations annexes. Le tout est regroupé dans la '''table de segment''', un tableau dont chaque case est attribuée à un programme/segment. La table des segments est un tableau numéroté, chaque segment ayant un numéro qui précise sa position dans le tableau. Chaque case, chaque entrée, contient un '''descripteur de segment''' qui regroupe plusieurs informations sur le segment : son adresse de base, sa taille, diverses informations.
===La relocation avec la relocation matérielle : le registre de base===
Un segment peut être placé n'importe où en RAM physique et sa position en RAM change à chaque exécution. Le programme est chargé à une adresse, celle du début du segment, qui change à chaque chargement du programme. Et toutes les adresses utilisées par le programme doivent être corrigées lors du chargement du programme, généralement par l'OS. Cette correction s'appelle la '''relocation''', et elle consiste à ajouter l'adresse de début du segment à chaque adresse manipulée par le programme.
[[File:Relocation assistée par matériel.png|centre|vignette|upright=2.5|Relocation.]]
La relocation matérielle fait que la relocation est faite par le processeur, pas par l'OS. La relocation est intégrée dans le processeur par l'intégration d'un registre : le '''registre de base''', aussi appelé '''registre de relocation'''. Il mémorise l'adresse à laquelle commence le segment, la première adresse du programme. Pour effectuer la relocation, le processeur ajoute automatiquement l'adresse de base à chaque accès mémoire, en allant la chercher dans le registre de relocation.
[[File:Registre de base de segment.png|centre|vignette|upright=2|Registre de base de segment.]]
Le processeur s'occupe de la relocation des segments et le programme compilé n'en voit rien. Pour le dire autrement, les programmes manipulent des adresses logiques, qui sont traduites par le processeur en adresses physiques. La traduction se fait en ajoutant le contenu du registre de relocation à l'adresse logique. De plus, cette méthode fait que chaque programme a son propre espace d'adressage.
[[File:CPU created logical address presentation.png|centre|vignette|upright=2|Traduction d'adresse avec la relocation matérielle.]]
Le système d'exploitation mémorise les adresses de base pour chaque programme, dans la table des segments. Le registre de base est mis à jour automatiquement lors de chaque changement de segment. Pour cela, le registre de base est accessible via certaines instructions, accessibles en espace noyau, plus rarement en espace utilisateur. Le registre de segment est censé être adressé implicitement, vu qu'il est unique. Si ce n'est pas le cas, il est possible d'écrire dans ce registre de segment, qui est alors adressable.
===La protection mémoire avec la relocation matérielle : le registre limite===
Sans restrictions supplémentaires, la taille maximale d'un segment est égale à la taille complète de l'espace d'adressage. Sur les processeurs 32 bits, un segment a une taille maximale de 2^32 octets, soit 4 gibioctets. Mais il est possible de limiter la taille du segment à 2 gibioctets, 1 gibioctet, 64 Kibioctets, ou toute autre taille. La limite est définie lors de la création du segment, mais elle peut cependant évoluer au cours de l'exécution du programme, grâce à l'allocation mémoire.
Le processeur vérifie à chaque accès mémoire que celui-ci se fait bien dans le segment, qu'il ne déborde pas en-dehors. C'est possible qu'une adresse calculée sorte du segment, à la suite d'un bug ou d'une erreur de programmation, voire pire. Et le processeur doit éviter de tels '''débordements de segments'''.
A chaque accès mémoire, le processeur compare l'adresse accédée et vérifie qu'elle est bien dans le segment. Pour cela, il y a deux solutions. La première part du principe que le segment est placé en mémoire entre l'adresse de base et l'adresse limite. Il suffit de mémoriser l'adresse limite, l'adresse physique à ne pas dépasser. Une autre solution mémorise la taille du segment. La table des segments doit donc mémoriser, en plus de l'adresse de base : soit l'adresse maximale du segment, soit la taille du segment. D'autres informations peuvent être ajoutées, comme on le verra plus tard, mais cela complexifie la table des segments.
De plus, le processeur se voit ajouter un '''registre limite''', qui mémorise soit la taille du segment, soit l'adresse limite. Les deux registres, base et limite, sont utilisés pour vérifier si un programme qui lit/écrit de la mémoire en-dehors de son segment attitré : au-delà pour le registre limite, en-deça pour le registre de base. Le processeur vérifie pour chaque accès mémoire ne déborde pas au-delà du segment qui lui est allouée, ce qui n'arrive que si l'adresse d'accès dépasse la valeur du registre limite. Pour les accès en-dessous du segment, il suffit de vérifier si l'addition de relocation déborde, tout débordement signifiant erreur de protection mémoire.
[[File:Registre limite.png|centre|vignette|upright=2|Registre limite]]
Utiliser la taille du segment a de nombreux avantages. L'un d'entre eux se manifeste quand on déplace un segment en mémoire RAM. Le descripteur doit alors être mis à jour, et c'est plus facile quand on utilise la taille du segment. Si on utilise l'adresse limite, il faut mettre à jour à la fois l'adresse de base et l'adresse limite, dans le descripteur. En utilisant la taille, seule l'adresse de base doit être modifiée, vu que le segment n'a pas changé de taille. Un autre avantage est lié aux performances, mais nous devons faire un détour pour le comprendre.
La taille du segment est équivalent à l'adresse logique maximale possible. Par exemple, si un segment fait 256 octets, les adresses logiques possibles vont de 0 à 255, 256 est donc à la fois la taille du segment et l'adresse logique à partir de laquelle on déborde du segment. Et cela marche si on remplace 256 par n'importe quelle valeur : vu que le segment commence à l'adresse 0, sa taille en octets indique l'adresse de dépassement. Interpréter la taille du segment comme une adresse logique fait que les tests avec le registre limite sont plus performants, voyons pourquoi.
En utilisant l'adresse physique limite, on doit faire la relocation, puis comparer l'adresse calculée avec l'adresse limite. Le calcul d'adresse doit se faire avant la vérification. En utilisant la taille, on doit comparer l'adresse logique avec la taille du segment. On peut alors faire le test de débordement avant ou pendant la relocation. Les deux peuvent être faits en parallèle, dans deux circuits distincts, ce qui améliore un peu le temps d'un accès mémoire. Quelques processeurs en ont profité, mais on verra cela dans la section sur la segmentation.
[[File:Comparaison entre adresse limite physique et logique.png|centre|vignette|upright=2|Comparaison entre adresse limite physique et logique]]
Les registres de base et limite sont altérés uniquement par le système d'exploitation et ne sont accessibles qu'en espace noyau. Lorsque le système d'exploitation charge un programme, ou reprend son exécution, il charge les adresses de début/fin du segment dans ces registres. D'ailleurs, ces deux registres doivent être sauvegardés et restaurés lors de chaque interruption. Par contre, et c'est assez évident, ils ne le sont pas lors d'un appel de fonction. Cela fait une différence de plus entre interruption et appels de fonctions.
: Il faut noter que le registre limite et le registre de base sont parfois fusionnés en un seul registre, qui contient un descripteur de segment tout entier.
Pour information, la relocation matérielle avec un registre limite a été implémentée sur plusieurs processeurs assez anciens, notamment sur les anciens supercalculateurs de marque CDC. Un exemple est le fameux CDC 6600, qui implémentait cette technique.
===La mémoire virtuelle avec la relocation matérielle===
Il est possible d'implémenter la mémoire virtuelle avec la relocation matérielle. Pour cela, il faut swapper des segments entiers sur le disque dur. Les segments sont placés en mémoire RAM et leur taille évolue au fur et à mesure que les programmes demandent du rab de mémoire RAM. Lorsque la mémoire est pleine, ou qu'un programme demande plus de mémoire que disponible, des segments entiers sont sauvegardés dans le ''swapfile'', pour faire de la place.
Faire ainsi de demande juste de mémoriser si un segment est en mémoire RAM ou non, ainsi que la position des segments swappés dans le ''swapfile''. Pour cela, il faut modifier la table des segments, afin d'ajouter un '''bit de swap''' qui précise si le segment en question est swappé ou non. Lorsque le système d'exploitation veut swapper un segment, il le copie dans le ''swapfile'' et met ce bit à 1. Lorsque l'OS recharge ce segment en RAM, il remet ce bit à 0. La gestion de la position des segments dans le ''swapfile'' est le fait d'une structure de données séparée de la table des segments.
L'OS exécute chaque programme l'un après l'autre, à tour de rôle. Lorsque le tour d'un programme arrive, il consulte la table des segments pour récupérer les adresses de base et limite, mais il vérifie aussi le bit de swap. Si le bit de swap est à 0, alors l'OS se contente de charger les adresses de base et limite dans les registres adéquats. Mais sinon, il démarre une routine d'interruption qui charge le segment voulu en RAM, depuis le ''swapfile''. C'est seulement une fois le segment chargé que l'on connait son adresse de base/limite et que le chargement des registres de relocation peut se faire.
Un défaut évident de cette méthode est que l'on swappe des programmes entiers, qui sont généralement assez imposants. Les segments font généralement plusieurs centaines de mébioctets, pour ne pas dire plusieurs gibioctets, à l'époque actuelle. Ils étaient plus petits dans l'ancien temps, mais la mémoire était alors plus lente. Toujours est-il que la copie sur le disque dur des segments est donc longue, lente, et pas vraiment compatible avec le fait que les programmes s'exécutent à tour de rôle. Et ca explique pourquoi la relocation matérielle n'est presque jamais utilisée avec de la mémoire virtuelle.
===L'extension d'adressage avec la relocation matérielle===
Passons maintenant à la dernière fonctionnalité implémentable avec la traduction d'adresse : l'extension d'adressage. Elle permet d'utiliser plus de mémoire que ne le permet l'espace d'adressage. Par exemple, utiliser plus de 64 kibioctets de mémoire sur un processeur 16 bits. Pour cela, les adresses envoyées à la mémoire doivent être plus longues que les adresses gérées par le processeur.
L'extension des adresses se fait assez simplement avec la relocation matérielle : il suffit que le registre de base soit plus long. Prenons l'exemple d'un processeur aux adresses de 16 bits, mais qui est reliée à un bus d'adresse de 24 bits. L'espace d'adressage fait juste 64 kibioctets, mais le bus d'adresse gère 16 mébioctets de RAM. On peut utiliser les 16 mébioctets de RAM à une condition : que le registre de base fasse 24 bits, pas 16.
Un défaut de cette approche est qu'un programme ne peut pas utiliser plus de mémoire que ce que permet l'espace d'adressage. Mais par contre, on peut placer chaque programme dans des portions différentes de mémoire. Imaginons par exemple que l'on ait un processeur 16 bits, mais un bus d'adresse de 20 bits. Il est alors possible de découper la mémoire en 16 blocs de 64 kibioctets, chacun attribué à un segment/programme, qu'on sélectionne avec les 4 bits de poids fort de l'adresse. Il suffit de faire démarrer les segments au bon endroit en RAM, et cela demande juste que le registre de base le permette. C'est une sorte d'émulation de la commutation de banques.
==La segmentation en mode réel des processeurs x86==
Avant de passer à la suite, nous allons voir la technique de segmentation de l'Intel 8086, un des tout premiers processeurs 16 bits. Il s'agissait d'une forme très simple de segmentation, sans aucune forme de protection mémoire, ni même de mémoire virtuelle, ce qui le place à part des autres formes de segmentation. Il s'agit d'une amélioration de la relocation matérielle, qui avait pour but de permettre d'utiliser plus de 64 kibioctets de mémoire, ce qui était la limite maximale sur les processeurs 16 bits de l'époque.
Par la suite, la segmentation s'améliora et ajouta un support complet de la mémoire virtuelle et de la protection mémoire. L'ancienne forme de segmentation fut alors appelé le '''mode réel''', et la nouvelle forme de segmentation fut appelée le '''mode protégé'''. Le mode protégé rajoute la protection mémoire, en ajoutant des registres limite et une gestion des droits d'accès aux segments, absents en mode réel. De plus, il ajoute un support de la mémoire virtuelle grâce à l'utilisation d'une des segments digne de ce nom, table qui est absente en mode réel ! Pour le moment, voyons le mode réel.
===Les segments en mode réel===
[[File:Typical computer data memory arrangement.png|vignette|upright=0.5|Typical computer data memory arrangement]]
La segmentation en mode réel sépare la pile, le tas, le code machine et les données constantes dans quatre segments distincts.
* Le segment '''''text''''', qui contient le code machine du programme, de taille fixe.
* Le segment '''''data''''' contient des données de taille fixe qui occupent de la mémoire de façon permanente, des constantes, des variables globales, etc.
* Le segment pour la '''pile''', de taille variable.
* le reste est appelé le '''tas''', de taille variable.
Un point important est que sur ces processeurs, il n'y a pas de table des segments proprement dit. Chaque programme gére de lui-même les adresses de base des segments qu'il manipule. Il n'est en rien aidé par une table des segments gérée par le système d'exploitation.
===Les registres de segments en mode réel===
Chaque segment subit la relocation indépendamment des autres. Pour cela, le processeur intégre plusieurs registres de base, un par segment. Notons que cette solution ne marche que si le nombre de segments par programme est limité, à une dizaine de segments tout au plus. Les processeurs x86 utilisaient cette méthode, et n'associaient que 4 à 6 registres de segments par programme.
Les processeurs 8086 et le 286 avaient quatre registres de segment : un pour le code, un autre pour les données, et un pour la pile, le quatrième étant un registre facultatif laissé à l'appréciation du programmeur. Ils sont nommés CS (''code segment''), DS (''data segment''), SS (''Stack segment''), et ES (''Extra segment''). Le 386 rajouta deux registres, les registres FS et GS, qui sont utilisés pour les segments de données. Les processeurs post-386 ont donc 6 registres de segment.
Les registres CS et SS sont adressés implicitement, en fonction de l'instruction exécutée. Les instructions de la pile manipulent le segment associé à la pile, le chargement des instructions se fait dans le segment de code, les instructions arithmétiques et logiques vont chercher leurs opérandes sur le tas, etc. Et donc, toutes les instructions sont chargées depuis le segment pointé par CS, les instructions de gestion de la pile (PUSH et POP) utilisent le segment pointé par SS.
Les segments DS et ES sont, eux aussi, adressés implicitement. Pour cela, les instructions LOAD/STORE sont dupliquées : il y a une instruction LOAD pour le segment DS, une autre pour le segment ES. D'autres instructions lisent leurs opérandes dans un segment par défaut, mais on peut changer ce choix par défaut en précisant le segment voulu. Un exemple est celui de l'instruction CMPSB, qui compare deux octets/bytes : le premier est chargé depuis le segment DS, le second depuis le segment ES.
Un autre exemple est celui de l'instruction MOV avec un opérande en mémoire. Elle lit l'opérande en mémoire depuis le segment DS par défaut. Il est possible de préciser le segment de destination si celui-ci n'est pas DS. Par exemple, l'instruction MOV [A], AX écrit le contenu du registre AX dans l'adresse A du segment DS. Par contre, l'instruction MOV ES:[A], copie le contenu du registre AX das l'adresse A, mais dans le segment ES.
===La traduction d'adresse en mode réel===
La segmentation en mode réel a pour seul but de permettre à un programme de dépasser la limite des 64 KB autorisée par les adresses de 16 bits. L'idée est que chaque segment a droit à son propre espace de 64 KB. On a ainsi 64 Kb pour le code machine, 64 KB pour la pile, 64 KB pour un segment de données, etc. Les registres de segment mémorisaient la base du segment, les adresses calculées par l'ALU étant des ''offsets''. Ce sont tous des registres de 16 bits, mais ils ne mémorisent pas des adresses physiques de 16 bits, comme nous allons le voir.
[[File:Table des segments dans un banc de registres.png|centre|vignette|upright=2|Table des segments dans un banc de registres.]]
L'Intel 8086 utilisait des adresses de 20 bits, ce qui permet d'adresser 1 mébioctet de RAM. Vous pouvez vous demander comment on peut obtenir des adresses de 20 bits alors que les registres de segments font tous 16 bits ? Cela tient à la manière dont sont calculées les adresses physiques. Le registre de segment n'est pas additionné tel quel avec le décalage : à la place, le registre de segment est décalé de 4 rangs vers la gauche. Le décalage de 4 rangs vers la gauche fait que chaque segment a une adresse qui est multiple de 16. Le fait que le décalage soit de 16 bits fait que les segments ont une taille de 64 kibioctets.
{|class="wikitable"
|-
| <code> </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>+ </code><code style="background:#DDF">0001 0010 0011 0100</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </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. Au lieu d'utiliser un additionneur séparé pour le ''program counter'' et un autre pour le calcul de l'adresse physique, un seul additionneur est utilisé pour les deux. L'idée était de partager l'additionneur, qui servait à la fois à incrémenter le ''program counter'' et pour gérer la segmentation. 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|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> </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>+ </code><code style="background:#DDF">1111 1111 1111 1111</code>
| Décalage/''Offset''
| 16 bits
|-
| colspan="3" |
|-
| <code> </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 de nos PCs, à partir du 286 d'Intel. 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. A 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. A la place, les segments sont adressés de manière indirecte. A 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.
====L'implémentation de la protection mémoire sur le 386====
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 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 cache de descripteur de segment====
Pour améliorer les performances, le 386 intégrait 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 ce ache 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.
Un point important est que 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.
Il faut noter que ce cache avait un petit problème : il n'était pas cohérent avec la mémoire RAM. Par cohérent, on veut dire que si on modifie la table des segments en mémoire RAM, la copie du descripteur dans le cache n'est pas mise à jour. Le seul moyen pour la mettre à jour est de recharger de force le descripteur, ce qui demande de faire des manipulations assez complexes.
Les deux dernières propriétés étaient à l'origine d'une fonctionnalité non-prévue, celle 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'' 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.
La seule difficulté était de charger des descripteurs configurés dans le cache de descripteur. Il fallait pour cela utiliser des instructions non-documentées, comme l'instruction LOADALL.
====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. A 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]]
A 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. A 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. A la place, l'entrée contient un '''bit ''global''''', qui précise que cette correspondance est valide pour tous les processus. Le bit global accélère rapidement la traduction d'adresse pour l'accès au noyau.
Un défaut de cette méthode est que le partage d'une page entre plusieurs processus est presque impossible. Impossible de partager une page avec seulement certains processus et pas d'autres : soit on partage une page avec tous les processus, soit on l'alloue avec un seul processus.
====L'usage de plusieurs tables des pages====
Une solution alternative, plus simple, utilise une table des pages par processus lancé sur l'ordinateur, une table des pages unique par espace d'adressage. À chaque changement de processus, le registre qui mémorise la position de la table des pages est modifié pour pointer sur la bonne. C'est le système d'exploitation qui se charge de cette mise à jour.
Avec cette méthode, il est possible de partager une ou plusieurs pages entre plusieurs processus, en configurant les tables des pages convenablement. Les pages partagées sont mappées dans l'espace d'adressage de plusieurs processus, mais pas forcément au même endroit, pas forcément dans les mêmes adresses logiques. On peut placer la page partagée à l'adresse logique 0x0FFF pour un processus, à l'adresse logique 0xFF00 pour un autre processus, etc. Par contre, les entrées de la table des pages pour ces adresses pointent vers la même adresse physique.
[[File:Vm5.png|centre|vignette|upright=2|Tables des pages de plusieurs processus.]]
===La taille des pages===
La taille des pages varie suivant le processeur et le système d'exploitation et tourne souvent autour de 4 kibioctets. Les processeurs actuels gèrent plusieurs tailles différentes pour les pages : 4 kibioctets par défaut, 2 mébioctets, voire 1 à 4 gibioctets pour les pages les plus larges. Les pages de 4 kibioctets sont les pages par défaut, les autres tailles de page sont appelées des ''pages larges''. La taille optimale pour les pages dépend de nombreux paramètres et il n'y a pas de taille qui convienne à tout le monde. Certaines applications gagnent à utiliser des pages larges, d'autres vont au contraire perdre drastiquement en performance en les utilisant.
Le désavantage principal des pages larges est qu'elles favorisent la fragmentation mémoire. Si un programme veut réserver une portion de mémoire, pour une structure de donnée quelconque, il doit réserver une portion dont la taille est multiple de la taille d'une page. Par exemple, un programme ayant besoin de 110 kibioctets allouera 28 pages de 4 kibioctets, soit 120 kibioctets : 2 kibioctets seront perdus. Par contre, avec des pages larges de 2 mébioctets, on aura une perte de 2048 - 110 = 1938 kibioctets. En somme, des morceaux de mémoire seront perdus, car les pages sont trop grandes pour les données qu'on veut y mettre. Le résultat est que le programme qui utilise les pages larges utilisent plus de mémoire et ce d'autant plus qu'il utilise des données de petite taille. Un autre désavantage est qu'elles se marient mal avec certaines techniques d'optimisations de type ''copy-on-write''.
Mais l'avantage est que la traduction des adresses est plus performante. Une taille des pages plus élevée signifie moins de pages, donc des tables des pages plus petites. Et des pages des tables plus petites n'ont pas besoin de beaucoup de niveaux de hiérarchie, voire peuvent se limiter à des tables des pages simples, ce qui rend la traduction d'adresse plus simple et plus rapide. De plus, les programmes ont une certaine localité spatiale, qui font qu'ils accèdent souvent à des données proches. La traduction d'adresse peut alors profiter de systèmes de mise en cache dont nous parlerons dans le prochain chapitre, et ces systèmes de cache marchent nettement mieux avec des pages larges.
Il faut noter que la taille des pages est presque toujours une puissance de deux. Cela a de nombreux avantages, mais n'est pas une nécessité. Par exemple, le tout premier processeur avec de la pagination, le super-ordinateur Atlas, avait des pages de 3 kibioctets. L'avantage principal est que la traduction de l'adresse physique en adresse logique est trivial avec une puissance de deux. Cela garantit que l'on peut diviser l'adresse en un numéro de page et un ''offset'' : la traduction demande juste de remplacer les bits de poids forts par le numéro de page voulu. Sans cela, la traduction d'adresse implique des divisions et des multiplications, qui sont des opérations assez couteuses.
===Les entrées de la table des pages===
Avant de poursuivre, faisons un rapide rappel sur les entrées de la table des pages. Nous venons de voir que la table des pages contient de nombreuses informations : un bit ''valid'' pour la mémoire virtuelle, des bits ''dirty'' et ''accessed'' utilisés par l'OS, des bits de protection mémoire, un bit ''global'' et un potentiellement un identifiant de processus, etc. Étudions rapidement le format de la table des pages sur un processeur x86 32 bits.
* Elle contient d'abord le numéro de page physique.
* Les bits AVL sont inutilisés et peuvent être configurés à loisir par l'OS.
* Le bit G est le bit ''global''.
* Le bit PS vaut 0 pour une page de 4 kibioctets, mais est mis à 1 pour une page de 4 mébioctets dans le cas où le processus utilise des pages larges.
* Le bit D est le bit ''dirty''.
* Le bit A est le bit ''accessed''.
* Le bit PCD indique que la page ne peut pas être cachée, dans le sens où le processeur ne peut copier son contenu dans le cache et doit toujours lire ou écrire cette page directement dans la RAM.
* Le bit PWT indique que les écritures doivent mettre à jour le cache et la page en RAM (dans le chapitre sur le cache, on verra qu'il force le cache à se comporter comme un cache ''write-through'' pour cette page).
* Le bit U/S précise si la page est accessible en mode noyau ou utilisateur.
* Le bit R/W indique si la page est accessible en écriture, toutes les pages sont par défaut accessibles en lecture.
* Le bit P est le bit ''valid''.
[[File:PDE.png|centre|vignette|upright=2.5|Table des pages des processeurs Intel 32 bits.]]
==Comparaison des différentes techniques d'abstraction mémoire==
Pour résumer, l'abstraction mémoire permet de gérer : la relocation, la protection mémoire, l'isolation des processus, la mémoire virtuelle, l'extension de l'espace d'adressage, le partage de mémoire, etc. Elles sont souvent implémentées en même temps. Ce qui fait qu'elles sont souvent confondues, alors que ce sont des concepts sont différents. Ces liens sont résumés dans le tableau ci-dessous.
{|class="wikitable"
|-
!
! colspan="5" | Avec abstraction mémoire
! rowspan="2" | Sans abstraction mémoire
|-
!
! Relocation matérielle
! Segmentation en mode réel (x86)
! Segmentation, général
! Architectures à capacités
! Pagination
|-
! Abstraction matérielle des processus
| colspan="4" | Oui, relocation matérielle
| Oui, liée à la traduction d'adresse
| Impossible
|-
! Mémoire virtuelle
| colspan="2" | Non, sauf émulation logicielle
| colspan="3" | Oui, gérée par le processeur et l'OS
| Non, sauf émulation logicielle
|-
! Extension de l'espace d'adressage
| colspan="2" | Oui : registre de base élargi
| colspan="2" | Oui : adresse de base élargie dans la table des segments
| ''Physical Adress Extension'' des processeurs 32 bits
| Commutation de banques
|-
! Protection mémoire
| Registre limite
| Aucune
| colspan="2" | Registre limite, droits d'accès aux segments
| Gestion des droits d'accès aux pages
| Possible, méthodes variées
|-
! Partage de mémoire
| colspan="2" | Non
| colspan="2" | Segment partagés
| Pages partagées
| Possible, méthodes variées
|}
===Les différents types de segmentation===
La segmentation regroupe plusieurs techniques franchement différentes, qui auraient gagné à être nommées différemment. La principale différence est l'usage de registres de relocation versus des registres de sélecteurs de segments. L'usage de registres de relocation est le fait de la relocation matérielle, mais aussi de la segmentation en mode réel des CPU x86. Par contre, l'usage de sélecteurs de segments est le fait des autres formes de segmentation, architectures à capacité inclues.
La différence entre les deux est le nombre de segments. L'usage de registres de relocation fait que le CPU ne gère qu'un petit nombre de segments de grande taille. La mémoire virtuelle est donc rarement implémentée vu que swapper des segments de grande taille est trop long, l'impact sur les performances est trop important. Sans compter que l'usage de registres de base se marie très mal avec la mémoire virtuelle. Vu qu'un segment peut être swappé ou déplacée n'importe quand, il faut invalider les registres de base au moment du swap/déplacement, ce qui n'est pas chose aisée. Aucun processeur ne gère cela, les méthodes pour n'existent tout simplement pas. L'usage de registres de base implique que la mémoire virtuelle est absente.
La protection mémoire est aussi plus limitée avec l'usage de registres de relocation. Elle se limite à des registres limite, mais la gestion des droits d'accès est limitée. En théorie, la segmentation en mode réel pourrait implémenter une version limitée de protection mémoire, avec une protection de l'espace exécutable. Mais ca n'a jamais été fait en pratique sur les processeurs x86.
Le partage de la mémoire est aussi difficile sur les architectures avec des registres de base. L'absence de table des segments fait que le partage d'un segment est basiquement impossible sans utiliser des méthodes complétement tordues, qui ne sont jamais implémentées en pratique.
===Segmentation versus pagination===
Par rapport à la pagination, la segmentation a des avantages et des inconvénients. Tous sont liés aux propriétés des segments et pages : les segments sont de grande taille et de taille variable, les pages sont petites et de taille fixe.
L'avantage principal de la segmentation est sa rapidité. Le fait que les segments sont de grande taille fait qu'on a pas besoin d'équivalent aux tables des pages inversée ou multiple, juste d'une table des segments toute simple. De plus, les échanges entre table des pages/segments et registres sont plus rares avec la segmentation. Par exemple, si un programme utilise un segment de 2 gigas, tous les accès dans le segment se feront avec une seule consultation de la table des segments. Alors qu'avec la pagination, il faudra une consultation de la table des pages chaque bloc de 4 kibioctet, au minimum.
Mais les désavantages sont nombreux. Le système d'exploitation doit agencer les segments en RAM, et c'est une tâche complexe. Le fait que les segments puisse changer de taille rend le tout encore plus complexe. Par exemple, si on colle les segments les uns à la suite des autres, changer la taille d'un segment demande de réorganiser tous les segments en RAM, ce qui demande énormément de copies RAM-RAM. Une autre possibilité est de laisser assez d'espace entre les segments, mais cet espace est alors gâché, dans le sens où on ne peut pas y placer un nouveau segment.
Swapper un segment est aussi très long, vu que les segments sont de grande taille, alors que swapper une page est très rapide.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=L'espace d'adressage du processeur
| prevText=L'espace d'adressage du processeur
| next=Les méthodes de synchronisation entre processeur et périphériques
| nextText=Les méthodes de synchronisation entre processeur et périphériques
}}
</noinclude>
2nnood15i2j93uyiymsp4v3ns5qut70
Fonctionnement d'un ordinateur/La cohérence des caches
0
65959
763680
759460
2026-04-14T16:31:22Z
Mewtow
31375
763680
wikitext
text/x-wiki
Il est possible d'utiliser des caches avec la mémoire partagée, mais aussi sur les architectures distribuées et les architectures NUMA. Néanmoins, la gestion des caches peut poser des problèmes dits de '''cohérence des caches''' quand une donnée est présente dans plusieurs caches distincts.
{|
|[[File:SMP.svg|vignette|upright=1.5|Architecture à mémoire partagée avec des caches.]]
|[[File:NUMA-scheme-fr.svg|vignette|upright=2.0|Architecture distribuée avec des caches.]]
|}
[[File:Cohérence des caches.png|vignette|upright=1|Cohérence des caches]]
Introduisons la cohérence des caches par un exemple. Prenons deux cœurs/processeurs qui ont chacun une copie d'une donnée dans leur cache. Si un processeur modifie sa copie de la donnée, l'autre ne sera pas mise à jour. L'autre processeur manipule donc une donnée périmée : il n'y a pas '''cohérence des caches''', sous-entendu cohérence entre le contenu des différents caches.
[[File:Non Coherent.gif|centre|vignette|upright=2.5|Caches non-cohérents.]]
Or, il faut éviter cela sous peine d'avoir des problèmes. Mais avoir des caches cohérents demande d'avoir une valeur à jour de la donnée dans l'ensemble de ses caches, ce qui implique que les écritures dans un cache doivent être propagées dans les caches des autres processeurs. Il est possible de rendre des caches cohérents avec diverses méthodes qu'on va voir dans ce chapitre. Les deux animations ci-dessous montrent l'exemple de caches non-cohérents et de caches cohérents.
[[File:Coherent.gif|centre|vignette|upright=2.5|Caches cohérents.]]
Les problèmes de cohérence des caches se manifestent sur les architecture à mémoire partagée, c'est intuitif. Mais il y en a aussi pour certaines architectures distribuées, notamment sur les architectures NUMA. Et il peut même se manifester avec un seul processeur ! Le processeur fait face à un problème de cohérence de ses caches dès qu'un tier peut écrire dans la RAM, peu importe que le tier soit un autre processeur, un périphérique intégrant un contrôleur DMA, ou quoique ce soit d'autre. Nous avions déjà vu de tels problèmes avec les transferts DMA et les TLBs, mais sans en dire le nom. Par exemple, un transfert DMA modifie des données en RAM, mais les modifications ne sont pas transférées au cache. Pareil pour les TLBs : modifier la table des pages entraine une différence entre le contenu de la TLB et la table des pages en RAM.
[[File:Cache incoherence write.svg|centre|vignette|upright=2|Cohérence des caches avec DMA.]]
Et jusqu'ici, les problèmes de cohérences étaient réglés de deux manières différentes : en contournant le cache ou en l'invalidant. La '''cohérence par invalidation''' invalide le contenu du cache quand les circonstances l'exigent. Elle a l'avantage d'être simple à implémenter, pour un cout en performance qui dépend de la fréquence des invalidations. Plus les invalidations sont rares, plus le cout en performance est limité. Les candidats parfaits sont les TLB et les caches d'instruction, vu que leur contenu est rarement modifiés/écrit.
Pour les TLBs, modifier le contenu d'une table des pages est peu fréquent, ce qui fait que leur invalidation est souvent traitée par invalidation. La seule difficulté est que toute modification de la table des pages doit entrainer une invalidation des TLBs de tous les processeurs. Pour cela, le système d'exploitation envoie une interruption inter-processeurs spécifique, dont la routine invalide les TLBs. L'interruption est distribuée à tous les processeurs, sans exception, même au processeur envoyeur.
Mais pour les caches de données, invalider les caches de données à chaque écriture aurait un cout en performance trop important, vu qu'elles sont écrites très souvent. Pour complémenter l'invalidation des caches, les ingénieurs ont inventé des méthodes alternatives spécifiques aux caches de données. Elles permettent de détecter les données périmées et les mettre à jour. Le tout a été formalisé dans des '''protocoles de cohérence des caches'''.
Les protocoles de cohérence des caches marquent les lignes de cache comme invalides, si elles ont une donnée périmée. Les lignes de cache invalides ne peuvent pas être lues ou écrites. Toute lecture/écriture d'une ligne de cache invalide entraine un défaut de cache automatique. Les lignes de caches sont alors mises à jour avec une donnée valide. Le rôle d'un protocole de cohérence des caches est de détecter les copies périmées et de les mettre à jour automatiquement. Les problèmes de cohérence des caches surviennent dès qu'on a plusieurs caches dans une architecture parallèle, sauf pour quelques exceptions.
Tout protocole de cohérence des caches doit répondre à plusieurs problèmes.
* Premièrement : comment identifier les données invalides ?
* Deuxièmement : comment les autres caches sont-ils au courant qu'ils ont une donnée invalide ?
* Troisièmement : comment mettre à jour les données invalides ?
Les deux derniers problèmes impliquent une forme de communication entre les caches du processeur. Et pour cela, voyons par quel intermédiaire ils communiquent. Pour rappel, les processeurs/cœurs sont connectés entre eux soit avec un bus partagé, soit avec un réseau d'interconnexion assez complexe. Les deux situations n'utilisent pas les mêmes protocoles de cohérence des caches. Par contre, le premier problème implique d'associer un état valide/invalide à chaque ligne de cache, et c'est quelque chose d'indépendant de la communication entre processeurs. Voyons le premier problème avant de passer aux deux autres problèmes.
==Les états d'une ligne de cache : identifier les données invalides==
La cohérence des caches détecte les lignes de cache invalides. Pour cela, le protocole de cohérence des caches attribue à chaque ligne de cache un état, qui indique si la ligne de cache contient une donnée périmée, une donnée valide, ou autre.
Le '''protocole SI''' est le protocole de cohérence des caches le plus simple qui soit. Il ne gère que deux états : ligne de cache valide, ligne de cache invalide. L'état est encodé avec un '''''bit valid''''', un par ligne de cache, qui indique si la donnée est invalide ou non. Il est vérifié lors de chaque lecture. Voici décrit en image le fonctionnement de ce protocole. Un processeur garde une donnée valide tant qu'aucun autre processeur n'écrit dedans. Si cela arrive, la donnée devient invalide et toute lecture/écriture dedans se fait rejeter, un défaut de cache survient. Le processeur envoie alors une transaction sur le bus pour récupérer une donnée valide.
[[File:Diagramme d'état du protocole SI.png|centre|vignette|upright=2|Diagramme d'état du protocole SI.]]
Les protocoles plus élaborés ajoutent d'autres états pour des raisons d'optimisation. Les états en question sont encodés sur quelques bits, ajoutés à chaque ligne de cache, dans les bits de contrôle. Voyons ces protocoles plus élaborés dans ce qui suit.
===Le protocole MSI===
La cohérence des caches est très simple quand on a des caches ''write-trough'', mais ces derniers sont à l'origine de beaucoup d'écritures en mémoire qui saturent le bus. Aussi, on a inventé les caches ''Write-back'', où le contenu de la mémoire n'est pas cohérent avec le contenu du cache. Si on écrit dans la mémoire cache, le contenu de la mémoire RAM n'est pas mis à jour. On doit attendre que la donnée sorte du cache pour l'enregistrer en mémoire ou dans les niveaux de caches inférieurs (s'ils existent), ce qui évite de nombreuses écritures mémoires inutiles.
Divers protocoles de cohérence des caches existent pour les caches ''Write Back''. Le plus simple d'entre eux est le protocole MSI. Pour simplifier, il permet à des cœurs de réserver en lecture et écriture des lignes de cache, bien que la réservation soit temporaire. Elles utilise pour cela trois états : ''Modified'', ''Shared'' et ''Invalid''. L'état ''Shared'' change et correspond maintenant à une donnée à jour, présente dans plusieurs caches. L'état ''Modified'' correspond à une donnée à jour, mais dont les copies des autres caches sont périmées. L'état ''Invalid'' correspond encore une fois au cas où la donnée présente dans le cache est périmée.
Le protocole permet à un cœur de réserver une ligne de cache/donnée temporairement. Tout part d'une ligne de cache en état ''Shared'', c'est à dire accesible en lecture. Elle n'est pas réservée en écriture, mais elle est consultable par un ou plusieurs cœurs. Soit un seul coeur a chargé la donnée dans son cache, soit d'autres coeurs ont une copie de la donnée dans leur cache, peu importe. La seule contrainte est que l'on sait que tous les coeurs ont la même copie de la donnée, la cohérence des caches est donc respectée. Le protocole de cohérence doit faire en sorte de repasser dans l'état ''Shared'' après la moindre violation de cohérence des caches.
Le seul évènement capable de violer la cohérence des caches est la survenue d'une ou de plusieurs écritures. Pour qu'une écriture ait lieu, il faut qu'un cœur réserve la donnée en écriture. Pour cela, le ou les cœurs effectuent une écriture, ils tentent d'écrire dans la donnée voulue. S'il est le seul cœur à vouloir écrire à ce moment, il réservera la ligne de cache automatiquement. Mais si d'autres cœurs veulent modifier la donnée en même temps, une compétition avec les autres cœurs pour la réservation et un seul des cœurs gagnera la course. Quoiqu'il en soit, un cœur réservera la donnée et sa copie de la ligne de cache passe de l'état ''Shared'' à l'état ''Modified''. Les autres cœurs voient leur ligne de cache passer de l'état ''Shared'' à l'état ''Invalid''.
L'état ''Modified'' signifie que le coeur a réussît à réserver la ligne de cache aussi bien en écriture qu'en lecture. Seul lui peut lire ou écrire la donnée à sa guise. L'état ''Invalid'', quant à lui, sert à deux choses. Premièrement, il prévint qu'un autre cœur a réservé la donnée en écriture, ce qui bloque l'écriture dans la ligne de cache par tout autre cœur. Deuxièmement, il bloque aussi les lectures. Il prévint qu'un autre coeur a modifié la donnée, que la ligne contient actuellement une donnée périmée. Tout accès mémoire à une ligne de cache ''Invalid'' déclenche alors des mesures correctives afin de rétablir la cohérence des caches. Le contenu de la ligne de cache en état ''Modified'' est alors envoyé à tous les autres caches, la cohérence est rétablie, et les lignes de cache passent toutes en état ''Shared'', jusqu’à la prochaine écriture.
[[File:Diagramme d'état informel du protocole MSI.png|centre|vignette|upright=2|Diagramme d'état informel du protocole MSI.]]
Il est possible de modifier le fonctionnement précédent pour tenir compte d'un cas très spécifique : une écriture dans une ligne de cache marquée ''Invalid''. L'écriture est bloquée pour une ligne de cache en état ''Invalid'', car un autre cœur l'a réservée, elle est bloquée en attendant de recevoir la donnée valide. Mais vu qu'elle sera écrasée de toute manière, autant faire passer la ligne de cache directement en état ''Modified''.
L'optimisation est intéressante, mais il faut tenir compte du fait que dans ce cas, on a deux écritures qui se suivent dans le temps, réalisées par deux cœurs, appelons-les cœur 1 et cœur 2. La première écriture, faite par cœur 1, a marqué la ligne de cache comme invalide pour les autres, en ''Modified'' pour lui. La seconde, réalisée par le cœur 2, met sa ligne de cache en état ''Modified'' pour lui, ''Invalid'' pour les autres. Des problèmes de ''race condition'' peuvent survenir, le protocole doit gérer ce genre de cas où deux écritures se suivent de près. Dans ce cas, la dernière écriture doit fournir la donnée valide. La donnée qui était en état ''Modified'' dans le cœur 1 doit passer en état invalide, il perd la réservation en écriture. Le protocole précédent doit donc être adapté de manière à ajouter deux transitions : une de M vers I si un autre processeur écrit la donnée, et I vers M si le processeur écrit la donnée lui-même.
[[File:Modified-Shared-Invalid Protokoll.png|centre|vignette|upright=2|Diagramme du protocole MESI. Les abréviations PrRd et PrWr correspondent à des accès mémoire initiés par le processeur associé au cache, respectivement aux lectures et écritures. Les abréviations BusRd et BusRdx et Flush correspondent aux lectures, lectures exclusives ou écritures initiées par d'autres processeurs sur la ligne de cache.]]
Notons les trois état M, S et I, pour faire simple. Avec ce système, les lectures sont possibles seulement pour les lignes de cache en état M et S. Toute lecture d'une ligne de cache en état I se termine avec un défaut de cache. Le processeur envoie alors une requête GetS pour récupérer la donnée valide dans les caches d'un autre cœur, une donnée en étant S. L'état I force donc un défaut de cache lors des lectures. Pour les écritures, c'est différent. Les écritures dans une ligne de cache en état M sont automatiquement des succès de cache. Mais les lignes de cache en état S ou I sont traitées autrement. Une telle écriture envoie un signal GetM qui demande à réserver la ligne de cache en écriture. Si la requête est acceptée, la ligne de cache passe en état M, l'écriture est un succès de cache, les autres caches invalident leur copie de la donnée.
===Le protocole MESI===
[[File:Diagrama MESI.GIF|vignette|Diagramme du protocole MESI. Les abréviations PrRd et PrWr correspondent à des accès mémoire initiés par le processeur associé au cache, respectivement aux lectures et écritures. Les abréviations BusRd et BusRdx et Flush correspondent aux lectures, lectures exclusives ou écritures initiées par d'autres processeurs sur la ligne de cache.]]
Le protocole MSI n'est pas parfait. Un de ses défauts est que l'état '''Shared'' ne fait pas la différence entre une donnée présente dans un seul cache, et une donnée partagée par plusieurs cœurs. C'est un défaut car toute écriture déclenche des opérations correctives pour gérer la cohérence des caches, qui sont inutiles si un seul cœur a une copie de la donnée. Elles préviennent les autres caches pour rien en cas d'écriture dans une donnée non-partagée. Et les communications sur le bus ne sont pas gratuites.
Pour régler ce problème, on peut scinder l'état ''Shared'' en deux états : ''Exclusive'' si les autres processeurs ne possèdent pas de copie de la donnée, ''Shared'' si la donnée est partagée sur plusieurs cœurs. Le '''protocole MESI''' ainsi créé est identique au protocole MSI, avec quelques ajouts. Par exemple, si une donnée est lue la première fois par un cœur, la ligne de cache lue passe soit en ''Exclusive'' (les autres caches n'ont pas de copie de la donnée), soit en ''Shared'' (les autres caches en possèdent déjà une copie). Une donnée marquée ''Exclusive'' peut devenir ''Shared'' si la donnée est chargée dans le cache d'un autre processeur.
Comment le processeur fait-il pour savoir si les autres caches ont une copie de la donnée ? Pour cela, il faut ajouter un fil Shared sur le bus, qui sert à dire si un autre cache a une copie de la donnée. Lors de chaque lecture, l'adresse à lire sera envoyée à tous les caches, qui vérifieront s'ils possèdent une copie de la donnée. Une fois le résultat connu, chaque cache fournit un bit qui indique s'il a une copie de la donnée. Le bit Shared est obtenu en effectuant un OU logique entre toutes les versions du bit envoyé par les caches.
===Les protocoles MOSI et MOESI===
Les protocoles MESI et MSI ne permettent pas de transférer des données entre caches sans passer par la mémoire. Si le processeur demande la copie valide d'une donnée, tous les caches ayant la bonne version de la donnée répondent en même temps et la donnée est envoyée en plusieurs exemplaires ! Pour éviter ce problème, on doit rajouter un état supplémentaire : l'état ''Owned''. Si un processeur écrit dans son cache, il mettra sa donnée en Owned, mais les autres caches passeront leur donnée en version Modified, voire Shared une fois la mémoire mise à jour. Ainsi, un seul processeur pourra avoir une donnée dans l'état Owned et c'est lui qui est chargé de répondre aux demandes de mise à jour.
[[File:MOSI Processor Transactions.png|vignette|Protocole MOSI, transactions initiées par le processeur associé à la ligne de cache.]]
[[File:MOSI Bus Transactions.png|vignette|Protocole MOSI, transactions initiées par les autres processeurs.]]
Divers protocoles de cohérences des caches utilisent cet état Owned. Le premier d’entre eux est le '''protocole MOSI''', une variante du MESI où l'état exclusif est remplacé par l'état O. Lors d'une lecture, le cache vérifie si la lecture envoyée sur le bus correspond à une de ses données. Mais cette vérification va prendre du temps, et le processeur va devoir attendre un certain temps. Si au bout d'un certain temps, aucun cache n'a répondu, le processeur postule qu'aucun cache n'a la donnée demandée et va lire la donnée en mémoire. Ce temps est parfois fixé une fois pour toute lors de la création des processeurs, mais il peut aussi être variable, qui est géré comme suit :
* pour savoir si un cache contient une copie de la donnée demandée, chaque cache devra répondre en fournissant un bit ;
* quand le cache a terminé la vérification, il envoie un 1 sur une sortie spécifique, et un 0 sinon ;
* un ET logique est effectué entre tous les bits fournis par les différents caches, et le résultat final indique si tous les caches ont effectué leur vérification.
On peut aussi citer le '''protocole MOESI''', un protocole MESI auquel on a jouté l'état O.
==La cohérence des caches par espionnage du bus==
L''''espionnage du bus''' est la technique de cohérence du cache la plus simple à comprendre, du moins sur le principe. Aussi, nous allons la voir en premier. Avec elle, les caches sont reliés à bus partagé, qui communique avec les niveaux de cache inférieurs ou avec la RAM. Faisons d'abord un rappel sur ce qu'est ce bus partagé.
===Les bus partagés : rappels et implémentation de la cohérence des caches===
Le premier cas est celui où plusieurs processeurs/cœurs sont connectés à la mémoire RAM à travers un bus partagé. Les processeurs disposent d'une mémoire cache chacun et ils sont tous reliés à la mémoire RAM à travers le bus mémoire. Dans ce cas, tous les caches ont connectés au bus mémoire, qui sert de point de ralliement. Sans cohérence des caches, les communications se font dans le sens ''cache -> mémoire'' ou ''mémoire -> cache''. L'idée est alors de rajouter les communications ''cache- -> cache'' sur le bus mémoire. La mémoire ne répond pas forcément à de telles communications.
[[File:Architecture multicoeurs à bus partagé.png|centre|vignette|upright=2|Architecture multicoeurs à bus partagé]]
L'idée marche très bien, mais il faut l'adapter sur les processeurs multicœurs, qui ont une hiérarchie de cache assez complexe. Les caches partagés entre tous les cœurs ne posent aucun problème de cohérence car, avec eux, la donnée n'est présente qu'une seule fois dans tout le cache. Par contre, il faut gérer la cohérence entre caches dédiés.
[[File:Shared cache coherency.png|centre|vignette|upright=2|Cohérence et caches partagés. Vous remarquerez que sur le schéma, la mémoire RAM contient encore une autre version de la donnée car on utilise un cache ''Write Back'').]]
Le cas le plus simple est celui à deux niveaux de caches, avec des caches L1 dédiés et un cache L2 partagé entre tous les cœurs. Les caches L1 sont reliés au cache L2 partagé par un bus, qui n'a souvent pas de nom. Nous désignerons le bus entre le cache L1 et le cache L2 : '''bus partagé''', sous-entendu partagé entre tous les caches. Sans cohérence des caches, les transferts sur ce bus se font des caches L1 vers le cache L2 partagé, ou dans l'autre sens. Là encore, l'idée est de faire communiquer les caches L1 via le bus partagé.
[[File:Architecture multicoeurs à bus partagé entre caches L1 et L2.png|centre|vignette|upright=2|Architecture multicoeurs à bus partagé entre caches L1 et L2]]
Dans ce qui va suivre, quand nous parlerons de bus partagé, cela voudra dire : soit on parle du bus entre le L1 et le L2 sur un processeur multicœur, soit on parle du bus mémoire sur une architecture multi-processeur/multicœurs avec des caches dédiés. L'idée est que ce bus partagé existe avec ou sans cohérence des caches. Sans cohérence, il permet d'échanger des données entre deux niveaux de la hiérarchie mémoire : cache L1 vers L2, caches vers mémoire. Avec cohérence, le bus partagé interconnecte les caches entre eux.
===La mise à jour et l'invalidation sur écriture===
Les protocoles à '''espionnage du bus''' sont des protocoles où les transmissions entre caches se font sur le bus partagé. Le nom qui trahit l'idée qui se cache derrière cette technique : les caches interceptent les écritures sur le bus partagé, qu'elles proviennent ou non des autres processeurs. Quand un cœur/processeur écrit une donnée dans son cache L1, un signal est envoyé sur le bus partagé pour prévenir les autres caches, afin qu'ils invalident la ligne de cache concernée. De plus, il faut aussi mettre à jour les copies en question avec une donnée valide, ce qui passe là-encore par le bus partagé.
Voyons d'abord comment la mise à jour des copies se fait. La solution la plus simple pour cela est de propager les écritures dans les niveaux de cache inférieurs, jusqu'à la mémoire. L'écriture est alors transmise sur le bus partagé, les autres caches ont juste à récupérer la donnée et l'adresse écrite. Si l'adresse match une ligne de cache, la ligne de cache est mise à jour immédiatement avec la donnée envoyée sur le bus partagé. Le cache est alors mis à jour immédiatement, la ligne de cache n'a même pas le temps d'être invalidée. On parle alors de '''mise à jour sur écriture'''. Avec elle, les caches sont mis à jour automatiquement le plus tôt possible, il n'y a pas d'invalidation proprement dit.
La solution la plus simple pour cela est d'utiliser des caches ''write through'', qui propagent toute écriture dans les niveaux de cache inférieurs, jusqu'à la mémoire. Toute écriture dans une ligne de cache déclenche alors une mise à jour. Mais l'impact sur le débit binaire est alors très important. Aussi, la plupart des processeurs préfèrent utiliser des caches ''write back'' pour gagner en performance. Dans ce cas, les écritures qui sont propagées dépendent de l'état de la ligne de cache écrite. Écrire dans une ligne de cache en état ''Exclusive'' ne déclenchera pas de mise à jour, par exemple. Seules les écritures dans des lignes de cache en état ''Modified'' et ''Shared'' le feront.
Mais il est aussi possible de ne pas mettre à jour les lignes de cache à chaque écriture, et de préférer attendre. À la place, l'écriture est remplacée par un ''signal d'invalidation'' qui transmet uniquement l'adresse écrite et n'est pas pris en compte par le cache L2/L3 partagé. Il prévient les autres caches que telle ligne de cache a été modifiée et qu'il faut en invalider les copies. Le cache se contente de marquer la ligne de cache fautive comme invalide, mais ne la met pas à jour. Il y a alors '''invalidation sur écriture'''. La ligne de cache est mise à jour lors d'une lecture/écriture. Tout accès à une ligne de cache invalide entraine un défaut de cache et la donnée est chargée depuis la RAM et/ou depuis un autre cache.
Les deux techniques précédentes différent sur un point : la première met à jour la ligne de cache immédiatement, la seconde attend que la ligne de cache soit lue/écrite avant de mettre à jour, elle le fait au dernier moment. Le temps d'accès à une donnée est donc plus long avec ces derniers. Avec la mise à jour sur écriture, la donnée est mise à jour tout de suite, les accès ultérieurs ne déclenchent pas de défaut de cache, la donnée est accessible directement. Le temps d'accès moyen est donc plus faible.
Par contre, une partie des mises à jour sont inutiles, car les autres processeurs ne liront pas la donnée ou alors pas tout de suite. Avec l'invalidation, on met à jour les lignes de cache quand la donnée est lue, quand elle est réellement utilisée. Vu qu'une mise à jour est plus gourmande en énergie qu'une simple invalidation, on n'est pas forcément gagnant. Une mise à jour demande un accès complet au cache, avec écriture dans le plan mémoire. Alors d'une invalidation demande simplement de modifier les bits de contrôle d'une ligne de cache.
De nos jours, les caches utilisent l'invalidation sur écriture pour des raisons de complexité d'implémentation. Les protocoles à mise à jour sur écriture sont plus complexes à implémenter pour de sombres raisons de consistance mémoire.
===La mise à jour en cas d'invalidation sur écriture===
Avec l'invalidation sur écriture, la mise à jour des lignes de cache se fait séparément de l'invalidation. Et dans ce cas, il faut trouver où se trouve la donnée valide. La donnée valide est présente soit dans le cache d'un autre processeur, soit dans la mémoire RAM. La donnée valide est copiée depuis cette source, c'est une simple transaction mémoire. Voyons ce qu'il en est.
Le cas le plus simple est celui où plusieurs processeurs ont un cache chacun et sont reliés à une mémoire partagée. L'implémentation la plus simple lit la donnée valide depuis la RAM. Elle utilise alors des caches de type ''write-through'', où les écritures sont propagées la mémoire RAM. Il est possible de faire la même chose avec un cache ''write-back'', cependant. Mais il doit se comporter comme un cache ''write-through'' en propageant des écritures dans certaines situations. Dès qu'il écrit une ligne de cache en état ''Modified'' ou ''Shared'', il propagera l'écriture dans la RAM. Par contre, s'il a une ligne de cache en état ''Exclusive'', il n'a pas à propager les écritures dans le niveau de cache inférieur, il n'a pas à générer de signal d'invalidation du tout.
Une autre solution fait une copie depuis le cache qui contient la donnée valide, sans passer par la mémoire RAM. Les copies entre caches passent par le bus mémoire, la différence étant que la mémoire ne répond pas forcément à des transferts. Les caches étant plus rapides que la RAM, les copies entre caches sont plus rapides qu'un accès en mémoire RAM, même si les deux utilisent le même bus. L'état ''Owned'' permet d'optimiser cette situation : c'est le cache en état ''Owned'' qui répond alors à la requête mémoire.
[[File:Cohérence des caches write-through.png|centre|vignette|upright=3|Cohérence des caches ''write-through''.]]
Sur les architectures multicœurs, le cache partagé prend la place de la mémoire RAM et le bus partagé celui du bus mémoire. En général, les caches L2 sont inclusifs, à savoir que toute donnée écrite dans les caches L1 est présente dans le cache L2. La donnée valide est donc généralement lue depuis le cache partagé L2/L3.
Quand un cache a besoin d'une donnée valide, il envoie une '''requête de mise à jour''', qui demande aux autres caches s'ils ont une donnée valide. La donnée valide est en état ''Modified'' ou ''Owned''. Si un cache a une donnée dans cet état, il répond aux requêtes de mise à jour en envoyant la donnée voulue, éventuellement avec l'adresse. Le cache demandeur reçoit la donnée et met à jour sa ligne de cache. Les autres caches ne tiennent pas forcément compte de cette mise à jour. Du moins, pas avec "invalidation sur écriture" pure, mais certains caches sont un peu plus opportunistes et en profitent pour mettre à jour la ligne de cache au cas où. On peut les voir comme des intermédiaires entre invalidation et mise à jour sur écriture.
==La cohérence des caches à base de répertoires==
La cohérence des caches à base de répertoire utilise un '''répertoire''', qui mémorise l'état de chaque ligne de cache, mais aussi quel processeur dispose de telle ou telle ligne de cache. Les processeurs envoient des requêtes au répertoire avant chaque accès au cache. Le répertoire va alors soit répondre favorablement et autoriser l'accès au cache, soit l'interdire. Une réponse favorable signifie que le processeur a une donnée valide, une réponse défavorable signifie que la ligne de cache accédée doit être mise à jour. La cohérence des caches est donc gérée par le répertoire, qui est centralisé.
L'usage d'un répertoire est la norme sur les architectures NUMA, avec plusieurs ordinateurs reliés entre eux. Avec l'arrivée des processeurs multicœurs avec une hiérarchie de cache, les architectures à mémoire partagée se sont mises à s'inspirer des protocoles à répertoire pour gagner en performance. L'usage de caches de répertoire a permis d'utiliser la technique sur les processeurs multicœurs normaux, en complément de l'espionnage du bus.
===L'usage des répertoires sur les architectures NUMA===
Plusieurs architectures différentes utilisent une cohérence des caches par répertoire. Leur usage le plus intuitif est celui des architectures NUMA, avec plusieurs ordinateurs reliés entre eux via réseau. Sur les architectures NUMA, une donnée lue depuis la RAM d'un autre ordinateur peut être mise en mémoire cache. Et la moindre modification d'une copie doit être propagée via réseau sur les autres ordinateurs. Autant dire que la cohérence des caches est assez compliquée sur de telles architectures. Avec elles, chaque ordinateur a une copie du répertoire, pour gérer les données provenant des mémoires des autres ordinateurs.
[[File:Cohérence des cache à répertoire.jpg|centre|vignette|upright=2.5|Cohérence des caches - Répertoire décentralisé.]]
Les répertoires sont utilisés sur les architectures NUMA. Rappelons que sur de telles architectures, chaque processeur a une mémoire dédiée, mais qu'il a accès à toutes les mémoires de l'architecture à travers un réseau local. La mémoire dédiée au processeur est appelée la ''mémoire locale'', alors que celles des autres processeurs sont appelées les ''mémoires distantes''. Une partie de l'espace d'adressage est associé à la mémoire locale, mais le reste de l'espace d'adressage est associé aux mémoires distantes. Quand le processeur veut lire/écrire dans sa mémoire locale, le répertoire n'est pas consulté. Mais quand il veut lire/écrire en dehors, le répertoire est consulté pour gérer la cohérence des caches.
[[File:Espace d'adressage d'une architecture NUMA.png|centre|vignette|upright=2|Espace d'adressage d'une architecture NUMA.]]
Voyons maintenant comment le tout fonctionne. Nous allons prendre l'architecture illustrée ci-dessous. Elle contient plusieurs ordinateurs, chacun avec une mémoire locale reliée à un ou plusieurs processeurs multicœur aux hiérarchies de caches complexes. Nous n'allons pas nous intéresser aux caches L1/L2/L3, mais allons nous concentrer sur la mémoire cache L4, partagée entre plusieurs processeurs.
Il s'agit d'une mémoire cache spécialisée dans les accès aux mémoires distantes. Elle contient des données provenant des mémoires distantes, qui ont été chargées lors d'accès antérieurs. Nous allons l'appeler le '''cache distant''' pour simplifier les explications. Les accès aux mémoires distantes se font via le réseau local d'interconnexion, mais le résultat des lectures est mémorisé dans le cache distant, ce qui évite de faire un accès réseau à chaque lecture/écriture. Le répertoire est consulté pour tout accès au cache distant, mais n'est pas consulté pour l'accès aux caches L1/L2/L3 gérés par espionnage de bus. Il l'est seulement quand un ordinateur veut accéder aux données d'une mémoire distante, lors d'un accès en dehors de l'espace d'adressage de la mémoire locale.
[[File:Cc-NUMA System.svg|centre|vignette|upright=3|Architecture Ccc-NUMA.]]
Pour comprendre comment le répertoire et le cache L4 sont utilisés, partons du principe qu'un processeur veuille lire une donnée située dans une mémoire distante. Le cache L4 ne contient pas la donnée en question, ce qui déclenche un défaut de cache. Une transaction réseau est alors démarrée, pour rapatrier la donnée dans le cache L4. Le rapatriement peut simplement lire la donnée dans la mémoire principale si elle n'a pas été cachée, mais elle peut aussi aller la chercher dans les caches du processeur distant si nécessaire (comme illustré ci-dessous). Les répertoires sont alors mis à jour sur tous les ordinateurs. Le répertoire permet de désigner quel processeur a une copie de la donnée voulue, et d'aller la chercher sans demander à tous les processeurs.
[[File:Cc-NUMA Remote Memory Read.svg|centre|vignette|upright=2|Architecture Ccc-NUMA, lecture dans une mémoire distante.]]
Intuitivement, on se dit que les futurs accès à cette donnée rapatriée se font dans le cache L4. Mais dans les faits, la donnée peut être copiée dans le cache L3/L2, voire L1 du processeur local. Maintenant, imaginons que le processeur local modifie cette donnée distante. Les protocoles de cohérence des caches par espionnage de bus propagent un signal d'invalidation jusque dans le cache L4. Il faut ensuite propager le signal d'invalidation aux autres ordinateurs qui manipulent cette donnée. Pour cela, le répertoire est consulté pour récupérer la liste des processeurs qui ont cette donnée dans leur cache. Le signal d'invalidation est ensuite transmis par le réseau d'interconnexion, et arrive aux destinataires, qui invalident la donnée dans leurs caches.
[[File:Cc-NUMA Local Memory Read.svg|centre|vignette|upright=2|Architecture Ccc-NUMA, invalidation dans une mémoire distante.]]
===L'usage de répertoire sur les architectures multiprocesseurs et multicœurs===
L'espionnage de bus est simple à implémenter. Mais il a un défaut assez flagrant : les signaux d'invalidation et les requêtes de mise à jour passent par le bus partagé, idem pour les réponses à des signaux/requêtes. Le trafic sur le bus partagé est donc augmenté, assez fortement. Et au-delà de quelques processeurs, le trafic est trop important. L'usage d'état ''Owned'' et ''Exclusive'' améliore la situation, mais pas de quoi faire des miracles. Le problème de l'espionnage de bus est que les signaux et requêtes sont envoyés à tout le monde, grâce à l'usage d'un bus partagé. Et un bus partagé est une forme assez rudimentaire d'interconnexion, qui devient inefficace dès que le nombre de composants à connecter dessus est trop important.
De nos jours, les processeurs multicœurs récents remplacent partiellement le bus partagé par un '''réseau d'interconnexion intra-processeur''' plus complexe qu'un simple bus. De même, les cartes mères multi-processeurs incorporent un '''réseau d'interconnexion inter-processeur''', placé sur la carte mère, pour connecter les processeurs, la mémoire et les autres entrées-sorties. Le tout est illustré ci-dessous et vous remarquerez que le tout ressemble un peu à une architecture NUMA mais sans mémoire RAM, la RAM étant accédée via le réseau d'interconnexion comme l'est le GPU ou un cœur/processeur. Mais cela ne fait pas grande différence, car l'essentiel est que les mémoires caches soient là, de même que le réseau d'interconnexion. Les systèmes multicœurs/multiprocesseurs utilisent l'espionnage du bus à l'intérieur d'un cœur/processeur, mais utilisent la cohérence basée sur un répertoire entre les processeurs/cœurs.
[[File:Cohérence des caches avec un répertoire centralisé.png|centre|vignette|upright=2|Cohérence des caches avec un répertoire sur une architecture multicœurs.]]
Dans ce qui suit, le réseau d'interconnexions entre processeurs/cœurs sera volontairement laissé vague, car il peut être absolument n'importe quoi : un bus partagé, un réseau en anneau, un réseau ''crossbar'', etc. Par contre, il doit donner l'illusion que chaque cache est connecté à tous les autres via un ensemble de liaisons point-à-point. Il n'y a pas une transmission à la fois, plusieurs transmissions entre processeurs/cœurs peuvent avoir lieu en même temps. Les signaux d'invalidation sont envoyés uniquement aux processeurs/cœurs qui ont une copie de la donnée, pas à tous. Idem pour les requêtes de mise à jour, envoyées seulement au cache qui a une copie valide de la donnée. De fait, les transmissions pour la cohérence peuvent se faire en même temps que d'autres lectures/écritures normales, ce qui fait meilleur usage du débit mémoire.
Prenons comme exemple la situation du schéma précédent, où chaque cœur dispose d'un seul cache dédié, et voyons comment est gérée la cohérence des caches sur un tel processeur. Tout écriture dans le cache dédié entraine l'émission d'un signal d'invalidation pour préciser que la ligne de cache a été modifiée. L'envoi du signal d'invalidation est cependant géré par le répertoire, qui décide quels cœurs prévenir. Le répertoire configure alors le réseau d'interconnexion pour connecter entre eux les caches qui doivent l'être, pour propager les signaux d'invalidation et les requêtes de mise à jour. Les autres caches sont laissés libres et sont disponibles pour des lectures et écritures. On économise alors du débit binaire, au prix d'une perte en temps d'accès liée à l'interrogation du répertoire.
Prenons ensuite le cas d'une lecture dans un cache dédié, illustré ci-dessous. Si un défaut de cache a lieu, alors la ligne de cache n'est pas disponible dans le cache dédié. Elle doit alors être rappatriée depuis la mémoire RAM dans le pire des cas, ou depuis un autre cache. Pour savoir dans quel cas il est, le cœur interroge le répertoire. Il sait si la ligne mémoire est cachée ou non, et dans quel cache elle se trouve si elle l'est. Le répertoire démarre alors une requête de lecture au cache adéquat, via une transaction réseau. Le cache adéquat répond à la transaction par une autre transaction réseau, à destination du cœur qui a déclenché le défaut de cache. Là encore, les trois requêtes sont envoyées uniquement aux cœurs/caches qui en ont besoin.
[[File:Directory Scheme.png|centre|vignette|upright=2.5|Cohérence des caches avec un répertoire centralisé.]]
===Le contenu du répertoire : les implémentations à base de RAM et de caches===
Avant de poursuivre, un point de terminologie. Imaginez que la mémoire est découpée en blocs qui font la même taille qu'une ligne de cache, et qui sont alignés sur cette taille. Un bloc peut être copié dans le cache, éventuellement écrit par le processeur, et rapatrié en mémoire RAM une fois évincé du cache. Le bloc de mémoire sera appelé dans ce qui suit une '''ligne mémoire''', par analogie avec une ligne de cache. Un répertoire mémorise, pour chaque ligne mémoire, la liste des processeurs dont le cache qui en a une copie. Il peut aussi mémoriser l'état de la ligne de cache associée, mais ce n'est pas obligatoire, l'état peut être stocké dans la ligne de cache elle-même.
====Les répertoires basés sur une mémoire RAM====
L'implémentation la plus simple mémorise, pour chaque ligne mémoire, quel processeur l'a copié dans son cache. Pour cela, elle utilise un '''bit de présence''' par processeur, qui indique si le cache du processeur a une copie de la ligne mémoire : le énième bit de présence indique si le énième processeur a la ligne mémoire dans son cache. Elle a pour défaut de rapidement faire grossir le répertoire, dont la taille est proportionnelle au nombre de processeurs et de ligne mémoire.
[[File:Full bit vector format diagram.jpg|centre|vignette|upright=2|Full bit vector format diagram]]
Une autre solution mémorise la liste des processeurs autrement. Au lieu d'utiliser un bit par processeur, elle mémorise une '''liste de pointeurs''' vers ces processeurs. Elle attribue un numéro à chaque processeur et mémorise une liste de plusieurs numéros. L'avantage est que les numéros sont assez courts. Au lieu d'utiliser N bits pour N processeurs, chaque numéro ne fait que <math>\log_2{N}</math>. On gagne en mémoire si on autorise C copies, et que <math>C \times \log_2{N} \leq N</math>. Un exemple sera sans doute plus parlant. Prenons 256 processeurs. Un répertoire complet demandera 256 bits. Une liste de pointeurs encodera un numéro de processeur sur 8 bits, on est gagnant tant qu'on a moins de 256/8 = 32 processeurs par ligne de cache.
Par contre, la technique n'autorise qu'un nombre maximal de numéros par ligne mémoire. Si ce nombre est dépassé, le répertoire doit gérer la situation. Et il y a plusieurs solutions possibles pour cela. La première n'autorise réellement que N copies d'une même ligne de cache et invalide les copies en trop si le nombre est dépassé. Le cout en performance est cependant élevé. Les deux autres solutions autorisent à dépasser le nombre maximal de numéros. La première se débrouille en repassant en mode ''broadcast'' une fois le nombre de numéro maximal dépassé. Le répertoire envoie alors toute invalidation de la ligne mémoire concernée à tous les processeurs, vu que le répertoire n'a pas les moyens de savoir qui a une copie valide ou non. Une autre solution déclenche une exception matérielle qui gère la situation en logiciel.
{|class="wikitable"
|+ Répertoire complet et à liste de pointeur
|-
!
! Adresse
! État
! Liste des processeurs
|-
! Représentation complète
| rowspan="2" | 0xFFF1244
| rowspan="2" | ''Shared''
| 0001 1000 0001 1100
|-
! Liste de pointeurs
| 3, 4, 5 12, 13
|}
Les deux méthodes précédentes posent problème quand le nombre de processeur est élevé. Aussi, quelques optimisations permettent de limiter la casse. La première consiste à ne pas encoder toutes les informations nécessaires. Une idée possible est par exemple de regrouper les caches/processeurs par groupes de 2/3/4. Le répertoire mémorise alors quel groupe de cache/processeur contient une copie de la donnée, mais pas exactement quel cache dans ce groupe. Par exemple, avec des groupes de 2, il se peut qu'un processeur du groupe ait une copie de donnée, ou les deux, le répertoire ne fait pas la différence. Si la donnée est invalidée, il envoie des signaux d'invalidation aux deux processeurs du groupe.
[[File:Coarse bit vector format diagram.jpg|centre|vignette|upright=2|Coarse bit vector format diagram]]
Les répertoires vus plus haut sont basés sur une mémoire RAM. Elle contiennent une adresse par le ligne mémoire, l'adresse contient l'état de la ligne de cache et la liste des processeurs. La consultation du répertoire demande juste d'adresser le répertoire avec l'adresse de la ligne mémoire, ce qui peut être fait avant ou en parallèle de l'accès au cache. Mais le problème est que le répertoire est alors une mémoire très grosse. Elle est d'autant plus grosse qu'il y a de lignes mémoires et elle devient rapidement impraticable dès que la mémoire est un peu grosse.
====Les répertoires basés sur une mémoire cache====
Le répertoire est donc une structure assez grosse, ce qui est un problème. En pratique, les répertoires précédents sont tellement gros qu'on ne peut pas leur dédier une mémoire RAM. des tables qui sont mémorisées en mémoire RAM. Quelques processeurs ont réussi à le faire, notamment les premiers SGI Origin, qui avaient une banque de mémoire dédiée au répertoire. Mais la majeure partie des implémentations devaient placer le répertoire dans la mémoire RAM principale de l'ordinateur, un peu au même titre que la table des pages.
Mais quoi bon avoir des caches si leur accès demande de consulter un répertoire en mémoire RAM ? Et pourtant, nous avons déjà vu une situation similaire. Il est possible de faire une analogie avec la table des pages, encore que celle-ci soit grandement limitée. La table des pages est là aussi une structure très grosse, censée être consultée à chaque accès mémoire, qui mémorise des informations pour chaque page mémoire. Le répertoire est une structure similaire : remplacez ligne mémoire par page mémoire et vous aurez l'idée. Et vous vous souvenez certainement de la solution utilisée, à savoir l'usage de caches de traduction d'adresse, les fameuses TLBs.
Les caches en question sont appelés des '''caches de répertoire'''. Ils ont plusieurs entrées, mais moins qu'il n'y a de lignes mémoire. Une entrée peut être vide ou occupée. Une entrée occupée mémorise de quoi gérer la cohérence pour une ligne mémoire. Elle mémorise l'adresse de la ligne mémoire, l'état de la ligne et la liste des processeurs. Le cache de répertoire mémorise une partie du répertoire, celle en cours d'utilisation.
L'implémentation la plus simple conserve un répertoire en mémoire RAM, complété par un cache de répertoire par processeur. Les caches de répertoire sont consultés à chaque écriture, ce qui n'est pas un problème vu qu'ils sont très petits et ont un temps d'accès minuscule. Il y a plusieurs caches de répertoire, avec plusieurs niveaux de cache. Typiquement, il y a un cache de répertoire L1 associé au cache L1 de données, un cache de répertoire L2 pour le cache de données L2, etc.
Il faut cependant remarquer que le répertoire est une structure dont la majorité des entrées sont vides. En effet, les seules entrées occupées correspondent aux lignes mémoires présentes dans le cache du processeur. Il n'y a pas besoin de mémoriser autant d'entrées qu'il y a de lignes mémoires, seulement une entrée par ligne de cache. Cette simplification donne un répertoire très petit, dans lequel on a éliminé les entrées vides. Il s'agit d'une optimisation évident à laquelle vous aviez peut-être déjà pensé. Le tout est nommé avec le nom de ''inclusive directory cache'', que nous traduirons par '''cache de répertoire inclusif'''.
Il existe deux implémentations possibles de ce cache de répertoire inclusif. La première place le répertoire dans un cache dédié, séparé des autres caches, associé au contrôleur mémoire. Le fonctionnement est alors le suivant. Pour toutes les lignes mémoires dans le cache, le cache de répertoire possède une entrée associée, qui mémorise une copie ''tag'' de la ligne de cache et la liste des processeurs. Le ''tag'' en question n'est autre que le ''tag'' utilisé dans le cache L1 (si on suppose que le L2 est partagé). Si jamais la ligne mémoire n'est pas trouvée dans ce cache, alors on suppose qu'elle est en état ''Invalid'', ou qu'elle n'a pas été chargée depuis la mémoire. Les actions correctives sont les mêmes dans les deux cas. Un défaut est que lorsqu'une ligne de cache est évincée du cache L1, le répertoire doit être prévenu, ce qui ajoute de la complexité. En théorie, le cache devrait être un cache associatif par voie, avec un grand nombre de voies pour gérer des accès simultannés.
Une autre implémentation utilise des caches L1/L2 inclusifs. Ainsi, le répertoire a juste à mémoriser les lignes mémoires dans le cache partagé L2/L3. Mieux : il a juste à mémoriser la liste des processeurs dans la ligne de cache elle-même ! L'implémentation précédente recopiait les ''tag'' dans le répertoire, ce qui les dupliquait. Mais on n'avait pas le choix, car il fallait regrouper les ''tags'' des différents L1 dans un répertoire unique, on ne pouvait pas avoir un répertoire dispersé dans plusieurs caches L1. Mais avec des caches inclusifs, faire pareil avec les lignes de cache du L3 serait de la duplication inutile. Alors on fusionne le cache partagé L2/L3 avec le cache de répertoire. La difficulté est alors de maintenir des caches inclusifs, ce qui est plus compliqué que prévu.
Une autre solution consiste à mémoriser la liste des copies dans les caches eux-mêmes. Le répertoire n'identifie, pour chaque ligne de cache, qu'un seul processeur : plus précisément, il identifie la ligne de cache du processeur qui contient la copie. À l'intérieur d'une ligne de cache, la copie suivante (si elle existe) est indiquée dans les bits de contrôle. On parle de répertoires à base de caches.
[[File:Répertoire à base de caches.jpg|centre|vignette|upright=2|Répertoire à base de caches]]
==Les avantages et inconvénients des deux méthodes==
L'avantage de l'espionnage du bus est qu'il utilise peu de circuits et qu'il est facile à implémenter, car il réutilise un bus partagé qui est déjà là. Par contre, son désavantage majeur est que les écritures dans un cache sont propagées sur le bus partagé, au moins partiellement. Soit les écritures sont réellement propagées sur le bus partagée, soit un message d'invalidation est envoyé sur le bus partagé, peu importe : un message est envoyé aux autres caches pour dire qu'une écriture a eu lieu et qu'il faut potentiellement invalider des données. Le débit binaire du bus partagé est donc partiellement grignoté par les communications entre caches. Et le désavantage est d'autant plus grand qu'il y a de coeurs/processeurs, qui se partagent le bus partagé.
L'usage d'un répertoire résout ces problèmes. Le débit binaire du bus partagé n'est pas grignoté, car la liaison entre caches et répertoires est séparée. Par contre, le temps d'accès au cache est augmenté, car tout accès mémoire demande l'autorisation au répertoire. En soi, le problème est compensé par l'économie en débit binaire. Sur les architectures avec beaucoup de processeurs, le gain en débit binaire sur-compense la hausse du temps d'accès. Mais sur les architectures avec peu de cœurs, c'est l'inverse. En général, les architectures distribuées/NUMA utilisent des répertoires, alors que les architectures à mémoire partagée utilisent l'espionnage du bus. Le tout est résumé ci-dessous.
{|class="wikitable" style="text-align:center;"
|-
!
! Mémoire partagée
! Architectures NUMA
! Architecture distribuée
|-
! Invalidation du cache
| colspan="3" | Caches d'instruction et TLB
|-
! Espionnage du bus
| X
|
|
|-
! Répertoire de cohérence
| X
| X
| X
|}
Remarquez que l'espionnage du bus n'a de sens que sur les architectures à mémoire partagée, alors que l'usage de répertoire est plus générale.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les architectures à parallélisme de données
| next=Les sections critiques et le modèle mémoire
}}
</noinclude>
97djt2vou9c8e7wq6pscbkdx4vld7m3
763681
763680
2026-04-14T16:45:50Z
Mewtow
31375
763681
wikitext
text/x-wiki
Il est possible d'utiliser des caches avec la mémoire partagée, mais aussi sur les architectures distribuées et les architectures NUMA. Néanmoins, la gestion des caches peut poser des problèmes dits de '''cohérence des caches''' quand une donnée est présente dans plusieurs caches distincts.
[[File:Cohérence des caches.png|vignette|upright=1|Cohérence des caches]]
Introduisons la cohérence des caches par un exemple. Prenons deux cœurs/processeurs qui ont chacun une copie d'une donnée dans leur cache. Si un processeur modifie sa copie de la donnée, l'autre ne sera pas mise à jour. L'autre processeur manipule donc une donnée périmée : il n'y a pas '''cohérence des caches''', sous-entendu cohérence entre le contenu des différents caches.
[[File:Non Coherent.gif|centre|vignette|upright=2.5|Caches non-cohérents.]]
Or, il faut éviter cela sous peine d'avoir des problèmes. Mais avoir des caches cohérents demande d'avoir une valeur à jour de la donnée dans l'ensemble de ses caches, ce qui implique que les écritures dans un cache doivent être propagées dans les caches des autres processeurs. Il est possible de rendre des caches cohérents avec diverses méthodes qu'on va voir dans ce chapitre. Les deux animations ci-dessous montrent l'exemple de caches non-cohérents et de caches cohérents.
[[File:Coherent.gif|centre|vignette|upright=2.5|Caches cohérents.]]
Les problèmes de cohérence des caches se manifestent sur toutes les architectures multiprocesseur/multicœur, mais ils peuvent même se manifester avec un seul processeur ! Ils surviennent dès qu'un tiers peut écrire dans la RAM, peu importe que ce soit un autre processeur, un périphérique, un contrôleur DMA, ... Nous avions déjà vu de tels problèmes avec les transferts DMA et les TLBs, mais sans en dire le nom. Par exemple, un transfert DMA modifie des données en RAM, mais pas leurs copies dans le cache. Pareil pour les TLBs : modifier une entrée de la table des pages ne modifie pas sa copie dans la TLB.
Et jusqu'ici, ces problèmes de cohérences étaient réglés en invalidant le cache, quand les circonstances l'exigent. Elle est simple à implémenter, pour un cout en performance qui dépend de la fréquence des invalidations. Pour les TLBs, modifier le contenu d'une table des pages est peu fréquent, ce qui fait qu'elles sont des candidats parfaits pour la cohérence par invalidation. La seule difficulté est que toute modification de la table des pages doit entrainer une invalidation des TLBs de tous les processeurs. Pour cela, le système d'exploitation envoie une interruption inter-processeurs spécifique, dont la routine invalide les TLBs. L'interruption est distribuée à tous les processeurs, sans exception, même au processeur envoyeur.
Mais pour les caches de données, invalider les caches de données à chaque écriture aurait un cout en performance trop important, vu qu'elles sont écrites très souvent. Pour complémenter l'invalidation des caches, les ingénieurs ont inventé des méthodes alternatives spécifiques aux caches de données. Elles permettent de détecter les données périmées et les mettre à jour. Le tout a été formalisé dans des '''protocoles de cohérence des caches'''.
Les protocoles de cohérence des caches marquent les lignes de cache comme invalides, si elles ont une donnée périmée. Les lignes de cache invalides ne peuvent pas être lues ou écrites. Toute lecture/écriture d'une ligne de cache invalide entraine un défaut de cache automatique. Les lignes de caches sont alors mises à jour avec une donnée valide. Le rôle d'un protocole de cohérence des caches est de détecter les copies périmées et de les mettre à jour automatiquement. Les problèmes de cohérence des caches surviennent dès qu'on a plusieurs caches dans une architecture parallèle, sauf pour quelques exceptions.
Tout protocole de cohérence des caches doit répondre à plusieurs problèmes.
* Premièrement : comment identifier les données invalides ?
* Deuxièmement : comment les autres caches sont-ils au courant qu'ils ont une donnée invalide ?
* Troisièmement : comment mettre à jour les données invalides ?
Les deux derniers problèmes impliquent une forme de communication entre les caches du processeur. Et pour cela, voyons par quel intermédiaire ils communiquent. Pour rappel, les processeurs/cœurs sont connectés entre eux soit avec un bus partagé, soit avec un réseau d'interconnexion assez complexe. Les deux situations n'utilisent pas les mêmes protocoles de cohérence des caches. Par contre, le premier problème implique d'associer un état valide/invalide à chaque ligne de cache, et c'est quelque chose d'indépendant de la communication entre processeurs. Voyons le premier problème avant de passer aux deux autres problèmes.
==Les états d'une ligne de cache : identifier les données invalides==
La cohérence des caches détecte les lignes de cache invalides. Pour cela, le protocole de cohérence des caches attribue à chaque ligne de cache un état, qui indique si la ligne de cache contient une donnée périmée, une donnée valide, ou autre.
Le '''protocole SI''' est le protocole de cohérence des caches le plus simple qui soit. Il ne gère que deux états : ligne de cache valide, ligne de cache invalide. L'état est encodé avec un '''''bit valid''''', un par ligne de cache, qui indique si la donnée est invalide ou non. Il est vérifié lors de chaque lecture. Voici décrit en image le fonctionnement de ce protocole. Un processeur garde une donnée valide tant qu'aucun autre processeur n'écrit dedans. Si cela arrive, la donnée devient invalide et toute lecture/écriture dedans se fait rejeter, un défaut de cache survient. Le processeur envoie alors une transaction sur le bus pour récupérer une donnée valide.
[[File:Diagramme d'état du protocole SI.png|centre|vignette|upright=2|Diagramme d'état du protocole SI.]]
Les protocoles plus élaborés ajoutent d'autres états pour des raisons d'optimisation. Les états en question sont encodés sur quelques bits, ajoutés à chaque ligne de cache, dans les bits de contrôle. Voyons ces protocoles plus élaborés dans ce qui suit.
===Le protocole MSI===
La cohérence des caches est très simple quand on a des caches ''write-trough'', mais ces derniers sont à l'origine de beaucoup d'écritures en mémoire qui saturent le bus. Aussi, on a inventé les caches ''Write-back'', où le contenu de la mémoire n'est pas cohérent avec le contenu du cache. Si on écrit dans la mémoire cache, le contenu de la mémoire RAM n'est pas mis à jour. On doit attendre que la donnée sorte du cache pour l'enregistrer en mémoire ou dans les niveaux de caches inférieurs (s'ils existent), ce qui évite de nombreuses écritures mémoires inutiles.
Divers protocoles de cohérence des caches existent pour les caches ''Write Back''. Le plus simple d'entre eux est le protocole MSI. Pour simplifier, il permet à des cœurs de réserver en lecture et écriture des lignes de cache, bien que la réservation soit temporaire. Elles utilise pour cela trois états : ''Modified'', ''Shared'' et ''Invalid''. L'état ''Shared'' change et correspond maintenant à une donnée à jour, présente dans plusieurs caches. L'état ''Modified'' correspond à une donnée à jour, mais dont les copies des autres caches sont périmées. L'état ''Invalid'' correspond encore une fois au cas où la donnée présente dans le cache est périmée.
Le protocole permet à un cœur de réserver une ligne de cache/donnée temporairement. Tout part d'une ligne de cache en état ''Shared'', c'est à dire accesible en lecture. Elle n'est pas réservée en écriture, mais elle est consultable par un ou plusieurs cœurs. Soit un seul coeur a chargé la donnée dans son cache, soit d'autres coeurs ont une copie de la donnée dans leur cache, peu importe. La seule contrainte est que l'on sait que tous les coeurs ont la même copie de la donnée, la cohérence des caches est donc respectée. Le protocole de cohérence doit faire en sorte de repasser dans l'état ''Shared'' après la moindre violation de cohérence des caches.
Le seul évènement capable de violer la cohérence des caches est la survenue d'une ou de plusieurs écritures. Pour qu'une écriture ait lieu, il faut qu'un cœur réserve la donnée en écriture. Pour cela, le ou les cœurs effectuent une écriture, ils tentent d'écrire dans la donnée voulue. S'il est le seul cœur à vouloir écrire à ce moment, il réservera la ligne de cache automatiquement. Mais si d'autres cœurs veulent modifier la donnée en même temps, une compétition avec les autres cœurs pour la réservation et un seul des cœurs gagnera la course. Quoiqu'il en soit, un cœur réservera la donnée et sa copie de la ligne de cache passe de l'état ''Shared'' à l'état ''Modified''. Les autres cœurs voient leur ligne de cache passer de l'état ''Shared'' à l'état ''Invalid''.
L'état ''Modified'' signifie que le coeur a réussît à réserver la ligne de cache aussi bien en écriture qu'en lecture. Seul lui peut lire ou écrire la donnée à sa guise. L'état ''Invalid'', quant à lui, sert à deux choses. Premièrement, il prévint qu'un autre cœur a réservé la donnée en écriture, ce qui bloque l'écriture dans la ligne de cache par tout autre cœur. Deuxièmement, il bloque aussi les lectures. Il prévint qu'un autre coeur a modifié la donnée, que la ligne contient actuellement une donnée périmée. Tout accès mémoire à une ligne de cache ''Invalid'' déclenche alors des mesures correctives afin de rétablir la cohérence des caches. Le contenu de la ligne de cache en état ''Modified'' est alors envoyé à tous les autres caches, la cohérence est rétablie, et les lignes de cache passent toutes en état ''Shared'', jusqu’à la prochaine écriture.
[[File:Diagramme d'état informel du protocole MSI.png|centre|vignette|upright=2|Diagramme d'état informel du protocole MSI.]]
Il est possible de modifier le fonctionnement précédent pour tenir compte d'un cas très spécifique : une écriture dans une ligne de cache marquée ''Invalid''. L'écriture est bloquée pour une ligne de cache en état ''Invalid'', car un autre cœur l'a réservée, elle est bloquée en attendant de recevoir la donnée valide. Mais vu qu'elle sera écrasée de toute manière, autant faire passer la ligne de cache directement en état ''Modified''.
L'optimisation est intéressante, mais il faut tenir compte du fait que dans ce cas, on a deux écritures qui se suivent dans le temps, réalisées par deux cœurs, appelons-les cœur 1 et cœur 2. La première écriture, faite par cœur 1, a marqué la ligne de cache comme invalide pour les autres, en ''Modified'' pour lui. La seconde, réalisée par le cœur 2, met sa ligne de cache en état ''Modified'' pour lui, ''Invalid'' pour les autres. Des problèmes de ''race condition'' peuvent survenir, le protocole doit gérer ce genre de cas où deux écritures se suivent de près. Dans ce cas, la dernière écriture doit fournir la donnée valide. La donnée qui était en état ''Modified'' dans le cœur 1 doit passer en état invalide, il perd la réservation en écriture. Le protocole précédent doit donc être adapté de manière à ajouter deux transitions : une de M vers I si un autre processeur écrit la donnée, et I vers M si le processeur écrit la donnée lui-même.
[[File:Modified-Shared-Invalid Protokoll.png|centre|vignette|upright=2|Diagramme du protocole MESI. Les abréviations PrRd et PrWr correspondent à des accès mémoire initiés par le processeur associé au cache, respectivement aux lectures et écritures. Les abréviations BusRd et BusRdx et Flush correspondent aux lectures, lectures exclusives ou écritures initiées par d'autres processeurs sur la ligne de cache.]]
Notons les trois état M, S et I, pour faire simple. Avec ce système, les lectures sont possibles seulement pour les lignes de cache en état M et S. Toute lecture d'une ligne de cache en état I se termine avec un défaut de cache. Le processeur envoie alors une requête GetS pour récupérer la donnée valide dans les caches d'un autre cœur, une donnée en étant S. L'état I force donc un défaut de cache lors des lectures. Pour les écritures, c'est différent. Les écritures dans une ligne de cache en état M sont automatiquement des succès de cache. Mais les lignes de cache en état S ou I sont traitées autrement. Une telle écriture envoie un signal GetM qui demande à réserver la ligne de cache en écriture. Si la requête est acceptée, la ligne de cache passe en état M, l'écriture est un succès de cache, les autres caches invalident leur copie de la donnée.
===Le protocole MESI===
[[File:Diagrama MESI.GIF|vignette|Diagramme du protocole MESI. Les abréviations PrRd et PrWr correspondent à des accès mémoire initiés par le processeur associé au cache, respectivement aux lectures et écritures. Les abréviations BusRd et BusRdx et Flush correspondent aux lectures, lectures exclusives ou écritures initiées par d'autres processeurs sur la ligne de cache.]]
Le protocole MSI n'est pas parfait. Un de ses défauts est que l'état '''Shared'' ne fait pas la différence entre une donnée présente dans un seul cache, et une donnée partagée par plusieurs cœurs. C'est un défaut car toute écriture déclenche des opérations correctives pour gérer la cohérence des caches, qui sont inutiles si un seul cœur a une copie de la donnée. Elles préviennent les autres caches pour rien en cas d'écriture dans une donnée non-partagée. Et les communications sur le bus ne sont pas gratuites.
Pour régler ce problème, on peut scinder l'état ''Shared'' en deux états : ''Exclusive'' si les autres processeurs ne possèdent pas de copie de la donnée, ''Shared'' si la donnée est partagée sur plusieurs cœurs. Le '''protocole MESI''' ainsi créé est identique au protocole MSI, avec quelques ajouts. Par exemple, si une donnée est lue la première fois par un cœur, la ligne de cache lue passe soit en ''Exclusive'' (les autres caches n'ont pas de copie de la donnée), soit en ''Shared'' (les autres caches en possèdent déjà une copie). Une donnée marquée ''Exclusive'' peut devenir ''Shared'' si la donnée est chargée dans le cache d'un autre processeur.
Comment le processeur fait-il pour savoir si les autres caches ont une copie de la donnée ? Pour cela, il faut ajouter un fil Shared sur le bus, qui sert à dire si un autre cache a une copie de la donnée. Lors de chaque lecture, l'adresse à lire sera envoyée à tous les caches, qui vérifieront s'ils possèdent une copie de la donnée. Une fois le résultat connu, chaque cache fournit un bit qui indique s'il a une copie de la donnée. Le bit Shared est obtenu en effectuant un OU logique entre toutes les versions du bit envoyé par les caches.
===Les protocoles MOSI et MOESI===
Les protocoles MESI et MSI ne permettent pas de transférer des données entre caches sans passer par la mémoire. Si le processeur demande la copie valide d'une donnée, tous les caches ayant la bonne version de la donnée répondent en même temps et la donnée est envoyée en plusieurs exemplaires ! Pour éviter ce problème, on doit rajouter un état supplémentaire : l'état ''Owned''. Si un processeur écrit dans son cache, il mettra sa donnée en Owned, mais les autres caches passeront leur donnée en version Modified, voire Shared une fois la mémoire mise à jour. Ainsi, un seul processeur pourra avoir une donnée dans l'état Owned et c'est lui qui est chargé de répondre aux demandes de mise à jour.
[[File:MOSI Processor Transactions.png|vignette|Protocole MOSI, transactions initiées par le processeur associé à la ligne de cache.]]
[[File:MOSI Bus Transactions.png|vignette|Protocole MOSI, transactions initiées par les autres processeurs.]]
Divers protocoles de cohérences des caches utilisent cet état Owned. Le premier d’entre eux est le '''protocole MOSI''', une variante du MESI où l'état exclusif est remplacé par l'état O. Lors d'une lecture, le cache vérifie si la lecture envoyée sur le bus correspond à une de ses données. Mais cette vérification va prendre du temps, et le processeur va devoir attendre un certain temps. Si au bout d'un certain temps, aucun cache n'a répondu, le processeur postule qu'aucun cache n'a la donnée demandée et va lire la donnée en mémoire. Ce temps est parfois fixé une fois pour toute lors de la création des processeurs, mais il peut aussi être variable, qui est géré comme suit :
* pour savoir si un cache contient une copie de la donnée demandée, chaque cache devra répondre en fournissant un bit ;
* quand le cache a terminé la vérification, il envoie un 1 sur une sortie spécifique, et un 0 sinon ;
* un ET logique est effectué entre tous les bits fournis par les différents caches, et le résultat final indique si tous les caches ont effectué leur vérification.
On peut aussi citer le '''protocole MOESI''', un protocole MESI auquel on a jouté l'état O.
==La cohérence des caches par espionnage du bus==
L''''espionnage du bus''' est la technique de cohérence du cache la plus simple à comprendre, du moins sur le principe. Aussi, nous allons la voir en premier. Avec elle, les caches sont reliés à bus partagé, qui communique avec les niveaux de cache inférieurs ou avec la RAM. Faisons d'abord un rappel sur ce qu'est ce bus partagé.
===Les bus partagés : rappels et implémentation de la cohérence des caches===
Le premier cas est celui où plusieurs processeurs/cœurs sont connectés à la mémoire RAM à travers un bus partagé. Les processeurs disposent d'une mémoire cache chacun et ils sont tous reliés à la mémoire RAM à travers le bus mémoire. Dans ce cas, tous les caches ont connectés au bus mémoire, qui sert de point de ralliement. Sans cohérence des caches, les communications se font dans le sens ''cache -> mémoire'' ou ''mémoire -> cache''. L'idée est alors de rajouter les communications ''cache- -> cache'' sur le bus mémoire. La mémoire ne répond pas forcément à de telles communications.
[[File:Architecture multicoeurs à bus partagé.png|centre|vignette|upright=2|Architecture multicoeurs à bus partagé]]
L'idée marche très bien, mais il faut l'adapter sur les processeurs multicœurs, qui ont une hiérarchie de cache assez complexe. Les caches partagés entre tous les cœurs ne posent aucun problème de cohérence car, avec eux, la donnée n'est présente qu'une seule fois dans tout le cache. Par contre, il faut gérer la cohérence entre caches dédiés.
[[File:Shared cache coherency.png|centre|vignette|upright=2|Cohérence et caches partagés. Vous remarquerez que sur le schéma, la mémoire RAM contient encore une autre version de la donnée car on utilise un cache ''Write Back'').]]
Le cas le plus simple est celui à deux niveaux de caches, avec des caches L1 dédiés et un cache L2 partagé entre tous les cœurs. Les caches L1 sont reliés au cache L2 partagé par un bus, qui n'a souvent pas de nom. Nous désignerons le bus entre le cache L1 et le cache L2 : '''bus partagé''', sous-entendu partagé entre tous les caches. Sans cohérence des caches, les transferts sur ce bus se font des caches L1 vers le cache L2 partagé, ou dans l'autre sens. Là encore, l'idée est de faire communiquer les caches L1 via le bus partagé.
[[File:Architecture multicoeurs à bus partagé entre caches L1 et L2.png|centre|vignette|upright=2|Architecture multicoeurs à bus partagé entre caches L1 et L2]]
Dans ce qui va suivre, quand nous parlerons de bus partagé, cela voudra dire : soit on parle du bus entre le L1 et le L2 sur un processeur multicœur, soit on parle du bus mémoire sur une architecture multi-processeur/multicœurs avec des caches dédiés. L'idée est que ce bus partagé existe avec ou sans cohérence des caches. Sans cohérence, il permet d'échanger des données entre deux niveaux de la hiérarchie mémoire : cache L1 vers L2, caches vers mémoire. Avec cohérence, le bus partagé interconnecte les caches entre eux.
===La mise à jour et l'invalidation sur écriture===
Les protocoles à '''espionnage du bus''' sont des protocoles où les transmissions entre caches se font sur le bus partagé. Le nom qui trahit l'idée qui se cache derrière cette technique : les caches interceptent les écritures sur le bus partagé, qu'elles proviennent ou non des autres processeurs. Quand un cœur/processeur écrit une donnée dans son cache L1, un signal est envoyé sur le bus partagé pour prévenir les autres caches, afin qu'ils invalident la ligne de cache concernée. De plus, il faut aussi mettre à jour les copies en question avec une donnée valide, ce qui passe là-encore par le bus partagé.
Voyons d'abord comment la mise à jour des copies se fait. La solution la plus simple pour cela est de propager les écritures dans les niveaux de cache inférieurs, jusqu'à la mémoire. L'écriture est alors transmise sur le bus partagé, les autres caches ont juste à récupérer la donnée et l'adresse écrite. Si l'adresse match une ligne de cache, la ligne de cache est mise à jour immédiatement avec la donnée envoyée sur le bus partagé. Le cache est alors mis à jour immédiatement, la ligne de cache n'a même pas le temps d'être invalidée. On parle alors de '''mise à jour sur écriture'''. Avec elle, les caches sont mis à jour automatiquement le plus tôt possible, il n'y a pas d'invalidation proprement dit.
La solution la plus simple pour cela est d'utiliser des caches ''write through'', qui propagent toute écriture dans les niveaux de cache inférieurs, jusqu'à la mémoire. Toute écriture dans une ligne de cache déclenche alors une mise à jour. Mais l'impact sur le débit binaire est alors très important. Aussi, la plupart des processeurs préfèrent utiliser des caches ''write back'' pour gagner en performance. Dans ce cas, les écritures qui sont propagées dépendent de l'état de la ligne de cache écrite. Écrire dans une ligne de cache en état ''Exclusive'' ne déclenchera pas de mise à jour, par exemple. Seules les écritures dans des lignes de cache en état ''Modified'' et ''Shared'' le feront.
Mais il est aussi possible de ne pas mettre à jour les lignes de cache à chaque écriture, et de préférer attendre. À la place, l'écriture est remplacée par un ''signal d'invalidation'' qui transmet uniquement l'adresse écrite et n'est pas pris en compte par le cache L2/L3 partagé. Il prévient les autres caches que telle ligne de cache a été modifiée et qu'il faut en invalider les copies. Le cache se contente de marquer la ligne de cache fautive comme invalide, mais ne la met pas à jour. Il y a alors '''invalidation sur écriture'''. La ligne de cache est mise à jour lors d'une lecture/écriture. Tout accès à une ligne de cache invalide entraine un défaut de cache et la donnée est chargée depuis la RAM et/ou depuis un autre cache.
Les deux techniques précédentes différent sur un point : la première met à jour la ligne de cache immédiatement, la seconde attend que la ligne de cache soit lue/écrite avant de mettre à jour, elle le fait au dernier moment. Le temps d'accès à une donnée est donc plus long avec ces derniers. Avec la mise à jour sur écriture, la donnée est mise à jour tout de suite, les accès ultérieurs ne déclenchent pas de défaut de cache, la donnée est accessible directement. Le temps d'accès moyen est donc plus faible.
Par contre, une partie des mises à jour sont inutiles, car les autres processeurs ne liront pas la donnée ou alors pas tout de suite. Avec l'invalidation, on met à jour les lignes de cache quand la donnée est lue, quand elle est réellement utilisée. Vu qu'une mise à jour est plus gourmande en énergie qu'une simple invalidation, on n'est pas forcément gagnant. Une mise à jour demande un accès complet au cache, avec écriture dans le plan mémoire. Alors d'une invalidation demande simplement de modifier les bits de contrôle d'une ligne de cache.
De nos jours, les caches utilisent l'invalidation sur écriture pour des raisons de complexité d'implémentation. Les protocoles à mise à jour sur écriture sont plus complexes à implémenter pour de sombres raisons de consistance mémoire.
===La mise à jour en cas d'invalidation sur écriture===
Avec l'invalidation sur écriture, la mise à jour des lignes de cache se fait séparément de l'invalidation. Et dans ce cas, il faut trouver où se trouve la donnée valide. La donnée valide est présente soit dans le cache d'un autre processeur, soit dans la mémoire RAM. La donnée valide est copiée depuis cette source, c'est une simple transaction mémoire. Voyons ce qu'il en est.
Le cas le plus simple est celui où plusieurs processeurs ont un cache chacun et sont reliés à une mémoire partagée. L'implémentation la plus simple lit la donnée valide depuis la RAM. Elle utilise alors des caches de type ''write-through'', où les écritures sont propagées la mémoire RAM. Il est possible de faire la même chose avec un cache ''write-back'', cependant. Mais il doit se comporter comme un cache ''write-through'' en propageant des écritures dans certaines situations. Dès qu'il écrit une ligne de cache en état ''Modified'' ou ''Shared'', il propagera l'écriture dans la RAM. Par contre, s'il a une ligne de cache en état ''Exclusive'', il n'a pas à propager les écritures dans le niveau de cache inférieur, il n'a pas à générer de signal d'invalidation du tout.
Une autre solution fait une copie depuis le cache qui contient la donnée valide, sans passer par la mémoire RAM. Les copies entre caches passent par le bus mémoire, la différence étant que la mémoire ne répond pas forcément à des transferts. Les caches étant plus rapides que la RAM, les copies entre caches sont plus rapides qu'un accès en mémoire RAM, même si les deux utilisent le même bus. L'état ''Owned'' permet d'optimiser cette situation : c'est le cache en état ''Owned'' qui répond alors à la requête mémoire.
[[File:Cohérence des caches write-through.png|centre|vignette|upright=3|Cohérence des caches ''write-through''.]]
Sur les architectures multicœurs, le cache partagé prend la place de la mémoire RAM et le bus partagé celui du bus mémoire. En général, les caches L2 sont inclusifs, à savoir que toute donnée écrite dans les caches L1 est présente dans le cache L2. La donnée valide est donc généralement lue depuis le cache partagé L2/L3.
Quand un cache a besoin d'une donnée valide, il envoie une '''requête de mise à jour''', qui demande aux autres caches s'ils ont une donnée valide. La donnée valide est en état ''Modified'' ou ''Owned''. Si un cache a une donnée dans cet état, il répond aux requêtes de mise à jour en envoyant la donnée voulue, éventuellement avec l'adresse. Le cache demandeur reçoit la donnée et met à jour sa ligne de cache. Les autres caches ne tiennent pas forcément compte de cette mise à jour. Du moins, pas avec "invalidation sur écriture" pure, mais certains caches sont un peu plus opportunistes et en profitent pour mettre à jour la ligne de cache au cas où. On peut les voir comme des intermédiaires entre invalidation et mise à jour sur écriture.
==La cohérence des caches à base de répertoires==
La cohérence des caches à base de répertoire utilise un '''répertoire''', qui mémorise l'état de chaque ligne de cache, mais aussi quel processeur dispose de telle ou telle ligne de cache. Les processeurs envoient des requêtes au répertoire avant chaque accès au cache. Le répertoire va alors soit répondre favorablement et autoriser l'accès au cache, soit l'interdire. Une réponse favorable signifie que le processeur a une donnée valide, une réponse défavorable signifie que la ligne de cache accédée doit être mise à jour. La cohérence des caches est donc gérée par le répertoire, qui est centralisé.
L'usage d'un répertoire est la norme sur les architectures NUMA, avec plusieurs ordinateurs reliés entre eux. Avec l'arrivée des processeurs multicœurs avec une hiérarchie de cache, les architectures à mémoire partagée se sont mises à s'inspirer des protocoles à répertoire pour gagner en performance. L'usage de caches de répertoire a permis d'utiliser la technique sur les processeurs multicœurs normaux, en complément de l'espionnage du bus.
===L'usage des répertoires sur les architectures NUMA===
Plusieurs architectures différentes utilisent une cohérence des caches par répertoire. Leur usage le plus intuitif est celui des architectures NUMA, avec plusieurs ordinateurs reliés entre eux via réseau. Sur les architectures NUMA, une donnée lue depuis la RAM d'un autre ordinateur peut être mise en mémoire cache. Et la moindre modification d'une copie doit être propagée via réseau sur les autres ordinateurs. Autant dire que la cohérence des caches est assez compliquée sur de telles architectures. Avec elles, chaque ordinateur a une copie du répertoire, pour gérer les données provenant des mémoires des autres ordinateurs.
[[File:Cohérence des cache à répertoire.jpg|centre|vignette|upright=2.5|Cohérence des caches - Répertoire décentralisé.]]
Les répertoires sont utilisés sur les architectures NUMA. Rappelons que sur de telles architectures, chaque processeur a une mémoire dédiée, mais qu'il a accès à toutes les mémoires de l'architecture à travers un réseau local. La mémoire dédiée au processeur est appelée la ''mémoire locale'', alors que celles des autres processeurs sont appelées les ''mémoires distantes''. Une partie de l'espace d'adressage est associé à la mémoire locale, mais le reste de l'espace d'adressage est associé aux mémoires distantes. Quand le processeur veut lire/écrire dans sa mémoire locale, le répertoire n'est pas consulté. Mais quand il veut lire/écrire en dehors, le répertoire est consulté pour gérer la cohérence des caches.
[[File:Espace d'adressage d'une architecture NUMA.png|centre|vignette|upright=2|Espace d'adressage d'une architecture NUMA.]]
Voyons maintenant comment le tout fonctionne. Nous allons prendre l'architecture illustrée ci-dessous. Elle contient plusieurs ordinateurs, chacun avec une mémoire locale reliée à un ou plusieurs processeurs multicœur aux hiérarchies de caches complexes. Nous n'allons pas nous intéresser aux caches L1/L2/L3, mais allons nous concentrer sur la mémoire cache L4, partagée entre plusieurs processeurs.
Il s'agit d'une mémoire cache spécialisée dans les accès aux mémoires distantes. Elle contient des données provenant des mémoires distantes, qui ont été chargées lors d'accès antérieurs. Nous allons l'appeler le '''cache distant''' pour simplifier les explications. Les accès aux mémoires distantes se font via le réseau local d'interconnexion, mais le résultat des lectures est mémorisé dans le cache distant, ce qui évite de faire un accès réseau à chaque lecture/écriture. Le répertoire est consulté pour tout accès au cache distant, mais n'est pas consulté pour l'accès aux caches L1/L2/L3 gérés par espionnage de bus. Il l'est seulement quand un ordinateur veut accéder aux données d'une mémoire distante, lors d'un accès en dehors de l'espace d'adressage de la mémoire locale.
[[File:Cc-NUMA System.svg|centre|vignette|upright=3|Architecture Ccc-NUMA.]]
Pour comprendre comment le répertoire et le cache L4 sont utilisés, partons du principe qu'un processeur veuille lire une donnée située dans une mémoire distante. Le cache L4 ne contient pas la donnée en question, ce qui déclenche un défaut de cache. Une transaction réseau est alors démarrée, pour rapatrier la donnée dans le cache L4. Le rapatriement peut simplement lire la donnée dans la mémoire principale si elle n'a pas été cachée, mais elle peut aussi aller la chercher dans les caches du processeur distant si nécessaire (comme illustré ci-dessous). Les répertoires sont alors mis à jour sur tous les ordinateurs. Le répertoire permet de désigner quel processeur a une copie de la donnée voulue, et d'aller la chercher sans demander à tous les processeurs.
[[File:Cc-NUMA Remote Memory Read.svg|centre|vignette|upright=2|Architecture Ccc-NUMA, lecture dans une mémoire distante.]]
Intuitivement, on se dit que les futurs accès à cette donnée rapatriée se font dans le cache L4. Mais dans les faits, la donnée peut être copiée dans le cache L3/L2, voire L1 du processeur local. Maintenant, imaginons que le processeur local modifie cette donnée distante. Les protocoles de cohérence des caches par espionnage de bus propagent un signal d'invalidation jusque dans le cache L4. Il faut ensuite propager le signal d'invalidation aux autres ordinateurs qui manipulent cette donnée. Pour cela, le répertoire est consulté pour récupérer la liste des processeurs qui ont cette donnée dans leur cache. Le signal d'invalidation est ensuite transmis par le réseau d'interconnexion, et arrive aux destinataires, qui invalident la donnée dans leurs caches.
[[File:Cc-NUMA Local Memory Read.svg|centre|vignette|upright=2|Architecture Ccc-NUMA, invalidation dans une mémoire distante.]]
===L'usage de répertoire sur les architectures multiprocesseurs et multicœurs===
L'espionnage de bus est simple à implémenter. Mais il a un défaut assez flagrant : les signaux d'invalidation et les requêtes de mise à jour passent par le bus partagé, idem pour les réponses à des signaux/requêtes. Le trafic sur le bus partagé est donc augmenté, assez fortement. Et au-delà de quelques processeurs, le trafic est trop important. L'usage d'état ''Owned'' et ''Exclusive'' améliore la situation, mais pas de quoi faire des miracles. Le problème de l'espionnage de bus est que les signaux et requêtes sont envoyés à tout le monde, grâce à l'usage d'un bus partagé. Et un bus partagé est une forme assez rudimentaire d'interconnexion, qui devient inefficace dès que le nombre de composants à connecter dessus est trop important.
De nos jours, les processeurs multicœurs récents remplacent partiellement le bus partagé par un '''réseau d'interconnexion intra-processeur''' plus complexe qu'un simple bus. De même, les cartes mères multi-processeurs incorporent un '''réseau d'interconnexion inter-processeur''', placé sur la carte mère, pour connecter les processeurs, la mémoire et les autres entrées-sorties. Le tout est illustré ci-dessous et vous remarquerez que le tout ressemble un peu à une architecture NUMA mais sans mémoire RAM, la RAM étant accédée via le réseau d'interconnexion comme l'est le GPU ou un cœur/processeur. Mais cela ne fait pas grande différence, car l'essentiel est que les mémoires caches soient là, de même que le réseau d'interconnexion. Les systèmes multicœurs/multiprocesseurs utilisent l'espionnage du bus à l'intérieur d'un cœur/processeur, mais utilisent la cohérence basée sur un répertoire entre les processeurs/cœurs.
[[File:Cohérence des caches avec un répertoire centralisé.png|centre|vignette|upright=2|Cohérence des caches avec un répertoire sur une architecture multicœurs.]]
Dans ce qui suit, le réseau d'interconnexions entre processeurs/cœurs sera volontairement laissé vague, car il peut être absolument n'importe quoi : un bus partagé, un réseau en anneau, un réseau ''crossbar'', etc. Par contre, il doit donner l'illusion que chaque cache est connecté à tous les autres via un ensemble de liaisons point-à-point. Il n'y a pas une transmission à la fois, plusieurs transmissions entre processeurs/cœurs peuvent avoir lieu en même temps. Les signaux d'invalidation sont envoyés uniquement aux processeurs/cœurs qui ont une copie de la donnée, pas à tous. Idem pour les requêtes de mise à jour, envoyées seulement au cache qui a une copie valide de la donnée. De fait, les transmissions pour la cohérence peuvent se faire en même temps que d'autres lectures/écritures normales, ce qui fait meilleur usage du débit mémoire.
Prenons comme exemple la situation du schéma précédent, où chaque cœur dispose d'un seul cache dédié, et voyons comment est gérée la cohérence des caches sur un tel processeur. Tout écriture dans le cache dédié entraine l'émission d'un signal d'invalidation pour préciser que la ligne de cache a été modifiée. L'envoi du signal d'invalidation est cependant géré par le répertoire, qui décide quels cœurs prévenir. Le répertoire configure alors le réseau d'interconnexion pour connecter entre eux les caches qui doivent l'être, pour propager les signaux d'invalidation et les requêtes de mise à jour. Les autres caches sont laissés libres et sont disponibles pour des lectures et écritures. On économise alors du débit binaire, au prix d'une perte en temps d'accès liée à l'interrogation du répertoire.
Prenons ensuite le cas d'une lecture dans un cache dédié, illustré ci-dessous. Si un défaut de cache a lieu, alors la ligne de cache n'est pas disponible dans le cache dédié. Elle doit alors être rappatriée depuis la mémoire RAM dans le pire des cas, ou depuis un autre cache. Pour savoir dans quel cas il est, le cœur interroge le répertoire. Il sait si la ligne mémoire est cachée ou non, et dans quel cache elle se trouve si elle l'est. Le répertoire démarre alors une requête de lecture au cache adéquat, via une transaction réseau. Le cache adéquat répond à la transaction par une autre transaction réseau, à destination du cœur qui a déclenché le défaut de cache. Là encore, les trois requêtes sont envoyées uniquement aux cœurs/caches qui en ont besoin.
[[File:Directory Scheme.png|centre|vignette|upright=2.5|Cohérence des caches avec un répertoire centralisé.]]
===Le contenu du répertoire : les implémentations à base de RAM et de caches===
Avant de poursuivre, un point de terminologie. Imaginez que la mémoire est découpée en blocs qui font la même taille qu'une ligne de cache, et qui sont alignés sur cette taille. Un bloc peut être copié dans le cache, éventuellement écrit par le processeur, et rapatrié en mémoire RAM une fois évincé du cache. Le bloc de mémoire sera appelé dans ce qui suit une '''ligne mémoire''', par analogie avec une ligne de cache. Un répertoire mémorise, pour chaque ligne mémoire, la liste des processeurs dont le cache qui en a une copie. Il peut aussi mémoriser l'état de la ligne de cache associée, mais ce n'est pas obligatoire, l'état peut être stocké dans la ligne de cache elle-même.
====Les répertoires basés sur une mémoire RAM====
L'implémentation la plus simple mémorise, pour chaque ligne mémoire, quel processeur l'a copié dans son cache. Pour cela, elle utilise un '''bit de présence''' par processeur, qui indique si le cache du processeur a une copie de la ligne mémoire : le énième bit de présence indique si le énième processeur a la ligne mémoire dans son cache. Elle a pour défaut de rapidement faire grossir le répertoire, dont la taille est proportionnelle au nombre de processeurs et de ligne mémoire.
[[File:Full bit vector format diagram.jpg|centre|vignette|upright=2|Full bit vector format diagram]]
Une autre solution mémorise la liste des processeurs autrement. Au lieu d'utiliser un bit par processeur, elle mémorise une '''liste de pointeurs''' vers ces processeurs. Elle attribue un numéro à chaque processeur et mémorise une liste de plusieurs numéros. L'avantage est que les numéros sont assez courts. Au lieu d'utiliser N bits pour N processeurs, chaque numéro ne fait que <math>\log_2{N}</math>. On gagne en mémoire si on autorise C copies, et que <math>C \times \log_2{N} \leq N</math>. Un exemple sera sans doute plus parlant. Prenons 256 processeurs. Un répertoire complet demandera 256 bits. Une liste de pointeurs encodera un numéro de processeur sur 8 bits, on est gagnant tant qu'on a moins de 256/8 = 32 processeurs par ligne de cache.
Par contre, la technique n'autorise qu'un nombre maximal de numéros par ligne mémoire. Si ce nombre est dépassé, le répertoire doit gérer la situation. Et il y a plusieurs solutions possibles pour cela. La première n'autorise réellement que N copies d'une même ligne de cache et invalide les copies en trop si le nombre est dépassé. Le cout en performance est cependant élevé. Les deux autres solutions autorisent à dépasser le nombre maximal de numéros. La première se débrouille en repassant en mode ''broadcast'' une fois le nombre de numéro maximal dépassé. Le répertoire envoie alors toute invalidation de la ligne mémoire concernée à tous les processeurs, vu que le répertoire n'a pas les moyens de savoir qui a une copie valide ou non. Une autre solution déclenche une exception matérielle qui gère la situation en logiciel.
{|class="wikitable"
|+ Répertoire complet et à liste de pointeur
|-
!
! Adresse
! État
! Liste des processeurs
|-
! Représentation complète
| rowspan="2" | 0xFFF1244
| rowspan="2" | ''Shared''
| 0001 1000 0001 1100
|-
! Liste de pointeurs
| 3, 4, 5 12, 13
|}
Les deux méthodes précédentes posent problème quand le nombre de processeur est élevé. Aussi, quelques optimisations permettent de limiter la casse. La première consiste à ne pas encoder toutes les informations nécessaires. Une idée possible est par exemple de regrouper les caches/processeurs par groupes de 2/3/4. Le répertoire mémorise alors quel groupe de cache/processeur contient une copie de la donnée, mais pas exactement quel cache dans ce groupe. Par exemple, avec des groupes de 2, il se peut qu'un processeur du groupe ait une copie de donnée, ou les deux, le répertoire ne fait pas la différence. Si la donnée est invalidée, il envoie des signaux d'invalidation aux deux processeurs du groupe.
[[File:Coarse bit vector format diagram.jpg|centre|vignette|upright=2|Coarse bit vector format diagram]]
Les répertoires vus plus haut sont basés sur une mémoire RAM. Elle contiennent une adresse par le ligne mémoire, l'adresse contient l'état de la ligne de cache et la liste des processeurs. La consultation du répertoire demande juste d'adresser le répertoire avec l'adresse de la ligne mémoire, ce qui peut être fait avant ou en parallèle de l'accès au cache. Mais le problème est que le répertoire est alors une mémoire très grosse. Elle est d'autant plus grosse qu'il y a de lignes mémoires et elle devient rapidement impraticable dès que la mémoire est un peu grosse.
====Les répertoires basés sur une mémoire cache====
Le répertoire est donc une structure assez grosse, ce qui est un problème. En pratique, les répertoires précédents sont tellement gros qu'on ne peut pas leur dédier une mémoire RAM. des tables qui sont mémorisées en mémoire RAM. Quelques processeurs ont réussi à le faire, notamment les premiers SGI Origin, qui avaient une banque de mémoire dédiée au répertoire. Mais la majeure partie des implémentations devaient placer le répertoire dans la mémoire RAM principale de l'ordinateur, un peu au même titre que la table des pages.
Mais quoi bon avoir des caches si leur accès demande de consulter un répertoire en mémoire RAM ? Et pourtant, nous avons déjà vu une situation similaire. Il est possible de faire une analogie avec la table des pages, encore que celle-ci soit grandement limitée. La table des pages est là aussi une structure très grosse, censée être consultée à chaque accès mémoire, qui mémorise des informations pour chaque page mémoire. Le répertoire est une structure similaire : remplacez ligne mémoire par page mémoire et vous aurez l'idée. Et vous vous souvenez certainement de la solution utilisée, à savoir l'usage de caches de traduction d'adresse, les fameuses TLBs.
Les caches en question sont appelés des '''caches de répertoire'''. Ils ont plusieurs entrées, mais moins qu'il n'y a de lignes mémoire. Une entrée peut être vide ou occupée. Une entrée occupée mémorise de quoi gérer la cohérence pour une ligne mémoire. Elle mémorise l'adresse de la ligne mémoire, l'état de la ligne et la liste des processeurs. Le cache de répertoire mémorise une partie du répertoire, celle en cours d'utilisation.
L'implémentation la plus simple conserve un répertoire en mémoire RAM, complété par un cache de répertoire par processeur. Les caches de répertoire sont consultés à chaque écriture, ce qui n'est pas un problème vu qu'ils sont très petits et ont un temps d'accès minuscule. Il y a plusieurs caches de répertoire, avec plusieurs niveaux de cache. Typiquement, il y a un cache de répertoire L1 associé au cache L1 de données, un cache de répertoire L2 pour le cache de données L2, etc.
Il faut cependant remarquer que le répertoire est une structure dont la majorité des entrées sont vides. En effet, les seules entrées occupées correspondent aux lignes mémoires présentes dans le cache du processeur. Il n'y a pas besoin de mémoriser autant d'entrées qu'il y a de lignes mémoires, seulement une entrée par ligne de cache. Cette simplification donne un répertoire très petit, dans lequel on a éliminé les entrées vides. Il s'agit d'une optimisation évident à laquelle vous aviez peut-être déjà pensé. Le tout est nommé avec le nom de ''inclusive directory cache'', que nous traduirons par '''cache de répertoire inclusif'''.
Il existe deux implémentations possibles de ce cache de répertoire inclusif. La première place le répertoire dans un cache dédié, séparé des autres caches, associé au contrôleur mémoire. Le fonctionnement est alors le suivant. Pour toutes les lignes mémoires dans le cache, le cache de répertoire possède une entrée associée, qui mémorise une copie ''tag'' de la ligne de cache et la liste des processeurs. Le ''tag'' en question n'est autre que le ''tag'' utilisé dans le cache L1 (si on suppose que le L2 est partagé). Si jamais la ligne mémoire n'est pas trouvée dans ce cache, alors on suppose qu'elle est en état ''Invalid'', ou qu'elle n'a pas été chargée depuis la mémoire. Les actions correctives sont les mêmes dans les deux cas. Un défaut est que lorsqu'une ligne de cache est évincée du cache L1, le répertoire doit être prévenu, ce qui ajoute de la complexité. En théorie, le cache devrait être un cache associatif par voie, avec un grand nombre de voies pour gérer des accès simultannés.
Une autre implémentation utilise des caches L1/L2 inclusifs. Ainsi, le répertoire a juste à mémoriser les lignes mémoires dans le cache partagé L2/L3. Mieux : il a juste à mémoriser la liste des processeurs dans la ligne de cache elle-même ! L'implémentation précédente recopiait les ''tag'' dans le répertoire, ce qui les dupliquait. Mais on n'avait pas le choix, car il fallait regrouper les ''tags'' des différents L1 dans un répertoire unique, on ne pouvait pas avoir un répertoire dispersé dans plusieurs caches L1. Mais avec des caches inclusifs, faire pareil avec les lignes de cache du L3 serait de la duplication inutile. Alors on fusionne le cache partagé L2/L3 avec le cache de répertoire. La difficulté est alors de maintenir des caches inclusifs, ce qui est plus compliqué que prévu.
Une autre solution consiste à mémoriser la liste des copies dans les caches eux-mêmes. Le répertoire n'identifie, pour chaque ligne de cache, qu'un seul processeur : plus précisément, il identifie la ligne de cache du processeur qui contient la copie. À l'intérieur d'une ligne de cache, la copie suivante (si elle existe) est indiquée dans les bits de contrôle. On parle de répertoires à base de caches.
[[File:Répertoire à base de caches.jpg|centre|vignette|upright=2|Répertoire à base de caches]]
==Les avantages et inconvénients des deux méthodes==
L'avantage de l'espionnage du bus est qu'il utilise peu de circuits et qu'il est facile à implémenter, car il réutilise un bus partagé qui est déjà là. Par contre, son désavantage majeur est que les écritures dans un cache sont propagées sur le bus partagé, au moins partiellement. Soit les écritures sont réellement propagées sur le bus partagée, soit un message d'invalidation est envoyé sur le bus partagé, peu importe : un message est envoyé aux autres caches pour dire qu'une écriture a eu lieu et qu'il faut potentiellement invalider des données. Le débit binaire du bus partagé est donc partiellement grignoté par les communications entre caches. Et le désavantage est d'autant plus grand qu'il y a de coeurs/processeurs, qui se partagent le bus partagé.
L'usage d'un répertoire résout ces problèmes. Le débit binaire du bus partagé n'est pas grignoté, car la liaison entre caches et répertoires est séparée. Par contre, le temps d'accès au cache est augmenté, car tout accès mémoire demande l'autorisation au répertoire. En soi, le problème est compensé par l'économie en débit binaire. Sur les architectures avec beaucoup de processeurs, le gain en débit binaire sur-compense la hausse du temps d'accès. Mais sur les architectures avec peu de cœurs, c'est l'inverse. En général, les architectures distribuées/NUMA utilisent des répertoires, alors que les architectures à mémoire partagée utilisent l'espionnage du bus. Le tout est résumé ci-dessous.
{|class="wikitable" style="text-align:center;"
|-
!
! Mémoire partagée
! Architectures NUMA
! Architecture distribuée
|-
! Invalidation du cache
| colspan="3" | Caches d'instruction et TLB
|-
! Espionnage du bus
| X
|
|
|-
! Répertoire de cohérence
| X
| X
| X
|}
Remarquez que l'espionnage du bus n'a de sens que sur les architectures à mémoire partagée, alors que l'usage de répertoire est plus générale.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les architectures à parallélisme de données
| next=Les sections critiques et le modèle mémoire
}}
</noinclude>
4xtlhuhuk7ra2m5r8kxsb98882qpzhp
Fonctionnement d'un ordinateur/Architectures multiprocesseurs et multicœurs
0
65962
763676
763647
2026-04-14T13:17:00Z
Mewtow
31375
763676
wikitext
text/x-wiki
Pour réellement tirer parti du parallélisme de taches, rien ne vaut l'utilisation de plusieurs processeurs et/ou de plusieurs cœurs, qui exécutent chacun un ou plusieurs programmes dans leur coin. Des solutions multiprocesseurs ont alors vu le jour pour rendre l'usage de plusieurs processeurs plus adéquat. Avant de poursuivre, nous allons voir les systèmes multiprocesseur à part des processeurs multicœurs. Il faut dire qu'utiliser plusieurs processeurs et avoir plusieurs cœurs sur la même puce, ce n'est pas la même chose. Particulièrement pour ce qui est de la mémoire cache.
Les '''systèmes multiprocesseur''' placent plusieurs processeurs sur la même carte mère. Ils sont courants dans les serveurs ou les ''data centers'', mais sont beaucoup plus rares pour les ordinateurs grand public. Il y a eu quelques systèmes multiprocesseur vendus au grand public dans les années 2000, certaines cartes mères avaient deux sockets pour mettre deux processeurs. Mais les logiciels et les systèmes d'exploitation grand public n'étaient pas adaptés pour, ce qui fait que la technologie est restée confidentielle.
Puis, en 2005, les '''processeurs multicœurs''' sont arrivés. Ils peuvent être vus comme un regroupement de plusieurs processeurs dans le même circuit intégré. Pour être plus précis, ils contiennent plusieurs ''cœurs'', chaque cœur pouvant exécuter un programme tout seul. Un cœur dispose de toute la machinerie électronique pour exécuter un programme, que ce soit un séquenceur d'instruction, des registres, une unité de calcul. Par contre, certains circuits du processeur sont partagés entre les cœurs, comme les circuits d’interfaçage avec la mémoire.
Les processeurs multicœurs sont devenus la norme dans les ordinateurs grand public et les logiciels et systèmes d'exploitation se sont adaptés. Suivant le nombre de cœurs présents dans notre processeur, celui-ci sera appelé un processeur double-cœur (deux cœurs), quadruple-cœur (4 cœurs), octuple-cœur (8 cœurs), etc. Les processeurs grand public ont généralement entre 8 et 16 cœurs, à l'heure où j'écris ces lignes (2025), rarement au-delà. Par contre, les processeurs pour serveurs dépassent la vingtaine de cœurs. Les serveurs utilisent souvent des architectures dites '''''many core''''', qui ont un très grand nombre de cœurs, plus d'une cinquantaine, voire plusieurs centaines ou milliers.
: Dans ce qui suit, nous utiliserons les termes "processeurs" et "cœurs" comme s'ils étaient synonymes. Tout ce qui vaut pour les systèmes multiprocesseur vaut aussi pour les systèmes multicœurs.
==Le partage des caches==
Quand on conçoit un processeur multicœur, il ne faut pas oublier ce qui arrive à la pièce maîtresse de tout processeur actuel : le cache ! Pour le moment nous allons oublier le fait que les processeurs ont une hiérarchie de caches, avec des caches L1, L2, L3 et autres. Nous allons partir du principe qu'un processeur simple cœur a un seul cache, et voir comment adapter le cache à la présence de plusieurs cœurs. Nous allons rapidement lever cette hypothèse, pour étudier le cas où un processeur multicœur a une hiérarchie de caches, mais seulement après avoir vu le cas le plus simple à un seul cache.
===Le partage des caches sans hiérarchie de caches : caches dédiés et partagés===
Avec un seul niveau de cache, sans hiérarchie, deux solutions sont possibles. La première consiste à garder un seul cache, et de le partager entre les cœurs. L'autre solution est de dupliquer le cache et d'utiliser un cache par cœur. Les deux solutions sont appelées différemment. On parle de '''caches dédiés''' si chaque cœur possède son propre cache, et de '''cache partagé''' avec un cache partagé entre tous les cœurs. Ces deux méthodes ont des inconvénients et des avantages.
{|
|[[File:Caches dédiés.png|vignette|Caches dédiés]]
|[[File:Caches partagés.png|vignette|Cache partagé]]
|}
Le premier point sur lequel comparer caches dédiés et partagés est celui de la capacité du cache. La quantité de mémoire cache que l'on peut placer dans un processeur est limitée, car le cache prend beaucoup de place, près de la moitié des circuits du processeur. Aussi, un processeur incorpore une certaine quantité de mémoire cache, qu'il faut répartir entre un ou plusieurs caches. Les caches dédiés et partagés ne donnent pas le même résultat. D'un côté, le cache partagé fait que toute la mémoire cache est dédiée au cache partagé, qui est très gros. De l'autre, on doit répartir la capacité du cache entre plusieurs caches séparés, individuellement plus petits. En conséquence, on a le choix entre un petit cache pour chaque processeur ou un gros cache partagé.
Le choix entre les deux n'est pas simple, mais doit tenir compte du fait que les programmes exécutés sur les cœurs n'ont pas les mêmes besoins. Certains programmes sont plus gourmands et demandent beaucoup de cache, alors que d'autres utilisent peu la mémoire cache. Avec un cache dédié, tous les programmes ont accès à la même quantité de cache, car les caches des différents cœurs sont de la même taille. Les caches dédiés étant assez petits, les programmes plus gourmands devront se débrouiller avec un petit cache, alors que les autres programmes auront du cache en trop.
À l'opposé, un cache partagé répartit le cache de manière optimale : un programme gourmand peut utiliser autant de cache qu'il veut, laissant juste ce qu'il faut aux programmes moins gourmands. le cache peut être répartit plus facilement selon les besoins des différents programmes.
[[File:Cache partagé contre cache dédié.png|centre|vignette|upright=2.5|Cache partagé contre cache dédié]]
Un autre avantage des caches partagés est quand plusieurs cœurs accèdent aux même données. C'est un cas très courant, souvent lié à l'usage de mémoire partagé ou de ''threads''. Avec des caches dédiés, chaque cœur a une copie des données partagées. Mais avec un cache partagé, il n'y a qu'une seule copie de chaque donnée, ce qui utilise moins de mémoire cache. Imaginons que l'on sait 8 caches dédiés de 8 Kibioctets, soit 64 kibioctets au total, comparé à un cache partagé de même capacité totale. Les doublons dans les caches dédiés réduiront la capacité mémoire utile, effective, comparé à un cache partagé. S'il y a 1 Kibioctet de mémoire partagé, 8 kibioctets seront utilisés pour stocker ces données en doublons, seulement 1 kibioctet sur un cache partagé. Ajoutons aussi que la cohérence des caches est grandement simplifiée avec l'usage d'un cache partagé, vu que les données ne sont pas dupliquées dans plusieurs caches.
Mais le partage du cache peut se transformer en inconvénient si les programmes entrent en compétition pour le cache, que ce soit pour y placer des données ou pour les accès mémoire. Deux programmes peuvent vouloir accéder au cache en même temps, voire carrément se marcher sur les pieds. La résolution des conflits d'accès au cache est résolu soit en prenant un cache multiport, avec un port dédié par cœur, soit par des mécanismes d'arbitrages avec des circuits dédiés. Le revers de la médaille tient au temps de latence. Plus un cache est gros, plus il est lent. En conséquence, des caches dédiés seront plus rapides qu'un gros cache partagé plus lent.
===Le partage des caches adapté à une hiérarchie de caches===
Dans la réalité, un processeur multicœur ne contient pas qu'un seul cache, mais une hiérarchie de caches avec des caches L1, L2 et L3, parfois L4. Dans cette hiérarchie, certains caches sont partagés entre plusieurs cœurs, les autres sont dédiés. Le cache L1 n'est jamais partagé, car il doit avoir un temps d'accès très faible. Pour les autres caches, tout dépend du processeur.
[[File:Dual Core Generic.svg|vignette|Cache L2 partagé.]]
Les premiers processeurs multicœurs commerciaux utilisaient deux niveaux de cache : des caches L1 dédiés et un cache L2 partagé. Le cache L2 partagé était relié aux caches L1, grâce à un système assez complexe d'interconnexions. Le cache de niveau L2 était souvent simple port, car les caches L1 se chargent de filtrer les accès aux caches de niveau inférieurs.
Les processeurs multicœurs modernes ont des caches L3 et même L4, de grande capacité, ce qui a modifié le partage des caches. Le cache de dernier niveau, à savoir le cache le plus proche de la mémoire, est systématiquement partagé, car son rôle est d'être un cache lent mais gros. Il s'agit le plus souvent d'un cache de L3, plus rarement L4. Sur certains processeurs multicœurs, le cache de dernier niveau n'est techniquement pas dans le cœur, mais fait partie d'un ensemble de circuits reliés, comme le contrôleur mémoire ou l'interface mémoire. Il fonctionne à une fréquence différente du processeur, n'a pas la même tension d'alimentation, etc.
Le cas du cache L2 dépend des architectures : il est partagé sur certains processeurs, dédié sur d'autres. Mais sur les processeurs modernes, c'est un cache dédié soit par cœur, soit pour un groupe de cœurs. Dans le cas le plus courant, chaque cache L2 est partagé entre plusieurs cœurs mais pas à tous. En effet, on peut limiter le partage du cache à quelques cœurs particuliers pour des raisons de performances.
[[File:Partage des caches sur un processeur multicoeurs.png|centre|vignette|upright=2.0|Partage des caches sur un processeur multicœur.]]
D'autres processeurs ont des caches L2 dédiés. Il s'agit surtout des processeurs multicœurs anciens, parmi les premières générations de processeurs multicœurs. Un exemple est celui de la microarchitecture Nehalem d'Intel. Il avait des caches L1 et L2 dédiés, mais un cache L3 partagé.
[[File:Nehalem EP.png|centre|vignette|upright=2.0|Partage des caches sur un processeur Intel d'architecture Nehalem.]]
===Les caches partagés centralisés et distribués===
Un point important est que quand on parle de cache partagé ou de cache dédié, on ne parle que de la manière dont les cœurs peuvent accéder au cache, pas de la manière dont le caches est réellement localisé sur la puce. En théorie, qui dit plusieurs caches dédiés signifie que l'on a vraiment plusieurs caches séparés sur la puce. Et chaque cache dédié est proche du cœur qui lui est attribué. Et pour les caches partagés unique, une portion de la puce de silicium contient le cache, que cette portion est un énorme bloc de transistors. Il est généralement placé au milieu de la puce ou sur un côté, histoire de facilement le connecter à tous les cœurs.
Mais pour les caches séparés, ce n'est pas toujours le cas. Avoir un cache énorme poserait des problèmes sur les architectures avec beaucoup de cœurs. En réalité, le cache est souvent découpé en plusieurs banques, reliées à un contrôleur du cache par un système d'interconnexion assez complexe. Les banques sont physiquement séparées, et il arrive qu'elles soient placées proche d'un cœur chacune. L'organisation des banques ressemble beaucoup à l'organisation des caches dédiés, avec une banque étant l'équivalent d'un cache dédié. La différence est que les cœurs peuvent lire et écrire dans toutes les banques, grâce au système d'interconnexion et au contrôleur de cache.
Tel était le cas sur les processeurs AMD Jaguar. Ils avaient un cache L2 de 2 mébioctets, partagés entre tous les cœurs, qui était composé de 4 banques de 512 Kibioctets. Les quatre banques du cache étaient reliées aux 4 cœurs par un réseaux d'interconnexion assez complexe.
[[File:AMDJaguarModule.png|centre|vignette|upright=2|AMD Jaguar Module]]
La différence entre les deux solutions pour les caches partagés porte le nom de cache centralisés versus distribués. Un gros cache unique sur la puce est un '''cache centralisé''', et c'est généralement un cache partagé. Mais un cache composé de plusieurs banques dispersées sur la puce est un '''cache distribué''', qui peut être aussi bien dédié que partagé.
===Les caches virtualisés===
Il faut noter que quelques processeurs utilisent cette technique pour fusionnent le cache L2 et le cache L3. Par exemple, les processeurs IBM Telum utilisent des caches L3 virtualisés, dans leurs versions récentes. Le processeur Telum 2 contient 10 caches L2 de 36 mébioctets chacun, soit 360 mébioctets de cache. L'idée est que ces 360 mébioctets sont partagés à la demande entre cache L2 dédié et cache L3. On parle alors de '''cache virtualisé'''.
Un cache de 36 mébioctet est associé à un cœur, auquel il est directement relié. Les cœurs n'utilisent pas tous leur cache dédié à 100% Il arrive que des cœurs aient des caches partiellement vides, alors que d'autres on un cache qui déborde. L'idée est que si un cœur a un cache plein, les données évincées du cache L2 privé sont déplacées dans le cache L2 d'un autre cœur, qui lui est partiellement vide. Le cache L2 en question est alors partitionné en deux : une portion pour les données associée à son cœur, une portion pour les données des L2 des autres cœurs.
Pour que la technique fonctionne, le processeur mesure le remplissage de chaque cache L2. De plus, il faut gérer la politique de remplacement des lignes de cache. Une ligne de cache évincée du cache doit être déplacé dans un autre L2, pas dans les niveaux de cache inférieur, ni dans la mémoire. Du moins, tant qu'il reste de la place dans le cache L3. De plus, une lecture/écriture dans le cache L3 demande de localiser le cache L2 contenant la donnée. Pour cela, les caches L2 sont tous consultés lors d'un accès au L3, c'est la solution la plus simple, elle marche très bien si le taux de défaut du cache L2 est faible.
Une telle optimisation ressemble beaucoup à un cache L2/L3 distribué, mais il y a quelques différences qui sont décrites dans le paragraphe précédent. Avec un L2 distribué, tout accès au L2 déclencherait une consultation de toutes les banques du L2. Avec un cache L3 virtualisé, ce n'est pas le cas. Le cache L2 associé au cœur est consulté, et c'est seulement en cas de défaut de cache que les autres caches L2 sont consultés. De plus, avec un cache L2 distribué, il n'y a pas de déplacement d'une ligne de cache entre deux banques, entre deux caches L2 physiques. Alors qu'avec un cache L3 virtualisé, c'est le cas en cas de remplacement d'une ligne de cache dans le cache L2.
Sur le processeur Telum 1, le partage du cache L2 est assez simple. Un cache L2 fait 32 mébioctets et est découpé en deux banques de 16 mébioctets. En temps normal, les premiers 16 mébioctets sont toujours associé au cache L2, au cœur associé. Les 16 mébioctets restants peuvent soit être attribués au cache L3, soit fusionnés avec les 16 premiers mébioctets. Dans le cas où le cœur associé est en veille, n'est absolument pas utilisé, les 32 mébioctets sont attribués au cache L3. Un partage assez simple, donc. Le partage du cache L2/L3 sur les processeurs Telum 2 n'est pas connu, il est supposé être plus flexible.
==Le réseau d'interconnexion entre cœurs==
Les systèmes avec plusieurs processeurs incorporent un réseau d'interconnexion pour connecter les processeurs entre eux, ainsi qu'à la mémoire RAM. Il s'agit d'un '''réseau d'interconnexion inter-processeur''', placé sur la carte mère. Les CPU multicœurs ont aussi un tel réseau d'interconnexion, pour relier les cœurs entre eux. La différence est que le réseau d'interconnexion est placé dans le processeur, pas sur la carte mère.
Les systèmes multi-cœurs modernes utilisent des réseaux d'interconnexion standardisés, les standards les plus communs étant l'HyperTransport, l'Intel QuickPath Interconnect, l'IBM Elastic Interface, le Intel Ultra Path Interconnect, l'Infinity Fabric, etc. Ils sont aussi utilisés pour faire communiquer entre eux plusieurs processeurs.
[[File:Architecture multicoeurs et réseau sur puce.png|centre|vignette|upright=1.5|Architecture multicoeurs et réseau sur puce]]
===Le bus partagé entre plusieurs cœurs===
Pour un faible nombre de coeurs/processeurs, la solution utilisée relie les processeurs entre eux grâce au bus mémoire. Le bus mémoire est donc un '''bus partagé''', avec tout ce que cela implique.
[[File:Architecture multicoeurs à bus partagé.png|centre|vignette|upright=2|Architecture multiprocesseurs à bus partagé]]
Pour les systèmes multicœurs, l'usage d'un bus partagé doit être adaptée pour tenir compte des caches partagés. Voyons d'abord le cas d'un CPU avec deux niveaux de cache, dont un cache L2 est partagé entre tous les cœurs. Les caches L1 sont reliés au cache L2 partagé par un bus, qui n'a souvent pas de nom. Nous désignerons le bus entre le cache L1 et le cache L2 : '''bus partagé''', sous-entendu partagé entre tous les caches. C'est lui qui sert à connecter les cœurs entre eux.
[[File:Architecture multicoeurs à bus partagé entre caches L1 et L2.png|centre|vignette|upright=2|Architecture multicoeurs à bus partagé entre caches L1 et L2]]
Un processeur multicœur typique a une architecture avec trois niveaux de cache (L1, L2 et L3), avec un niveau L1 dédié par cœur, un niveau L2 partiellement partagé et un L3 totalement partagé. Le bus partagé est alors difficile à décrire, mais il correspond à l'ensemble des bus qui connectent les caches L1 aux caches L2, et les caches L2 au cache L3. Il s'agit alors d'un ensemble de bus, plus que d'un bus partagé unique.
L'usage d'un bus partagé a cependant de nombreux défauts. Par exemple, les processeurs doivent se répartir l'accès au bus mémoire, il faut gérer le cas où deux processeurs accèdent au bus en même temps, etc. Pour cela, un composant dédié s'occupe de l'arbitrage entre processeurs. Il est généralement placé sur la carte mère de l'ordinateur, dans le ''chipset'', dans le pont nord, ou un endroit proche. D'autres défauts très importants seront abordés en détail dans le chapitre sur la cohérence des caches
[[File:Intel486 System Arbitration.png|centre|vignette|upright=2|Système avec deux Intel486 - Arbitrage du bus mémoire.]]
===Le réseau d'interconnexion entre plusieurs cœurs===
Relier plusieurs cœurs avec des bus pose de nombreux problèmes techniques qui sont d'autant plus problématiques que le nombre de cœurs augmente. Le câblage est notamment très complexe, les contraintes électriques pour la transmission des signaux sont beaucoup plus fortes, les problèmes d'arbitrages se font plus fréquents, etc. Pour régler ces problèmes, les processeurs multicoeurs n'utilisent pas de bus partagé, mais un réseau d'interconnexion plus complexe.
Un exemple de réseau d'interconnexion est celui des architectures AMD EPYC, de microarchitecture Zen 1. Elles utilisaient des chiplets, à savoir que le processeur était composé de plusieurs puces interconnectées entre elles. Chaque puce contenait un processeur multicoeurs intégrant un cache L3, avec un réseau d'interconnexion interne au processeur sans doute basé sur un ensemble de bus. De plus, les puces étaient reliées à une puce d'interconnexion qui servait à la fois d'interface entre les processeurs, mais aussi d'interface avec la R1AM, le bus PCI-Express, etc. La puce d'interconnexion était gravée en 14 nm contre 7nm pour les chiplets des cœurs.
{|
|[[File:AMD Epyc 7702 delidded.jpg|centre|vignette|upright=2|AMD Epyc 7702.]]
|[[File:AMD Epyc Rome Aufbau.png|centre|vignette|upright=2|Schéma fonctionnel de l'AMD Epyc.]]
|}
Le réseau d'interconnexion peut être très complexe, avec des connexions réseau, des commutateurs, et des protocoles d'échanges entre processeurs assez complexes basés sur du passage de messages. De telles puces utilisent un '''réseau sur puce''' (''network on chip''). Mais d'autres simplifient le réseau d'interconnexion, qui se résume à un réseau ''crossbar'', voire à des mémoires FIFO pour faire l'interface entre les cœurs.
Le problème principal des réseaux sur puce est que les mémoires FIFOs sont difficiles à implémenter sur une puce de silicium. Elles prennent beaucoup de place, utilisent beaucoup de portes logiques, consomment beaucoup d'énergie, sont difficiles à concevoir pour diverses raisons (les accès concurrents/simultanés sont fréquents et font mauvais ménage avec les ''timings'' serrés de quelques cycles d'horloges requis). Il est donc impossible de placer beaucoup de mémoires FIFO dans un processeur, ce qui fait que les commutateur sont réduits à leur strict minimum : un réseau d'interconnexion, un système d'arbitrage simple parfois sans aucune FIFO, guère plus.
===Les architectures en ''tile''===
Un cas particulier de réseau sur puce est celui des '''architectures en ''tile''''', des architectures avec un grand nombre de cœurs, connectés les unes aux autres par un réseau d'interconnexion "rectangulaire". Chaque cœur est associé à un commutateur (''switch'') qui le connecte au réseau d'interconnexion, l'ensemble formant une ''tile''.
[[File:Tile64-Tile.svg|centre|vignette|upright=1.5|''Tile'' de base du Tile64.]]
Le réseau est souvent organisé en tableau, chaque ''tile'' étant connectée à plusieurs voisines. Dans le cas le plus fréquent, chaque ''tile'' est connectée à quatre voisines : celle du dessus, celle du dessous, celle de gauche et celle de droite. Précisons que cette architecture n'est pas une architecture distribuée dont tous les processeurs seraient placés sur la même puce de silicium. En effet, la comparaison ne marche pas pour ce qui est de la mémoire : tous les cœurs accèdent à une mémoire partagée située en dehors de la puce de silicium. Le réseau ne connecte pas plusieurs ordinateurs séparés avec chacun leur propre mémoire, mais plusieurs cœurs qui accèdent à une mémoire partagée.
Un bon exemple d'architecture en ''tile'' serait les déclinaisons de l'architecture Tilera. Les schémas du-dessous montrent l'architecture du processeur Tile 64. Outre les ''tiles'', qui sont les éléments de calcul de l'architecture, on trouve plusieurs contrôleurs mémoire DDR, divers interfaces réseau, des interfaces série et parallèles, et d'autres entrées-sorties.
[[File:Tile64.svg|centre|vignette|upright=2|Architecture Tile64 du Tilera.]]
==Les interruptions inter-processeurs==
Les '''interruptions inter-processeurs''' sont des interruptions déclenchées sur un processeur et exécutées sur un autre. Elles sont très utiles pour le système d'exploitation, pour des raisons qu'on ne peut pas expliquer ici. Pour générer des interruptions inter-processeur, le contrôleur d'interruption doit pouvoir rediriger des interruptions déclenchées par un processeur vers un autre.
Une interruption inter-processeurs peut être envoyée soit à un cœur bien précis, soit à n'importe quel cœur, soit à tous les cœurs. Tout dépend de ce que décide le système d'exploitation. Les trois situations ne sont pas identiques, sur un point : comment préciser quel est le processeur de destination ? Si on envoie une interruption à un cœur bien précis, il faut préciser quel est le cœur qui réceptionne l'interruption. Pour cela, il n'y a pas 36 solutions : on numérote les processeurs/cœurs avec un '''numéro de processeur'''. Ce numéro leur est soit attribué au démarrage par le BIOS, soit est gravé dans leur silicium pour les processeurs multicœurs.
Les anciens PC incorporaient sur leur carte mère un contrôleur d'interruption créé par Intel, le 8259A, qui ne gérait pas les interruptions inter-processeurs. Les cartes mères multiprocesseurs devaient incorporer un contrôleur spécial en complément. De nos jours, chaque cœur x86 possède son propre contrôleur d’interruption, le local APIC, qui gère les interruptions en provenance ou arrivant vers ce processeur. On trouve aussi un IO-APIC, qui gère les interruptions en provenance des périphériques et de les redistribuer vers les APIC locaux. L'IO-APIC gère aussi les interruptions inter-processeurs en faisant passer les interruptions d'un local APIC vers un autre. Tous les APIC locaux et l'IO-APIC sont reliés ensembles par un bus APIC spécialisé, par lequel ils vont pouvoir communiquer et s'échanger des demandes d'interruptions.
[[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.]]
On peut préciser le processeur de destination en configurant certains registres du IO-APIC, afin que celui-ci redirige la demande d'interruption d'un local APIC vers celui sélectionné. Cela se fait avec l'aide d'un registre de 64 bits nommé l'''Interrupt Command Register''. Certains bits de ce registre permettent de préciser quel est le type de transfert : doit-on envoyer l'interruption au processeur émetteur, à tous les autres processeurs, à un processeur particulier. Dans le dernier cas, certains bits du registre permettent de préciser le numéro du processeur qui va devoir recevoir l'interruption. À charge de l'APIC de faire ce qu'il faut en fonction du contenu de ce registre.
==Le multiprocesseur/multicœur asymétrique==
Sur les processeurs grand public actuels, les cœurs d'un processeur multicœurs sont tous identiques. Mais ce n'est certainement pas une obligation. On peut très bien regrouper plusieurs cœurs très différents, par exemple un cœur principal avec des cœurs plus spécialisés autour. Il faut ainsi distinguer le '''multicœurs symétrique''', dans lequel on place des processeurs identiques sur la même puce de silicium, du '''multicœurs asymétrique''' où les cœurs ne sont pas identiques. Et il en est de même sur les systèmes avec plusieurs processeurs : on parle de '''multiprocesseur symétrique''' si les processeurs sont identiques, '''multiprocesseur asymétrique''' s'ils sont différents.
Précisons ce que nous entendons par "cœurs différents" ou "identiques". Les processeurs Intel modernes utilisent deux types de cœurs différents : des cœurs P et des cœurs E. Le P est pour ''Performance'', le E est pour "Efficiency". Les deux ont le même jeu d'instruction : ce sont des processeurs x86. Par contre, ils ont des microarchitectures différentes. Et Intel n'est pas le seul à utiliser cette technique : ARM a fait pareil avec ses CPU d'architecture ''Big-little''. Il n'est pas clair si de telles organisation sont du multicœur symétrique ou asymétrique. Le jeu d'instruction est identique, sauf éventuellement pour certaines extension comme l'AVX. Les deux coeurs n'ont pas les mêmes performances, mais est-ce suffisant ? La terminologie n'est pas claire.
Un exemple de multicoeurs asymétrique est celui du processeur CELL de la console de jeu PS3. Il était conçu spécifiquement pour cette console. Il intègre un cœur principal POWER PC v5 et 8 cœurs qui servent de processeurs auxiliaires. Le processeur principal est appelé le PPE et les processeurs auxiliaires sont les SPE. Les SPE sont reliés à une mémoire locale (''local store'') de 256 kibioctets qui communique avec le processeur principal via un bus spécial. Cette fois-ci, les coprocesseurs sont intégrés dans le même processeur.
Les SPE communiquent avec la RAM principale via des contrôleurs DMA. Les SPE possèdent des instructions permettant de commander leur contrôleur DMA et c'est le seul moyen qu'ils ont pour récupérer des informations depuis la mémoire. Et c'est au programmeur de gérer tout ça ! C'est le processeur principal qui va envoyer aux SPE les programmes qu'ils doivent exécuter. Il délègue des calculs aux SPE en écrivant dans le local store du SPE et en lui ordonnant l’exécution du programme qu'il vient d'écrire.
[[File:Schema Cell.png|centre|vignette|upright=2|Architecture du processeur CELL de la PS3. Le PPE est le processeur principal, les SPE sont des processeurs auxiliaires qui comprennent : un ''local store'' noté LS, un processeur noté SXU, et un contrôleur DMA pour échanger des informations avec la mémoire principale.]]
==Annexe : les architectures à cœurs conjoints==
Sur certains processeurs multicœurs, certains circuits sont partagés entre plusieurs cœurs. Typiquement, l'unité de calcul flottante est partagée entre deux coeurs/''threads'', les unités SIMD qu'on verra dans quelques chapitres sont aussi dans ce cas. Le partage de circuits permet d'éviter de dupliquer trop de circuits et donc d'économiser des transistors. Le problème est que ce partage est source de dépendances structurelles, ce qui peut entraîner des pertes de performances.
Cette technique consistant de partage d'unités de calcul entre coeurs s'appelle le '''cluster multithreading''', ou encore les '''architectures à cœurs conjoints''' (''Conjoined Core Architectures''). Elle est notamment utilisée sur les processeurs AMD de microarchitecture Bulldozer, incluant ses trois révisions ultérieures nommées Piledriver, Steamroller et Excavator. Un exemple est celui des processeurs AMD FX-8150 et FX-8120.
Sur ces processeurs, les instructions sont chargées dans deux files d'instructions séparées, une par ''thread'' matériel. Les instructions sont ensuite décodées par un décodeur unique et renommées dans une unité de renommage unique. Par la suite, il y a deux voies entières séparées et une voie flottante partagée. Chaque voie entière a sa propre fenêtre d'instruction entière, son tampon de ré-ordonnancement, ses unités de calcul dédiées, ses registres, sa ''load-store queue'', son cache L1. Par contre, la voie flottante partage les unités de calcul flottantes et n'a qu'une seule fenêtre d'instruction partagée par les deux ''threads''.
[[File:AMD Bulldozer microarchitecture.png|centre|vignette|upright=3|Microarchitecture Bulldozer d'AMD.]]
La révision Steamroller sépara le ''front-end'' en deux voies distinctes, une par ''thread''. Concrètement, elle ajouta un second décodeur d'instruction, une seconde file de micro-opération et une seconde unité de renommage de registres, afin d'améliorer les performances. Niveaux optimisations mineures, les stations de réservation ont été augmentées, elles peuvent mémoriser plus de micro-opérations, idem pour les bancs de registre et les files de lecture/écriture. Un cache de micro-opérations a été ajouté, de même que des optimisations quant au renommage de registre. Des ALU ont aussi été ajoutées, des FPU retirées.
[[File:AMD excavator microarchitecture.png|centre|vignette|upright=3|Microarchitecture Excavator d'AMD.]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les architectures parallèles
| prevText=Les architectures parallèles
| next=Architectures multithreadées et Hyperthreading
| nextText=Architectures multithreadées et Hyperthreading
}}
</noinclude>
dh31lv5udhf1y3u4b9rzy2men5hn0hk
763677
763676
2026-04-14T13:22:08Z
Mewtow
31375
/* Les interruptions inter-processeurs */
763677
wikitext
text/x-wiki
Pour réellement tirer parti du parallélisme de taches, rien ne vaut l'utilisation de plusieurs processeurs et/ou de plusieurs cœurs, qui exécutent chacun un ou plusieurs programmes dans leur coin. Des solutions multiprocesseurs ont alors vu le jour pour rendre l'usage de plusieurs processeurs plus adéquat. Avant de poursuivre, nous allons voir les systèmes multiprocesseur à part des processeurs multicœurs. Il faut dire qu'utiliser plusieurs processeurs et avoir plusieurs cœurs sur la même puce, ce n'est pas la même chose. Particulièrement pour ce qui est de la mémoire cache.
Les '''systèmes multiprocesseur''' placent plusieurs processeurs sur la même carte mère. Ils sont courants dans les serveurs ou les ''data centers'', mais sont beaucoup plus rares pour les ordinateurs grand public. Il y a eu quelques systèmes multiprocesseur vendus au grand public dans les années 2000, certaines cartes mères avaient deux sockets pour mettre deux processeurs. Mais les logiciels et les systèmes d'exploitation grand public n'étaient pas adaptés pour, ce qui fait que la technologie est restée confidentielle.
Puis, en 2005, les '''processeurs multicœurs''' sont arrivés. Ils peuvent être vus comme un regroupement de plusieurs processeurs dans le même circuit intégré. Pour être plus précis, ils contiennent plusieurs ''cœurs'', chaque cœur pouvant exécuter un programme tout seul. Un cœur dispose de toute la machinerie électronique pour exécuter un programme, que ce soit un séquenceur d'instruction, des registres, une unité de calcul. Par contre, certains circuits du processeur sont partagés entre les cœurs, comme les circuits d’interfaçage avec la mémoire.
Les processeurs multicœurs sont devenus la norme dans les ordinateurs grand public et les logiciels et systèmes d'exploitation se sont adaptés. Suivant le nombre de cœurs présents dans notre processeur, celui-ci sera appelé un processeur double-cœur (deux cœurs), quadruple-cœur (4 cœurs), octuple-cœur (8 cœurs), etc. Les processeurs grand public ont généralement entre 8 et 16 cœurs, à l'heure où j'écris ces lignes (2025), rarement au-delà. Par contre, les processeurs pour serveurs dépassent la vingtaine de cœurs. Les serveurs utilisent souvent des architectures dites '''''many core''''', qui ont un très grand nombre de cœurs, plus d'une cinquantaine, voire plusieurs centaines ou milliers.
: Dans ce qui suit, nous utiliserons les termes "processeurs" et "cœurs" comme s'ils étaient synonymes. Tout ce qui vaut pour les systèmes multiprocesseur vaut aussi pour les systèmes multicœurs.
==Le partage des caches==
Quand on conçoit un processeur multicœur, il ne faut pas oublier ce qui arrive à la pièce maîtresse de tout processeur actuel : le cache ! Pour le moment nous allons oublier le fait que les processeurs ont une hiérarchie de caches, avec des caches L1, L2, L3 et autres. Nous allons partir du principe qu'un processeur simple cœur a un seul cache, et voir comment adapter le cache à la présence de plusieurs cœurs. Nous allons rapidement lever cette hypothèse, pour étudier le cas où un processeur multicœur a une hiérarchie de caches, mais seulement après avoir vu le cas le plus simple à un seul cache.
===Le partage des caches sans hiérarchie de caches : caches dédiés et partagés===
Avec un seul niveau de cache, sans hiérarchie, deux solutions sont possibles. La première consiste à garder un seul cache, et de le partager entre les cœurs. L'autre solution est de dupliquer le cache et d'utiliser un cache par cœur. Les deux solutions sont appelées différemment. On parle de '''caches dédiés''' si chaque cœur possède son propre cache, et de '''cache partagé''' avec un cache partagé entre tous les cœurs. Ces deux méthodes ont des inconvénients et des avantages.
{|
|[[File:Caches dédiés.png|vignette|Caches dédiés]]
|[[File:Caches partagés.png|vignette|Cache partagé]]
|}
Le premier point sur lequel comparer caches dédiés et partagés est celui de la capacité du cache. La quantité de mémoire cache que l'on peut placer dans un processeur est limitée, car le cache prend beaucoup de place, près de la moitié des circuits du processeur. Aussi, un processeur incorpore une certaine quantité de mémoire cache, qu'il faut répartir entre un ou plusieurs caches. Les caches dédiés et partagés ne donnent pas le même résultat. D'un côté, le cache partagé fait que toute la mémoire cache est dédiée au cache partagé, qui est très gros. De l'autre, on doit répartir la capacité du cache entre plusieurs caches séparés, individuellement plus petits. En conséquence, on a le choix entre un petit cache pour chaque processeur ou un gros cache partagé.
Le choix entre les deux n'est pas simple, mais doit tenir compte du fait que les programmes exécutés sur les cœurs n'ont pas les mêmes besoins. Certains programmes sont plus gourmands et demandent beaucoup de cache, alors que d'autres utilisent peu la mémoire cache. Avec un cache dédié, tous les programmes ont accès à la même quantité de cache, car les caches des différents cœurs sont de la même taille. Les caches dédiés étant assez petits, les programmes plus gourmands devront se débrouiller avec un petit cache, alors que les autres programmes auront du cache en trop.
À l'opposé, un cache partagé répartit le cache de manière optimale : un programme gourmand peut utiliser autant de cache qu'il veut, laissant juste ce qu'il faut aux programmes moins gourmands. le cache peut être répartit plus facilement selon les besoins des différents programmes.
[[File:Cache partagé contre cache dédié.png|centre|vignette|upright=2.5|Cache partagé contre cache dédié]]
Un autre avantage des caches partagés est quand plusieurs cœurs accèdent aux même données. C'est un cas très courant, souvent lié à l'usage de mémoire partagé ou de ''threads''. Avec des caches dédiés, chaque cœur a une copie des données partagées. Mais avec un cache partagé, il n'y a qu'une seule copie de chaque donnée, ce qui utilise moins de mémoire cache. Imaginons que l'on sait 8 caches dédiés de 8 Kibioctets, soit 64 kibioctets au total, comparé à un cache partagé de même capacité totale. Les doublons dans les caches dédiés réduiront la capacité mémoire utile, effective, comparé à un cache partagé. S'il y a 1 Kibioctet de mémoire partagé, 8 kibioctets seront utilisés pour stocker ces données en doublons, seulement 1 kibioctet sur un cache partagé. Ajoutons aussi que la cohérence des caches est grandement simplifiée avec l'usage d'un cache partagé, vu que les données ne sont pas dupliquées dans plusieurs caches.
Mais le partage du cache peut se transformer en inconvénient si les programmes entrent en compétition pour le cache, que ce soit pour y placer des données ou pour les accès mémoire. Deux programmes peuvent vouloir accéder au cache en même temps, voire carrément se marcher sur les pieds. La résolution des conflits d'accès au cache est résolu soit en prenant un cache multiport, avec un port dédié par cœur, soit par des mécanismes d'arbitrages avec des circuits dédiés. Le revers de la médaille tient au temps de latence. Plus un cache est gros, plus il est lent. En conséquence, des caches dédiés seront plus rapides qu'un gros cache partagé plus lent.
===Le partage des caches adapté à une hiérarchie de caches===
Dans la réalité, un processeur multicœur ne contient pas qu'un seul cache, mais une hiérarchie de caches avec des caches L1, L2 et L3, parfois L4. Dans cette hiérarchie, certains caches sont partagés entre plusieurs cœurs, les autres sont dédiés. Le cache L1 n'est jamais partagé, car il doit avoir un temps d'accès très faible. Pour les autres caches, tout dépend du processeur.
[[File:Dual Core Generic.svg|vignette|Cache L2 partagé.]]
Les premiers processeurs multicœurs commerciaux utilisaient deux niveaux de cache : des caches L1 dédiés et un cache L2 partagé. Le cache L2 partagé était relié aux caches L1, grâce à un système assez complexe d'interconnexions. Le cache de niveau L2 était souvent simple port, car les caches L1 se chargent de filtrer les accès aux caches de niveau inférieurs.
Les processeurs multicœurs modernes ont des caches L3 et même L4, de grande capacité, ce qui a modifié le partage des caches. Le cache de dernier niveau, à savoir le cache le plus proche de la mémoire, est systématiquement partagé, car son rôle est d'être un cache lent mais gros. Il s'agit le plus souvent d'un cache de L3, plus rarement L4. Sur certains processeurs multicœurs, le cache de dernier niveau n'est techniquement pas dans le cœur, mais fait partie d'un ensemble de circuits reliés, comme le contrôleur mémoire ou l'interface mémoire. Il fonctionne à une fréquence différente du processeur, n'a pas la même tension d'alimentation, etc.
Le cas du cache L2 dépend des architectures : il est partagé sur certains processeurs, dédié sur d'autres. Mais sur les processeurs modernes, c'est un cache dédié soit par cœur, soit pour un groupe de cœurs. Dans le cas le plus courant, chaque cache L2 est partagé entre plusieurs cœurs mais pas à tous. En effet, on peut limiter le partage du cache à quelques cœurs particuliers pour des raisons de performances.
[[File:Partage des caches sur un processeur multicoeurs.png|centre|vignette|upright=2.0|Partage des caches sur un processeur multicœur.]]
D'autres processeurs ont des caches L2 dédiés. Il s'agit surtout des processeurs multicœurs anciens, parmi les premières générations de processeurs multicœurs. Un exemple est celui de la microarchitecture Nehalem d'Intel. Il avait des caches L1 et L2 dédiés, mais un cache L3 partagé.
[[File:Nehalem EP.png|centre|vignette|upright=2.0|Partage des caches sur un processeur Intel d'architecture Nehalem.]]
===Les caches partagés centralisés et distribués===
Un point important est que quand on parle de cache partagé ou de cache dédié, on ne parle que de la manière dont les cœurs peuvent accéder au cache, pas de la manière dont le caches est réellement localisé sur la puce. En théorie, qui dit plusieurs caches dédiés signifie que l'on a vraiment plusieurs caches séparés sur la puce. Et chaque cache dédié est proche du cœur qui lui est attribué. Et pour les caches partagés unique, une portion de la puce de silicium contient le cache, que cette portion est un énorme bloc de transistors. Il est généralement placé au milieu de la puce ou sur un côté, histoire de facilement le connecter à tous les cœurs.
Mais pour les caches séparés, ce n'est pas toujours le cas. Avoir un cache énorme poserait des problèmes sur les architectures avec beaucoup de cœurs. En réalité, le cache est souvent découpé en plusieurs banques, reliées à un contrôleur du cache par un système d'interconnexion assez complexe. Les banques sont physiquement séparées, et il arrive qu'elles soient placées proche d'un cœur chacune. L'organisation des banques ressemble beaucoup à l'organisation des caches dédiés, avec une banque étant l'équivalent d'un cache dédié. La différence est que les cœurs peuvent lire et écrire dans toutes les banques, grâce au système d'interconnexion et au contrôleur de cache.
Tel était le cas sur les processeurs AMD Jaguar. Ils avaient un cache L2 de 2 mébioctets, partagés entre tous les cœurs, qui était composé de 4 banques de 512 Kibioctets. Les quatre banques du cache étaient reliées aux 4 cœurs par un réseaux d'interconnexion assez complexe.
[[File:AMDJaguarModule.png|centre|vignette|upright=2|AMD Jaguar Module]]
La différence entre les deux solutions pour les caches partagés porte le nom de cache centralisés versus distribués. Un gros cache unique sur la puce est un '''cache centralisé''', et c'est généralement un cache partagé. Mais un cache composé de plusieurs banques dispersées sur la puce est un '''cache distribué''', qui peut être aussi bien dédié que partagé.
===Les caches virtualisés===
Il faut noter que quelques processeurs utilisent cette technique pour fusionnent le cache L2 et le cache L3. Par exemple, les processeurs IBM Telum utilisent des caches L3 virtualisés, dans leurs versions récentes. Le processeur Telum 2 contient 10 caches L2 de 36 mébioctets chacun, soit 360 mébioctets de cache. L'idée est que ces 360 mébioctets sont partagés à la demande entre cache L2 dédié et cache L3. On parle alors de '''cache virtualisé'''.
Un cache de 36 mébioctet est associé à un cœur, auquel il est directement relié. Les cœurs n'utilisent pas tous leur cache dédié à 100% Il arrive que des cœurs aient des caches partiellement vides, alors que d'autres on un cache qui déborde. L'idée est que si un cœur a un cache plein, les données évincées du cache L2 privé sont déplacées dans le cache L2 d'un autre cœur, qui lui est partiellement vide. Le cache L2 en question est alors partitionné en deux : une portion pour les données associée à son cœur, une portion pour les données des L2 des autres cœurs.
Pour que la technique fonctionne, le processeur mesure le remplissage de chaque cache L2. De plus, il faut gérer la politique de remplacement des lignes de cache. Une ligne de cache évincée du cache doit être déplacé dans un autre L2, pas dans les niveaux de cache inférieur, ni dans la mémoire. Du moins, tant qu'il reste de la place dans le cache L3. De plus, une lecture/écriture dans le cache L3 demande de localiser le cache L2 contenant la donnée. Pour cela, les caches L2 sont tous consultés lors d'un accès au L3, c'est la solution la plus simple, elle marche très bien si le taux de défaut du cache L2 est faible.
Une telle optimisation ressemble beaucoup à un cache L2/L3 distribué, mais il y a quelques différences qui sont décrites dans le paragraphe précédent. Avec un L2 distribué, tout accès au L2 déclencherait une consultation de toutes les banques du L2. Avec un cache L3 virtualisé, ce n'est pas le cas. Le cache L2 associé au cœur est consulté, et c'est seulement en cas de défaut de cache que les autres caches L2 sont consultés. De plus, avec un cache L2 distribué, il n'y a pas de déplacement d'une ligne de cache entre deux banques, entre deux caches L2 physiques. Alors qu'avec un cache L3 virtualisé, c'est le cas en cas de remplacement d'une ligne de cache dans le cache L2.
Sur le processeur Telum 1, le partage du cache L2 est assez simple. Un cache L2 fait 32 mébioctets et est découpé en deux banques de 16 mébioctets. En temps normal, les premiers 16 mébioctets sont toujours associé au cache L2, au cœur associé. Les 16 mébioctets restants peuvent soit être attribués au cache L3, soit fusionnés avec les 16 premiers mébioctets. Dans le cas où le cœur associé est en veille, n'est absolument pas utilisé, les 32 mébioctets sont attribués au cache L3. Un partage assez simple, donc. Le partage du cache L2/L3 sur les processeurs Telum 2 n'est pas connu, il est supposé être plus flexible.
==Le réseau d'interconnexion entre cœurs==
Les systèmes avec plusieurs processeurs incorporent un réseau d'interconnexion pour connecter les processeurs entre eux, ainsi qu'à la mémoire RAM. Il s'agit d'un '''réseau d'interconnexion inter-processeur''', placé sur la carte mère. Les CPU multicœurs ont aussi un tel réseau d'interconnexion, pour relier les cœurs entre eux. La différence est que le réseau d'interconnexion est placé dans le processeur, pas sur la carte mère.
Les systèmes multi-cœurs modernes utilisent des réseaux d'interconnexion standardisés, les standards les plus communs étant l'HyperTransport, l'Intel QuickPath Interconnect, l'IBM Elastic Interface, le Intel Ultra Path Interconnect, l'Infinity Fabric, etc. Ils sont aussi utilisés pour faire communiquer entre eux plusieurs processeurs.
[[File:Architecture multicoeurs et réseau sur puce.png|centre|vignette|upright=1.5|Architecture multicoeurs et réseau sur puce]]
===Le bus partagé entre plusieurs cœurs===
Pour un faible nombre de coeurs/processeurs, la solution utilisée relie les processeurs entre eux grâce au bus mémoire. Le bus mémoire est donc un '''bus partagé''', avec tout ce que cela implique.
[[File:Architecture multicoeurs à bus partagé.png|centre|vignette|upright=2|Architecture multiprocesseurs à bus partagé]]
Pour les systèmes multicœurs, l'usage d'un bus partagé doit être adaptée pour tenir compte des caches partagés. Voyons d'abord le cas d'un CPU avec deux niveaux de cache, dont un cache L2 est partagé entre tous les cœurs. Les caches L1 sont reliés au cache L2 partagé par un bus, qui n'a souvent pas de nom. Nous désignerons le bus entre le cache L1 et le cache L2 : '''bus partagé''', sous-entendu partagé entre tous les caches. C'est lui qui sert à connecter les cœurs entre eux.
[[File:Architecture multicoeurs à bus partagé entre caches L1 et L2.png|centre|vignette|upright=2|Architecture multicoeurs à bus partagé entre caches L1 et L2]]
Un processeur multicœur typique a une architecture avec trois niveaux de cache (L1, L2 et L3), avec un niveau L1 dédié par cœur, un niveau L2 partiellement partagé et un L3 totalement partagé. Le bus partagé est alors difficile à décrire, mais il correspond à l'ensemble des bus qui connectent les caches L1 aux caches L2, et les caches L2 au cache L3. Il s'agit alors d'un ensemble de bus, plus que d'un bus partagé unique.
L'usage d'un bus partagé a cependant de nombreux défauts. Par exemple, les processeurs doivent se répartir l'accès au bus mémoire, il faut gérer le cas où deux processeurs accèdent au bus en même temps, etc. Pour cela, un composant dédié s'occupe de l'arbitrage entre processeurs. Il est généralement placé sur la carte mère de l'ordinateur, dans le ''chipset'', dans le pont nord, ou un endroit proche. D'autres défauts très importants seront abordés en détail dans le chapitre sur la cohérence des caches
[[File:Intel486 System Arbitration.png|centre|vignette|upright=2|Système avec deux Intel486 - Arbitrage du bus mémoire.]]
===Le réseau d'interconnexion entre plusieurs cœurs===
Relier plusieurs cœurs avec des bus pose de nombreux problèmes techniques qui sont d'autant plus problématiques que le nombre de cœurs augmente. Le câblage est notamment très complexe, les contraintes électriques pour la transmission des signaux sont beaucoup plus fortes, les problèmes d'arbitrages se font plus fréquents, etc. Pour régler ces problèmes, les processeurs multicoeurs n'utilisent pas de bus partagé, mais un réseau d'interconnexion plus complexe.
Un exemple de réseau d'interconnexion est celui des architectures AMD EPYC, de microarchitecture Zen 1. Elles utilisaient des chiplets, à savoir que le processeur était composé de plusieurs puces interconnectées entre elles. Chaque puce contenait un processeur multicoeurs intégrant un cache L3, avec un réseau d'interconnexion interne au processeur sans doute basé sur un ensemble de bus. De plus, les puces étaient reliées à une puce d'interconnexion qui servait à la fois d'interface entre les processeurs, mais aussi d'interface avec la R1AM, le bus PCI-Express, etc. La puce d'interconnexion était gravée en 14 nm contre 7nm pour les chiplets des cœurs.
{|
|[[File:AMD Epyc 7702 delidded.jpg|centre|vignette|upright=2|AMD Epyc 7702.]]
|[[File:AMD Epyc Rome Aufbau.png|centre|vignette|upright=2|Schéma fonctionnel de l'AMD Epyc.]]
|}
Le réseau d'interconnexion peut être très complexe, avec des connexions réseau, des commutateurs, et des protocoles d'échanges entre processeurs assez complexes basés sur du passage de messages. De telles puces utilisent un '''réseau sur puce''' (''network on chip''). Mais d'autres simplifient le réseau d'interconnexion, qui se résume à un réseau ''crossbar'', voire à des mémoires FIFO pour faire l'interface entre les cœurs.
Le problème principal des réseaux sur puce est que les mémoires FIFOs sont difficiles à implémenter sur une puce de silicium. Elles prennent beaucoup de place, utilisent beaucoup de portes logiques, consomment beaucoup d'énergie, sont difficiles à concevoir pour diverses raisons (les accès concurrents/simultanés sont fréquents et font mauvais ménage avec les ''timings'' serrés de quelques cycles d'horloges requis). Il est donc impossible de placer beaucoup de mémoires FIFO dans un processeur, ce qui fait que les commutateur sont réduits à leur strict minimum : un réseau d'interconnexion, un système d'arbitrage simple parfois sans aucune FIFO, guère plus.
===Les architectures en ''tile''===
Un cas particulier de réseau sur puce est celui des '''architectures en ''tile''''', des architectures avec un grand nombre de cœurs, connectés les unes aux autres par un réseau d'interconnexion "rectangulaire". Chaque cœur est associé à un commutateur (''switch'') qui le connecte au réseau d'interconnexion, l'ensemble formant une ''tile''.
[[File:Tile64-Tile.svg|centre|vignette|upright=1.5|''Tile'' de base du Tile64.]]
Le réseau est souvent organisé en tableau, chaque ''tile'' étant connectée à plusieurs voisines. Dans le cas le plus fréquent, chaque ''tile'' est connectée à quatre voisines : celle du dessus, celle du dessous, celle de gauche et celle de droite. Précisons que cette architecture n'est pas une architecture distribuée dont tous les processeurs seraient placés sur la même puce de silicium. En effet, la comparaison ne marche pas pour ce qui est de la mémoire : tous les cœurs accèdent à une mémoire partagée située en dehors de la puce de silicium. Le réseau ne connecte pas plusieurs ordinateurs séparés avec chacun leur propre mémoire, mais plusieurs cœurs qui accèdent à une mémoire partagée.
Un bon exemple d'architecture en ''tile'' serait les déclinaisons de l'architecture Tilera. Les schémas du-dessous montrent l'architecture du processeur Tile 64. Outre les ''tiles'', qui sont les éléments de calcul de l'architecture, on trouve plusieurs contrôleurs mémoire DDR, divers interfaces réseau, des interfaces série et parallèles, et d'autres entrées-sorties.
[[File:Tile64.svg|centre|vignette|upright=2|Architecture Tile64 du Tilera.]]
==Les interruptions inter-processeurs==
Les '''interruptions inter-processeurs''' sont des interruptions déclenchées sur un processeur et exécutées sur un autre. Elles sont très utiles pour le système d'exploitation, pour des raisons qu'on ne peut pas expliquer ici. Pour générer des interruptions inter-processeur, le contrôleur d'interruption doit pouvoir rediriger des interruptions déclenchées par un processeur vers un autre.
Une interruption inter-processeurs peut être envoyée soit à un cœur bien précis, soit à n'importe quel cœur, soit à tous les cœurs. Tout dépend de ce que décide le système d'exploitation. Les trois situations ne sont pas identiques, sur un point : comment préciser quel est le processeur de destination ? Si on envoie une interruption à un cœur bien précis, il faut préciser quel est le cœur qui réceptionne l'interruption. Pour cela, il n'y a pas 36 solutions : on numérote les processeurs/cœurs avec un '''numéro de processeur'''. Ce numéro leur est soit attribué au démarrage par le BIOS, soit est gravé dans leur silicium pour les processeurs multicœurs.
L'utilité des interruptions inter-processeur est assez variée. Elles servaient autrefois pour la cohérence des caches, mais nous détaillerons cela dans un futur chapitre. Pour le reste, les interruptions inter-processeurs sont identiques aux interruptions normales. Elles ont un système de priorités, certaines devant passer avant les autres, là encore défini par des ''Interrupt Request Levels'' (IRQLs) ou quelque chose de similaire.
Les anciens PC incorporaient sur leur carte mère un contrôleur d'interruption créé par Intel, le 8259A, qui ne gérait pas les interruptions inter-processeurs. Les cartes mères multiprocesseurs devaient incorporer un contrôleur spécial en complément. De nos jours, chaque cœur x86 possède son propre contrôleur d’interruption, le local APIC, qui gère les interruptions en provenance ou arrivant vers ce processeur. On trouve aussi un IO-APIC, qui gère les interruptions en provenance des périphériques et de les redistribuer vers les APIC locaux. L'IO-APIC gère aussi les interruptions inter-processeurs en faisant passer les interruptions d'un local APIC vers un autre. Tous les APIC locaux et l'IO-APIC sont reliés ensembles par un bus APIC spécialisé, par lequel ils vont pouvoir communiquer et s'échanger des demandes d'interruptions.
[[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.]]
On peut préciser le processeur de destination en configurant certains registres du IO-APIC, afin que celui-ci redirige la demande d'interruption d'un local APIC vers celui sélectionné. Cela se fait avec l'aide d'un registre de 64 bits nommé l'''Interrupt Command Register''. Certains bits de ce registre permettent de préciser quel est le type de transfert : doit-on envoyer l'interruption au processeur émetteur, à tous les autres processeurs, à un processeur particulier. Dans le dernier cas, certains bits du registre permettent de préciser le numéro du processeur qui va devoir recevoir l'interruption. À charge de l'APIC de faire ce qu'il faut en fonction du contenu de ce registre.
==Le multiprocesseur/multicœur asymétrique==
Sur les processeurs grand public actuels, les cœurs d'un processeur multicœurs sont tous identiques. Mais ce n'est certainement pas une obligation. On peut très bien regrouper plusieurs cœurs très différents, par exemple un cœur principal avec des cœurs plus spécialisés autour. Il faut ainsi distinguer le '''multicœurs symétrique''', dans lequel on place des processeurs identiques sur la même puce de silicium, du '''multicœurs asymétrique''' où les cœurs ne sont pas identiques. Et il en est de même sur les systèmes avec plusieurs processeurs : on parle de '''multiprocesseur symétrique''' si les processeurs sont identiques, '''multiprocesseur asymétrique''' s'ils sont différents.
Précisons ce que nous entendons par "cœurs différents" ou "identiques". Les processeurs Intel modernes utilisent deux types de cœurs différents : des cœurs P et des cœurs E. Le P est pour ''Performance'', le E est pour "Efficiency". Les deux ont le même jeu d'instruction : ce sont des processeurs x86. Par contre, ils ont des microarchitectures différentes. Et Intel n'est pas le seul à utiliser cette technique : ARM a fait pareil avec ses CPU d'architecture ''Big-little''. Il n'est pas clair si de telles organisation sont du multicœur symétrique ou asymétrique. Le jeu d'instruction est identique, sauf éventuellement pour certaines extension comme l'AVX. Les deux coeurs n'ont pas les mêmes performances, mais est-ce suffisant ? La terminologie n'est pas claire.
Un exemple de multicoeurs asymétrique est celui du processeur CELL de la console de jeu PS3. Il était conçu spécifiquement pour cette console. Il intègre un cœur principal POWER PC v5 et 8 cœurs qui servent de processeurs auxiliaires. Le processeur principal est appelé le PPE et les processeurs auxiliaires sont les SPE. Les SPE sont reliés à une mémoire locale (''local store'') de 256 kibioctets qui communique avec le processeur principal via un bus spécial. Cette fois-ci, les coprocesseurs sont intégrés dans le même processeur.
Les SPE communiquent avec la RAM principale via des contrôleurs DMA. Les SPE possèdent des instructions permettant de commander leur contrôleur DMA et c'est le seul moyen qu'ils ont pour récupérer des informations depuis la mémoire. Et c'est au programmeur de gérer tout ça ! C'est le processeur principal qui va envoyer aux SPE les programmes qu'ils doivent exécuter. Il délègue des calculs aux SPE en écrivant dans le local store du SPE et en lui ordonnant l’exécution du programme qu'il vient d'écrire.
[[File:Schema Cell.png|centre|vignette|upright=2|Architecture du processeur CELL de la PS3. Le PPE est le processeur principal, les SPE sont des processeurs auxiliaires qui comprennent : un ''local store'' noté LS, un processeur noté SXU, et un contrôleur DMA pour échanger des informations avec la mémoire principale.]]
==Annexe : les architectures à cœurs conjoints==
Sur certains processeurs multicœurs, certains circuits sont partagés entre plusieurs cœurs. Typiquement, l'unité de calcul flottante est partagée entre deux coeurs/''threads'', les unités SIMD qu'on verra dans quelques chapitres sont aussi dans ce cas. Le partage de circuits permet d'éviter de dupliquer trop de circuits et donc d'économiser des transistors. Le problème est que ce partage est source de dépendances structurelles, ce qui peut entraîner des pertes de performances.
Cette technique consistant de partage d'unités de calcul entre coeurs s'appelle le '''cluster multithreading''', ou encore les '''architectures à cœurs conjoints''' (''Conjoined Core Architectures''). Elle est notamment utilisée sur les processeurs AMD de microarchitecture Bulldozer, incluant ses trois révisions ultérieures nommées Piledriver, Steamroller et Excavator. Un exemple est celui des processeurs AMD FX-8150 et FX-8120.
Sur ces processeurs, les instructions sont chargées dans deux files d'instructions séparées, une par ''thread'' matériel. Les instructions sont ensuite décodées par un décodeur unique et renommées dans une unité de renommage unique. Par la suite, il y a deux voies entières séparées et une voie flottante partagée. Chaque voie entière a sa propre fenêtre d'instruction entière, son tampon de ré-ordonnancement, ses unités de calcul dédiées, ses registres, sa ''load-store queue'', son cache L1. Par contre, la voie flottante partage les unités de calcul flottantes et n'a qu'une seule fenêtre d'instruction partagée par les deux ''threads''.
[[File:AMD Bulldozer microarchitecture.png|centre|vignette|upright=3|Microarchitecture Bulldozer d'AMD.]]
La révision Steamroller sépara le ''front-end'' en deux voies distinctes, une par ''thread''. Concrètement, elle ajouta un second décodeur d'instruction, une seconde file de micro-opération et une seconde unité de renommage de registres, afin d'améliorer les performances. Niveaux optimisations mineures, les stations de réservation ont été augmentées, elles peuvent mémoriser plus de micro-opérations, idem pour les bancs de registre et les files de lecture/écriture. Un cache de micro-opérations a été ajouté, de même que des optimisations quant au renommage de registre. Des ALU ont aussi été ajoutées, des FPU retirées.
[[File:AMD excavator microarchitecture.png|centre|vignette|upright=3|Microarchitecture Excavator d'AMD.]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les architectures parallèles
| prevText=Les architectures parallèles
| next=Architectures multithreadées et Hyperthreading
| nextText=Architectures multithreadées et Hyperthreading
}}
</noinclude>
34youtkjsaptzkq4p3uki5z1risgnix
763678
763677
2026-04-14T13:30:24Z
Mewtow
31375
/* Les interruptions inter-processeurs */
763678
wikitext
text/x-wiki
Pour réellement tirer parti du parallélisme de taches, rien ne vaut l'utilisation de plusieurs processeurs et/ou de plusieurs cœurs, qui exécutent chacun un ou plusieurs programmes dans leur coin. Des solutions multiprocesseurs ont alors vu le jour pour rendre l'usage de plusieurs processeurs plus adéquat. Avant de poursuivre, nous allons voir les systèmes multiprocesseur à part des processeurs multicœurs. Il faut dire qu'utiliser plusieurs processeurs et avoir plusieurs cœurs sur la même puce, ce n'est pas la même chose. Particulièrement pour ce qui est de la mémoire cache.
Les '''systèmes multiprocesseur''' placent plusieurs processeurs sur la même carte mère. Ils sont courants dans les serveurs ou les ''data centers'', mais sont beaucoup plus rares pour les ordinateurs grand public. Il y a eu quelques systèmes multiprocesseur vendus au grand public dans les années 2000, certaines cartes mères avaient deux sockets pour mettre deux processeurs. Mais les logiciels et les systèmes d'exploitation grand public n'étaient pas adaptés pour, ce qui fait que la technologie est restée confidentielle.
Puis, en 2005, les '''processeurs multicœurs''' sont arrivés. Ils peuvent être vus comme un regroupement de plusieurs processeurs dans le même circuit intégré. Pour être plus précis, ils contiennent plusieurs ''cœurs'', chaque cœur pouvant exécuter un programme tout seul. Un cœur dispose de toute la machinerie électronique pour exécuter un programme, que ce soit un séquenceur d'instruction, des registres, une unité de calcul. Par contre, certains circuits du processeur sont partagés entre les cœurs, comme les circuits d’interfaçage avec la mémoire.
Les processeurs multicœurs sont devenus la norme dans les ordinateurs grand public et les logiciels et systèmes d'exploitation se sont adaptés. Suivant le nombre de cœurs présents dans notre processeur, celui-ci sera appelé un processeur double-cœur (deux cœurs), quadruple-cœur (4 cœurs), octuple-cœur (8 cœurs), etc. Les processeurs grand public ont généralement entre 8 et 16 cœurs, à l'heure où j'écris ces lignes (2025), rarement au-delà. Par contre, les processeurs pour serveurs dépassent la vingtaine de cœurs. Les serveurs utilisent souvent des architectures dites '''''many core''''', qui ont un très grand nombre de cœurs, plus d'une cinquantaine, voire plusieurs centaines ou milliers.
: Dans ce qui suit, nous utiliserons les termes "processeurs" et "cœurs" comme s'ils étaient synonymes. Tout ce qui vaut pour les systèmes multiprocesseur vaut aussi pour les systèmes multicœurs.
==Le partage des caches==
Quand on conçoit un processeur multicœur, il ne faut pas oublier ce qui arrive à la pièce maîtresse de tout processeur actuel : le cache ! Pour le moment nous allons oublier le fait que les processeurs ont une hiérarchie de caches, avec des caches L1, L2, L3 et autres. Nous allons partir du principe qu'un processeur simple cœur a un seul cache, et voir comment adapter le cache à la présence de plusieurs cœurs. Nous allons rapidement lever cette hypothèse, pour étudier le cas où un processeur multicœur a une hiérarchie de caches, mais seulement après avoir vu le cas le plus simple à un seul cache.
===Le partage des caches sans hiérarchie de caches : caches dédiés et partagés===
Avec un seul niveau de cache, sans hiérarchie, deux solutions sont possibles. La première consiste à garder un seul cache, et de le partager entre les cœurs. L'autre solution est de dupliquer le cache et d'utiliser un cache par cœur. Les deux solutions sont appelées différemment. On parle de '''caches dédiés''' si chaque cœur possède son propre cache, et de '''cache partagé''' avec un cache partagé entre tous les cœurs. Ces deux méthodes ont des inconvénients et des avantages.
{|
|[[File:Caches dédiés.png|vignette|Caches dédiés]]
|[[File:Caches partagés.png|vignette|Cache partagé]]
|}
Le premier point sur lequel comparer caches dédiés et partagés est celui de la capacité du cache. La quantité de mémoire cache que l'on peut placer dans un processeur est limitée, car le cache prend beaucoup de place, près de la moitié des circuits du processeur. Aussi, un processeur incorpore une certaine quantité de mémoire cache, qu'il faut répartir entre un ou plusieurs caches. Les caches dédiés et partagés ne donnent pas le même résultat. D'un côté, le cache partagé fait que toute la mémoire cache est dédiée au cache partagé, qui est très gros. De l'autre, on doit répartir la capacité du cache entre plusieurs caches séparés, individuellement plus petits. En conséquence, on a le choix entre un petit cache pour chaque processeur ou un gros cache partagé.
Le choix entre les deux n'est pas simple, mais doit tenir compte du fait que les programmes exécutés sur les cœurs n'ont pas les mêmes besoins. Certains programmes sont plus gourmands et demandent beaucoup de cache, alors que d'autres utilisent peu la mémoire cache. Avec un cache dédié, tous les programmes ont accès à la même quantité de cache, car les caches des différents cœurs sont de la même taille. Les caches dédiés étant assez petits, les programmes plus gourmands devront se débrouiller avec un petit cache, alors que les autres programmes auront du cache en trop.
À l'opposé, un cache partagé répartit le cache de manière optimale : un programme gourmand peut utiliser autant de cache qu'il veut, laissant juste ce qu'il faut aux programmes moins gourmands. le cache peut être répartit plus facilement selon les besoins des différents programmes.
[[File:Cache partagé contre cache dédié.png|centre|vignette|upright=2.5|Cache partagé contre cache dédié]]
Un autre avantage des caches partagés est quand plusieurs cœurs accèdent aux même données. C'est un cas très courant, souvent lié à l'usage de mémoire partagé ou de ''threads''. Avec des caches dédiés, chaque cœur a une copie des données partagées. Mais avec un cache partagé, il n'y a qu'une seule copie de chaque donnée, ce qui utilise moins de mémoire cache. Imaginons que l'on sait 8 caches dédiés de 8 Kibioctets, soit 64 kibioctets au total, comparé à un cache partagé de même capacité totale. Les doublons dans les caches dédiés réduiront la capacité mémoire utile, effective, comparé à un cache partagé. S'il y a 1 Kibioctet de mémoire partagé, 8 kibioctets seront utilisés pour stocker ces données en doublons, seulement 1 kibioctet sur un cache partagé. Ajoutons aussi que la cohérence des caches est grandement simplifiée avec l'usage d'un cache partagé, vu que les données ne sont pas dupliquées dans plusieurs caches.
Mais le partage du cache peut se transformer en inconvénient si les programmes entrent en compétition pour le cache, que ce soit pour y placer des données ou pour les accès mémoire. Deux programmes peuvent vouloir accéder au cache en même temps, voire carrément se marcher sur les pieds. La résolution des conflits d'accès au cache est résolu soit en prenant un cache multiport, avec un port dédié par cœur, soit par des mécanismes d'arbitrages avec des circuits dédiés. Le revers de la médaille tient au temps de latence. Plus un cache est gros, plus il est lent. En conséquence, des caches dédiés seront plus rapides qu'un gros cache partagé plus lent.
===Le partage des caches adapté à une hiérarchie de caches===
Dans la réalité, un processeur multicœur ne contient pas qu'un seul cache, mais une hiérarchie de caches avec des caches L1, L2 et L3, parfois L4. Dans cette hiérarchie, certains caches sont partagés entre plusieurs cœurs, les autres sont dédiés. Le cache L1 n'est jamais partagé, car il doit avoir un temps d'accès très faible. Pour les autres caches, tout dépend du processeur.
[[File:Dual Core Generic.svg|vignette|Cache L2 partagé.]]
Les premiers processeurs multicœurs commerciaux utilisaient deux niveaux de cache : des caches L1 dédiés et un cache L2 partagé. Le cache L2 partagé était relié aux caches L1, grâce à un système assez complexe d'interconnexions. Le cache de niveau L2 était souvent simple port, car les caches L1 se chargent de filtrer les accès aux caches de niveau inférieurs.
Les processeurs multicœurs modernes ont des caches L3 et même L4, de grande capacité, ce qui a modifié le partage des caches. Le cache de dernier niveau, à savoir le cache le plus proche de la mémoire, est systématiquement partagé, car son rôle est d'être un cache lent mais gros. Il s'agit le plus souvent d'un cache de L3, plus rarement L4. Sur certains processeurs multicœurs, le cache de dernier niveau n'est techniquement pas dans le cœur, mais fait partie d'un ensemble de circuits reliés, comme le contrôleur mémoire ou l'interface mémoire. Il fonctionne à une fréquence différente du processeur, n'a pas la même tension d'alimentation, etc.
Le cas du cache L2 dépend des architectures : il est partagé sur certains processeurs, dédié sur d'autres. Mais sur les processeurs modernes, c'est un cache dédié soit par cœur, soit pour un groupe de cœurs. Dans le cas le plus courant, chaque cache L2 est partagé entre plusieurs cœurs mais pas à tous. En effet, on peut limiter le partage du cache à quelques cœurs particuliers pour des raisons de performances.
[[File:Partage des caches sur un processeur multicoeurs.png|centre|vignette|upright=2.0|Partage des caches sur un processeur multicœur.]]
D'autres processeurs ont des caches L2 dédiés. Il s'agit surtout des processeurs multicœurs anciens, parmi les premières générations de processeurs multicœurs. Un exemple est celui de la microarchitecture Nehalem d'Intel. Il avait des caches L1 et L2 dédiés, mais un cache L3 partagé.
[[File:Nehalem EP.png|centre|vignette|upright=2.0|Partage des caches sur un processeur Intel d'architecture Nehalem.]]
===Les caches partagés centralisés et distribués===
Un point important est que quand on parle de cache partagé ou de cache dédié, on ne parle que de la manière dont les cœurs peuvent accéder au cache, pas de la manière dont le caches est réellement localisé sur la puce. En théorie, qui dit plusieurs caches dédiés signifie que l'on a vraiment plusieurs caches séparés sur la puce. Et chaque cache dédié est proche du cœur qui lui est attribué. Et pour les caches partagés unique, une portion de la puce de silicium contient le cache, que cette portion est un énorme bloc de transistors. Il est généralement placé au milieu de la puce ou sur un côté, histoire de facilement le connecter à tous les cœurs.
Mais pour les caches séparés, ce n'est pas toujours le cas. Avoir un cache énorme poserait des problèmes sur les architectures avec beaucoup de cœurs. En réalité, le cache est souvent découpé en plusieurs banques, reliées à un contrôleur du cache par un système d'interconnexion assez complexe. Les banques sont physiquement séparées, et il arrive qu'elles soient placées proche d'un cœur chacune. L'organisation des banques ressemble beaucoup à l'organisation des caches dédiés, avec une banque étant l'équivalent d'un cache dédié. La différence est que les cœurs peuvent lire et écrire dans toutes les banques, grâce au système d'interconnexion et au contrôleur de cache.
Tel était le cas sur les processeurs AMD Jaguar. Ils avaient un cache L2 de 2 mébioctets, partagés entre tous les cœurs, qui était composé de 4 banques de 512 Kibioctets. Les quatre banques du cache étaient reliées aux 4 cœurs par un réseaux d'interconnexion assez complexe.
[[File:AMDJaguarModule.png|centre|vignette|upright=2|AMD Jaguar Module]]
La différence entre les deux solutions pour les caches partagés porte le nom de cache centralisés versus distribués. Un gros cache unique sur la puce est un '''cache centralisé''', et c'est généralement un cache partagé. Mais un cache composé de plusieurs banques dispersées sur la puce est un '''cache distribué''', qui peut être aussi bien dédié que partagé.
===Les caches virtualisés===
Il faut noter que quelques processeurs utilisent cette technique pour fusionnent le cache L2 et le cache L3. Par exemple, les processeurs IBM Telum utilisent des caches L3 virtualisés, dans leurs versions récentes. Le processeur Telum 2 contient 10 caches L2 de 36 mébioctets chacun, soit 360 mébioctets de cache. L'idée est que ces 360 mébioctets sont partagés à la demande entre cache L2 dédié et cache L3. On parle alors de '''cache virtualisé'''.
Un cache de 36 mébioctet est associé à un cœur, auquel il est directement relié. Les cœurs n'utilisent pas tous leur cache dédié à 100% Il arrive que des cœurs aient des caches partiellement vides, alors que d'autres on un cache qui déborde. L'idée est que si un cœur a un cache plein, les données évincées du cache L2 privé sont déplacées dans le cache L2 d'un autre cœur, qui lui est partiellement vide. Le cache L2 en question est alors partitionné en deux : une portion pour les données associée à son cœur, une portion pour les données des L2 des autres cœurs.
Pour que la technique fonctionne, le processeur mesure le remplissage de chaque cache L2. De plus, il faut gérer la politique de remplacement des lignes de cache. Une ligne de cache évincée du cache doit être déplacé dans un autre L2, pas dans les niveaux de cache inférieur, ni dans la mémoire. Du moins, tant qu'il reste de la place dans le cache L3. De plus, une lecture/écriture dans le cache L3 demande de localiser le cache L2 contenant la donnée. Pour cela, les caches L2 sont tous consultés lors d'un accès au L3, c'est la solution la plus simple, elle marche très bien si le taux de défaut du cache L2 est faible.
Une telle optimisation ressemble beaucoup à un cache L2/L3 distribué, mais il y a quelques différences qui sont décrites dans le paragraphe précédent. Avec un L2 distribué, tout accès au L2 déclencherait une consultation de toutes les banques du L2. Avec un cache L3 virtualisé, ce n'est pas le cas. Le cache L2 associé au cœur est consulté, et c'est seulement en cas de défaut de cache que les autres caches L2 sont consultés. De plus, avec un cache L2 distribué, il n'y a pas de déplacement d'une ligne de cache entre deux banques, entre deux caches L2 physiques. Alors qu'avec un cache L3 virtualisé, c'est le cas en cas de remplacement d'une ligne de cache dans le cache L2.
Sur le processeur Telum 1, le partage du cache L2 est assez simple. Un cache L2 fait 32 mébioctets et est découpé en deux banques de 16 mébioctets. En temps normal, les premiers 16 mébioctets sont toujours associé au cache L2, au cœur associé. Les 16 mébioctets restants peuvent soit être attribués au cache L3, soit fusionnés avec les 16 premiers mébioctets. Dans le cas où le cœur associé est en veille, n'est absolument pas utilisé, les 32 mébioctets sont attribués au cache L3. Un partage assez simple, donc. Le partage du cache L2/L3 sur les processeurs Telum 2 n'est pas connu, il est supposé être plus flexible.
==Le réseau d'interconnexion entre cœurs==
Les systèmes avec plusieurs processeurs incorporent un réseau d'interconnexion pour connecter les processeurs entre eux, ainsi qu'à la mémoire RAM. Il s'agit d'un '''réseau d'interconnexion inter-processeur''', placé sur la carte mère. Les CPU multicœurs ont aussi un tel réseau d'interconnexion, pour relier les cœurs entre eux. La différence est que le réseau d'interconnexion est placé dans le processeur, pas sur la carte mère.
Les systèmes multi-cœurs modernes utilisent des réseaux d'interconnexion standardisés, les standards les plus communs étant l'HyperTransport, l'Intel QuickPath Interconnect, l'IBM Elastic Interface, le Intel Ultra Path Interconnect, l'Infinity Fabric, etc. Ils sont aussi utilisés pour faire communiquer entre eux plusieurs processeurs.
[[File:Architecture multicoeurs et réseau sur puce.png|centre|vignette|upright=1.5|Architecture multicoeurs et réseau sur puce]]
===Le bus partagé entre plusieurs cœurs===
Pour un faible nombre de coeurs/processeurs, la solution utilisée relie les processeurs entre eux grâce au bus mémoire. Le bus mémoire est donc un '''bus partagé''', avec tout ce que cela implique.
[[File:Architecture multicoeurs à bus partagé.png|centre|vignette|upright=2|Architecture multiprocesseurs à bus partagé]]
Pour les systèmes multicœurs, l'usage d'un bus partagé doit être adaptée pour tenir compte des caches partagés. Voyons d'abord le cas d'un CPU avec deux niveaux de cache, dont un cache L2 est partagé entre tous les cœurs. Les caches L1 sont reliés au cache L2 partagé par un bus, qui n'a souvent pas de nom. Nous désignerons le bus entre le cache L1 et le cache L2 : '''bus partagé''', sous-entendu partagé entre tous les caches. C'est lui qui sert à connecter les cœurs entre eux.
[[File:Architecture multicoeurs à bus partagé entre caches L1 et L2.png|centre|vignette|upright=2|Architecture multicoeurs à bus partagé entre caches L1 et L2]]
Un processeur multicœur typique a une architecture avec trois niveaux de cache (L1, L2 et L3), avec un niveau L1 dédié par cœur, un niveau L2 partiellement partagé et un L3 totalement partagé. Le bus partagé est alors difficile à décrire, mais il correspond à l'ensemble des bus qui connectent les caches L1 aux caches L2, et les caches L2 au cache L3. Il s'agit alors d'un ensemble de bus, plus que d'un bus partagé unique.
L'usage d'un bus partagé a cependant de nombreux défauts. Par exemple, les processeurs doivent se répartir l'accès au bus mémoire, il faut gérer le cas où deux processeurs accèdent au bus en même temps, etc. Pour cela, un composant dédié s'occupe de l'arbitrage entre processeurs. Il est généralement placé sur la carte mère de l'ordinateur, dans le ''chipset'', dans le pont nord, ou un endroit proche. D'autres défauts très importants seront abordés en détail dans le chapitre sur la cohérence des caches
[[File:Intel486 System Arbitration.png|centre|vignette|upright=2|Système avec deux Intel486 - Arbitrage du bus mémoire.]]
===Le réseau d'interconnexion entre plusieurs cœurs===
Relier plusieurs cœurs avec des bus pose de nombreux problèmes techniques qui sont d'autant plus problématiques que le nombre de cœurs augmente. Le câblage est notamment très complexe, les contraintes électriques pour la transmission des signaux sont beaucoup plus fortes, les problèmes d'arbitrages se font plus fréquents, etc. Pour régler ces problèmes, les processeurs multicoeurs n'utilisent pas de bus partagé, mais un réseau d'interconnexion plus complexe.
Un exemple de réseau d'interconnexion est celui des architectures AMD EPYC, de microarchitecture Zen 1. Elles utilisaient des chiplets, à savoir que le processeur était composé de plusieurs puces interconnectées entre elles. Chaque puce contenait un processeur multicoeurs intégrant un cache L3, avec un réseau d'interconnexion interne au processeur sans doute basé sur un ensemble de bus. De plus, les puces étaient reliées à une puce d'interconnexion qui servait à la fois d'interface entre les processeurs, mais aussi d'interface avec la R1AM, le bus PCI-Express, etc. La puce d'interconnexion était gravée en 14 nm contre 7nm pour les chiplets des cœurs.
{|
|[[File:AMD Epyc 7702 delidded.jpg|centre|vignette|upright=2|AMD Epyc 7702.]]
|[[File:AMD Epyc Rome Aufbau.png|centre|vignette|upright=2|Schéma fonctionnel de l'AMD Epyc.]]
|}
Le réseau d'interconnexion peut être très complexe, avec des connexions réseau, des commutateurs, et des protocoles d'échanges entre processeurs assez complexes basés sur du passage de messages. De telles puces utilisent un '''réseau sur puce''' (''network on chip''). Mais d'autres simplifient le réseau d'interconnexion, qui se résume à un réseau ''crossbar'', voire à des mémoires FIFO pour faire l'interface entre les cœurs.
Le problème principal des réseaux sur puce est que les mémoires FIFOs sont difficiles à implémenter sur une puce de silicium. Elles prennent beaucoup de place, utilisent beaucoup de portes logiques, consomment beaucoup d'énergie, sont difficiles à concevoir pour diverses raisons (les accès concurrents/simultanés sont fréquents et font mauvais ménage avec les ''timings'' serrés de quelques cycles d'horloges requis). Il est donc impossible de placer beaucoup de mémoires FIFO dans un processeur, ce qui fait que les commutateur sont réduits à leur strict minimum : un réseau d'interconnexion, un système d'arbitrage simple parfois sans aucune FIFO, guère plus.
===Les architectures en ''tile''===
Un cas particulier de réseau sur puce est celui des '''architectures en ''tile''''', des architectures avec un grand nombre de cœurs, connectés les unes aux autres par un réseau d'interconnexion "rectangulaire". Chaque cœur est associé à un commutateur (''switch'') qui le connecte au réseau d'interconnexion, l'ensemble formant une ''tile''.
[[File:Tile64-Tile.svg|centre|vignette|upright=1.5|''Tile'' de base du Tile64.]]
Le réseau est souvent organisé en tableau, chaque ''tile'' étant connectée à plusieurs voisines. Dans le cas le plus fréquent, chaque ''tile'' est connectée à quatre voisines : celle du dessus, celle du dessous, celle de gauche et celle de droite. Précisons que cette architecture n'est pas une architecture distribuée dont tous les processeurs seraient placés sur la même puce de silicium. En effet, la comparaison ne marche pas pour ce qui est de la mémoire : tous les cœurs accèdent à une mémoire partagée située en dehors de la puce de silicium. Le réseau ne connecte pas plusieurs ordinateurs séparés avec chacun leur propre mémoire, mais plusieurs cœurs qui accèdent à une mémoire partagée.
Un bon exemple d'architecture en ''tile'' serait les déclinaisons de l'architecture Tilera. Les schémas du-dessous montrent l'architecture du processeur Tile 64. Outre les ''tiles'', qui sont les éléments de calcul de l'architecture, on trouve plusieurs contrôleurs mémoire DDR, divers interfaces réseau, des interfaces série et parallèles, et d'autres entrées-sorties.
[[File:Tile64.svg|centre|vignette|upright=2|Architecture Tile64 du Tilera.]]
==Les interruptions inter-processeurs==
Les '''interruptions inter-processeurs''' sont des interruptions déclenchées sur un processeur et exécutées sur un autre. Elles sont très utiles pour le système d'exploitation, pour des raisons qu'on ne peut pas expliquer ici. Pour générer des interruptions inter-processeur, le contrôleur d'interruption doit pouvoir rediriger des interruptions déclenchées par un processeur vers un autre.
Une interruption inter-processeurs peut être envoyée soit à un cœur bien précis, soit à n'importe quel cœur, soit à tous les cœurs. Tout dépend de ce que décide le système d'exploitation. Les trois situations ne sont pas identiques, sur un point : comment préciser quel est le processeur de destination ? Si on envoie une interruption à un cœur bien précis, il faut préciser quel est le cœur qui réceptionne l'interruption. Pour cela, il n'y a pas 36 solutions : on numérote les processeurs/cœurs avec un '''numéro de processeur'''. Ce numéro leur est soit attribué au démarrage par le BIOS, soit est gravé dans leur silicium pour les processeurs multicœurs.
L'utilité des interruptions inter-processeur est assez variée. Elles servaient autrefois pour la cohérence des caches, mais nous détaillerons cela dans un futur chapitre. Pour le reste, les interruptions inter-processeurs sont identiques aux interruptions normales. Elles ont un système de priorités, certaines devant passer avant les autres, là encore défini par des ''Interrupt Request Levels'' (IRQLs) ou quelque chose de similaire. Il peut y avoir des interruptions inter-processeur de type logicielles, à savoir lancées par une instruction machine. Par exemple, sur les IBM System/360 et les ''mainframes'' z/Architecture, le processeur avait une instruction SIGNAL PROCESSOR pour déclencher des interruptions logicielles inter-processeur.
Qui dit interruptions dit contrôleur d'interruption. Et celui-ci doit être adapté pour gérer les interruptions interprocesseurs. Pour comprendre comment il est modifié par la présence de plusieurs cœurs, voyons le cas des CPU x86. L'ancien contrôleur d'interruption 8259A ne gérait pas les interruptions inter-processeurs, ce qui fait que les cartes mères multiprocesseurs devaient incorporer un contrôleur d'interruption spécial en complément. Par contre, son successeur, l'APIC, les gérait nativement.
De nos jours, chaque cœur x86 possède son propre contrôleur d’interruption, le '''''local APIC''''', qui gère les interruptions en provenance ou arrivant vers ce processeur. On trouve aussi un '''''IO-APIC''''', qui gère les interruptions en provenance des périphériques et de les redistribuer vers les APIC locaux. L'IO-APIC gère aussi les interruptions inter-processeurs en faisant passer les interruptions d'un local APIC vers un autre. Tous les APIC locaux et l'IO-APIC sont reliés ensembles par un '''bus APIC''' spécialisé, par lequel ils vont pouvoir communiquer et s'échanger des demandes d'interruptions.
[[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 déclenchement d'une interruption inter-processeur se fait en écrivant dans un registre appelé l''''''Interrupt Command Register'''''. Un détail important est que l'écriture se fait dans le ''local APIC'' de l'envoyer, du processeur qui veut envoyer une interruption, pas dans le registre du processeur qui doit réceptionner l'interruption ! Le registre est composé de deux registres de 32 bits, et mémorise : le numéro du processeur de destination, le mode de transfert (à tous, à un cœur, etc), le numéro du vecteur d'interruption (pour préciser quelle interruption exécuter), et quelques informations supplémentaires. À charge de l'IO-APIC de faire ce qu'il faut en fonction du contenu de ce registre.
==Le multiprocesseur/multicœur asymétrique==
Sur les processeurs grand public actuels, les cœurs d'un processeur multicœurs sont tous identiques. Mais ce n'est certainement pas une obligation. On peut très bien regrouper plusieurs cœurs très différents, par exemple un cœur principal avec des cœurs plus spécialisés autour. Il faut ainsi distinguer le '''multicœurs symétrique''', dans lequel on place des processeurs identiques sur la même puce de silicium, du '''multicœurs asymétrique''' où les cœurs ne sont pas identiques. Et il en est de même sur les systèmes avec plusieurs processeurs : on parle de '''multiprocesseur symétrique''' si les processeurs sont identiques, '''multiprocesseur asymétrique''' s'ils sont différents.
Précisons ce que nous entendons par "cœurs différents" ou "identiques". Les processeurs Intel modernes utilisent deux types de cœurs différents : des cœurs P et des cœurs E. Le P est pour ''Performance'', le E est pour "Efficiency". Les deux ont le même jeu d'instruction : ce sont des processeurs x86. Par contre, ils ont des microarchitectures différentes. Et Intel n'est pas le seul à utiliser cette technique : ARM a fait pareil avec ses CPU d'architecture ''Big-little''. Il n'est pas clair si de telles organisation sont du multicœur symétrique ou asymétrique. Le jeu d'instruction est identique, sauf éventuellement pour certaines extension comme l'AVX. Les deux coeurs n'ont pas les mêmes performances, mais est-ce suffisant ? La terminologie n'est pas claire.
Un exemple de multicoeurs asymétrique est celui du processeur CELL de la console de jeu PS3. Il était conçu spécifiquement pour cette console. Il intègre un cœur principal POWER PC v5 et 8 cœurs qui servent de processeurs auxiliaires. Le processeur principal est appelé le PPE et les processeurs auxiliaires sont les SPE. Les SPE sont reliés à une mémoire locale (''local store'') de 256 kibioctets qui communique avec le processeur principal via un bus spécial. Cette fois-ci, les coprocesseurs sont intégrés dans le même processeur.
Les SPE communiquent avec la RAM principale via des contrôleurs DMA. Les SPE possèdent des instructions permettant de commander leur contrôleur DMA et c'est le seul moyen qu'ils ont pour récupérer des informations depuis la mémoire. Et c'est au programmeur de gérer tout ça ! C'est le processeur principal qui va envoyer aux SPE les programmes qu'ils doivent exécuter. Il délègue des calculs aux SPE en écrivant dans le local store du SPE et en lui ordonnant l’exécution du programme qu'il vient d'écrire.
[[File:Schema Cell.png|centre|vignette|upright=2|Architecture du processeur CELL de la PS3. Le PPE est le processeur principal, les SPE sont des processeurs auxiliaires qui comprennent : un ''local store'' noté LS, un processeur noté SXU, et un contrôleur DMA pour échanger des informations avec la mémoire principale.]]
==Annexe : les architectures à cœurs conjoints==
Sur certains processeurs multicœurs, certains circuits sont partagés entre plusieurs cœurs. Typiquement, l'unité de calcul flottante est partagée entre deux coeurs/''threads'', les unités SIMD qu'on verra dans quelques chapitres sont aussi dans ce cas. Le partage de circuits permet d'éviter de dupliquer trop de circuits et donc d'économiser des transistors. Le problème est que ce partage est source de dépendances structurelles, ce qui peut entraîner des pertes de performances.
Cette technique consistant de partage d'unités de calcul entre coeurs s'appelle le '''cluster multithreading''', ou encore les '''architectures à cœurs conjoints''' (''Conjoined Core Architectures''). Elle est notamment utilisée sur les processeurs AMD de microarchitecture Bulldozer, incluant ses trois révisions ultérieures nommées Piledriver, Steamroller et Excavator. Un exemple est celui des processeurs AMD FX-8150 et FX-8120.
Sur ces processeurs, les instructions sont chargées dans deux files d'instructions séparées, une par ''thread'' matériel. Les instructions sont ensuite décodées par un décodeur unique et renommées dans une unité de renommage unique. Par la suite, il y a deux voies entières séparées et une voie flottante partagée. Chaque voie entière a sa propre fenêtre d'instruction entière, son tampon de ré-ordonnancement, ses unités de calcul dédiées, ses registres, sa ''load-store queue'', son cache L1. Par contre, la voie flottante partage les unités de calcul flottantes et n'a qu'une seule fenêtre d'instruction partagée par les deux ''threads''.
[[File:AMD Bulldozer microarchitecture.png|centre|vignette|upright=3|Microarchitecture Bulldozer d'AMD.]]
La révision Steamroller sépara le ''front-end'' en deux voies distinctes, une par ''thread''. Concrètement, elle ajouta un second décodeur d'instruction, une seconde file de micro-opération et une seconde unité de renommage de registres, afin d'améliorer les performances. Niveaux optimisations mineures, les stations de réservation ont été augmentées, elles peuvent mémoriser plus de micro-opérations, idem pour les bancs de registre et les files de lecture/écriture. Un cache de micro-opérations a été ajouté, de même que des optimisations quant au renommage de registre. Des ALU ont aussi été ajoutées, des FPU retirées.
[[File:AMD excavator microarchitecture.png|centre|vignette|upright=3|Microarchitecture Excavator d'AMD.]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les architectures parallèles
| prevText=Les architectures parallèles
| next=Architectures multithreadées et Hyperthreading
| nextText=Architectures multithreadées et Hyperthreading
}}
</noinclude>
s53g3lrxl48b5usvhyb8vjtjenng9yv
763679
763678
2026-04-14T16:16:26Z
Mewtow
31375
/* Les interruptions inter-processeurs */
763679
wikitext
text/x-wiki
Pour réellement tirer parti du parallélisme de taches, rien ne vaut l'utilisation de plusieurs processeurs et/ou de plusieurs cœurs, qui exécutent chacun un ou plusieurs programmes dans leur coin. Des solutions multiprocesseurs ont alors vu le jour pour rendre l'usage de plusieurs processeurs plus adéquat. Avant de poursuivre, nous allons voir les systèmes multiprocesseur à part des processeurs multicœurs. Il faut dire qu'utiliser plusieurs processeurs et avoir plusieurs cœurs sur la même puce, ce n'est pas la même chose. Particulièrement pour ce qui est de la mémoire cache.
Les '''systèmes multiprocesseur''' placent plusieurs processeurs sur la même carte mère. Ils sont courants dans les serveurs ou les ''data centers'', mais sont beaucoup plus rares pour les ordinateurs grand public. Il y a eu quelques systèmes multiprocesseur vendus au grand public dans les années 2000, certaines cartes mères avaient deux sockets pour mettre deux processeurs. Mais les logiciels et les systèmes d'exploitation grand public n'étaient pas adaptés pour, ce qui fait que la technologie est restée confidentielle.
Puis, en 2005, les '''processeurs multicœurs''' sont arrivés. Ils peuvent être vus comme un regroupement de plusieurs processeurs dans le même circuit intégré. Pour être plus précis, ils contiennent plusieurs ''cœurs'', chaque cœur pouvant exécuter un programme tout seul. Un cœur dispose de toute la machinerie électronique pour exécuter un programme, que ce soit un séquenceur d'instruction, des registres, une unité de calcul. Par contre, certains circuits du processeur sont partagés entre les cœurs, comme les circuits d’interfaçage avec la mémoire.
Les processeurs multicœurs sont devenus la norme dans les ordinateurs grand public et les logiciels et systèmes d'exploitation se sont adaptés. Suivant le nombre de cœurs présents dans notre processeur, celui-ci sera appelé un processeur double-cœur (deux cœurs), quadruple-cœur (4 cœurs), octuple-cœur (8 cœurs), etc. Les processeurs grand public ont généralement entre 8 et 16 cœurs, à l'heure où j'écris ces lignes (2025), rarement au-delà. Par contre, les processeurs pour serveurs dépassent la vingtaine de cœurs. Les serveurs utilisent souvent des architectures dites '''''many core''''', qui ont un très grand nombre de cœurs, plus d'une cinquantaine, voire plusieurs centaines ou milliers.
: Dans ce qui suit, nous utiliserons les termes "processeurs" et "cœurs" comme s'ils étaient synonymes. Tout ce qui vaut pour les systèmes multiprocesseur vaut aussi pour les systèmes multicœurs.
==Le partage des caches==
Quand on conçoit un processeur multicœur, il ne faut pas oublier ce qui arrive à la pièce maîtresse de tout processeur actuel : le cache ! Pour le moment nous allons oublier le fait que les processeurs ont une hiérarchie de caches, avec des caches L1, L2, L3 et autres. Nous allons partir du principe qu'un processeur simple cœur a un seul cache, et voir comment adapter le cache à la présence de plusieurs cœurs. Nous allons rapidement lever cette hypothèse, pour étudier le cas où un processeur multicœur a une hiérarchie de caches, mais seulement après avoir vu le cas le plus simple à un seul cache.
===Le partage des caches sans hiérarchie de caches : caches dédiés et partagés===
Avec un seul niveau de cache, sans hiérarchie, deux solutions sont possibles. La première consiste à garder un seul cache, et de le partager entre les cœurs. L'autre solution est de dupliquer le cache et d'utiliser un cache par cœur. Les deux solutions sont appelées différemment. On parle de '''caches dédiés''' si chaque cœur possède son propre cache, et de '''cache partagé''' avec un cache partagé entre tous les cœurs. Ces deux méthodes ont des inconvénients et des avantages.
{|
|[[File:Caches dédiés.png|vignette|Caches dédiés]]
|[[File:Caches partagés.png|vignette|Cache partagé]]
|}
Le premier point sur lequel comparer caches dédiés et partagés est celui de la capacité du cache. La quantité de mémoire cache que l'on peut placer dans un processeur est limitée, car le cache prend beaucoup de place, près de la moitié des circuits du processeur. Aussi, un processeur incorpore une certaine quantité de mémoire cache, qu'il faut répartir entre un ou plusieurs caches. Les caches dédiés et partagés ne donnent pas le même résultat. D'un côté, le cache partagé fait que toute la mémoire cache est dédiée au cache partagé, qui est très gros. De l'autre, on doit répartir la capacité du cache entre plusieurs caches séparés, individuellement plus petits. En conséquence, on a le choix entre un petit cache pour chaque processeur ou un gros cache partagé.
Le choix entre les deux n'est pas simple, mais doit tenir compte du fait que les programmes exécutés sur les cœurs n'ont pas les mêmes besoins. Certains programmes sont plus gourmands et demandent beaucoup de cache, alors que d'autres utilisent peu la mémoire cache. Avec un cache dédié, tous les programmes ont accès à la même quantité de cache, car les caches des différents cœurs sont de la même taille. Les caches dédiés étant assez petits, les programmes plus gourmands devront se débrouiller avec un petit cache, alors que les autres programmes auront du cache en trop.
À l'opposé, un cache partagé répartit le cache de manière optimale : un programme gourmand peut utiliser autant de cache qu'il veut, laissant juste ce qu'il faut aux programmes moins gourmands. le cache peut être répartit plus facilement selon les besoins des différents programmes.
[[File:Cache partagé contre cache dédié.png|centre|vignette|upright=2.5|Cache partagé contre cache dédié]]
Un autre avantage des caches partagés est quand plusieurs cœurs accèdent aux même données. C'est un cas très courant, souvent lié à l'usage de mémoire partagé ou de ''threads''. Avec des caches dédiés, chaque cœur a une copie des données partagées. Mais avec un cache partagé, il n'y a qu'une seule copie de chaque donnée, ce qui utilise moins de mémoire cache. Imaginons que l'on sait 8 caches dédiés de 8 Kibioctets, soit 64 kibioctets au total, comparé à un cache partagé de même capacité totale. Les doublons dans les caches dédiés réduiront la capacité mémoire utile, effective, comparé à un cache partagé. S'il y a 1 Kibioctet de mémoire partagé, 8 kibioctets seront utilisés pour stocker ces données en doublons, seulement 1 kibioctet sur un cache partagé. Ajoutons aussi que la cohérence des caches est grandement simplifiée avec l'usage d'un cache partagé, vu que les données ne sont pas dupliquées dans plusieurs caches.
Mais le partage du cache peut se transformer en inconvénient si les programmes entrent en compétition pour le cache, que ce soit pour y placer des données ou pour les accès mémoire. Deux programmes peuvent vouloir accéder au cache en même temps, voire carrément se marcher sur les pieds. La résolution des conflits d'accès au cache est résolu soit en prenant un cache multiport, avec un port dédié par cœur, soit par des mécanismes d'arbitrages avec des circuits dédiés. Le revers de la médaille tient au temps de latence. Plus un cache est gros, plus il est lent. En conséquence, des caches dédiés seront plus rapides qu'un gros cache partagé plus lent.
===Le partage des caches adapté à une hiérarchie de caches===
Dans la réalité, un processeur multicœur ne contient pas qu'un seul cache, mais une hiérarchie de caches avec des caches L1, L2 et L3, parfois L4. Dans cette hiérarchie, certains caches sont partagés entre plusieurs cœurs, les autres sont dédiés. Le cache L1 n'est jamais partagé, car il doit avoir un temps d'accès très faible. Pour les autres caches, tout dépend du processeur.
[[File:Dual Core Generic.svg|vignette|Cache L2 partagé.]]
Les premiers processeurs multicœurs commerciaux utilisaient deux niveaux de cache : des caches L1 dédiés et un cache L2 partagé. Le cache L2 partagé était relié aux caches L1, grâce à un système assez complexe d'interconnexions. Le cache de niveau L2 était souvent simple port, car les caches L1 se chargent de filtrer les accès aux caches de niveau inférieurs.
Les processeurs multicœurs modernes ont des caches L3 et même L4, de grande capacité, ce qui a modifié le partage des caches. Le cache de dernier niveau, à savoir le cache le plus proche de la mémoire, est systématiquement partagé, car son rôle est d'être un cache lent mais gros. Il s'agit le plus souvent d'un cache de L3, plus rarement L4. Sur certains processeurs multicœurs, le cache de dernier niveau n'est techniquement pas dans le cœur, mais fait partie d'un ensemble de circuits reliés, comme le contrôleur mémoire ou l'interface mémoire. Il fonctionne à une fréquence différente du processeur, n'a pas la même tension d'alimentation, etc.
Le cas du cache L2 dépend des architectures : il est partagé sur certains processeurs, dédié sur d'autres. Mais sur les processeurs modernes, c'est un cache dédié soit par cœur, soit pour un groupe de cœurs. Dans le cas le plus courant, chaque cache L2 est partagé entre plusieurs cœurs mais pas à tous. En effet, on peut limiter le partage du cache à quelques cœurs particuliers pour des raisons de performances.
[[File:Partage des caches sur un processeur multicoeurs.png|centre|vignette|upright=2.0|Partage des caches sur un processeur multicœur.]]
D'autres processeurs ont des caches L2 dédiés. Il s'agit surtout des processeurs multicœurs anciens, parmi les premières générations de processeurs multicœurs. Un exemple est celui de la microarchitecture Nehalem d'Intel. Il avait des caches L1 et L2 dédiés, mais un cache L3 partagé.
[[File:Nehalem EP.png|centre|vignette|upright=2.0|Partage des caches sur un processeur Intel d'architecture Nehalem.]]
===Les caches partagés centralisés et distribués===
Un point important est que quand on parle de cache partagé ou de cache dédié, on ne parle que de la manière dont les cœurs peuvent accéder au cache, pas de la manière dont le caches est réellement localisé sur la puce. En théorie, qui dit plusieurs caches dédiés signifie que l'on a vraiment plusieurs caches séparés sur la puce. Et chaque cache dédié est proche du cœur qui lui est attribué. Et pour les caches partagés unique, une portion de la puce de silicium contient le cache, que cette portion est un énorme bloc de transistors. Il est généralement placé au milieu de la puce ou sur un côté, histoire de facilement le connecter à tous les cœurs.
Mais pour les caches séparés, ce n'est pas toujours le cas. Avoir un cache énorme poserait des problèmes sur les architectures avec beaucoup de cœurs. En réalité, le cache est souvent découpé en plusieurs banques, reliées à un contrôleur du cache par un système d'interconnexion assez complexe. Les banques sont physiquement séparées, et il arrive qu'elles soient placées proche d'un cœur chacune. L'organisation des banques ressemble beaucoup à l'organisation des caches dédiés, avec une banque étant l'équivalent d'un cache dédié. La différence est que les cœurs peuvent lire et écrire dans toutes les banques, grâce au système d'interconnexion et au contrôleur de cache.
Tel était le cas sur les processeurs AMD Jaguar. Ils avaient un cache L2 de 2 mébioctets, partagés entre tous les cœurs, qui était composé de 4 banques de 512 Kibioctets. Les quatre banques du cache étaient reliées aux 4 cœurs par un réseaux d'interconnexion assez complexe.
[[File:AMDJaguarModule.png|centre|vignette|upright=2|AMD Jaguar Module]]
La différence entre les deux solutions pour les caches partagés porte le nom de cache centralisés versus distribués. Un gros cache unique sur la puce est un '''cache centralisé''', et c'est généralement un cache partagé. Mais un cache composé de plusieurs banques dispersées sur la puce est un '''cache distribué''', qui peut être aussi bien dédié que partagé.
===Les caches virtualisés===
Il faut noter que quelques processeurs utilisent cette technique pour fusionnent le cache L2 et le cache L3. Par exemple, les processeurs IBM Telum utilisent des caches L3 virtualisés, dans leurs versions récentes. Le processeur Telum 2 contient 10 caches L2 de 36 mébioctets chacun, soit 360 mébioctets de cache. L'idée est que ces 360 mébioctets sont partagés à la demande entre cache L2 dédié et cache L3. On parle alors de '''cache virtualisé'''.
Un cache de 36 mébioctet est associé à un cœur, auquel il est directement relié. Les cœurs n'utilisent pas tous leur cache dédié à 100% Il arrive que des cœurs aient des caches partiellement vides, alors que d'autres on un cache qui déborde. L'idée est que si un cœur a un cache plein, les données évincées du cache L2 privé sont déplacées dans le cache L2 d'un autre cœur, qui lui est partiellement vide. Le cache L2 en question est alors partitionné en deux : une portion pour les données associée à son cœur, une portion pour les données des L2 des autres cœurs.
Pour que la technique fonctionne, le processeur mesure le remplissage de chaque cache L2. De plus, il faut gérer la politique de remplacement des lignes de cache. Une ligne de cache évincée du cache doit être déplacé dans un autre L2, pas dans les niveaux de cache inférieur, ni dans la mémoire. Du moins, tant qu'il reste de la place dans le cache L3. De plus, une lecture/écriture dans le cache L3 demande de localiser le cache L2 contenant la donnée. Pour cela, les caches L2 sont tous consultés lors d'un accès au L3, c'est la solution la plus simple, elle marche très bien si le taux de défaut du cache L2 est faible.
Une telle optimisation ressemble beaucoup à un cache L2/L3 distribué, mais il y a quelques différences qui sont décrites dans le paragraphe précédent. Avec un L2 distribué, tout accès au L2 déclencherait une consultation de toutes les banques du L2. Avec un cache L3 virtualisé, ce n'est pas le cas. Le cache L2 associé au cœur est consulté, et c'est seulement en cas de défaut de cache que les autres caches L2 sont consultés. De plus, avec un cache L2 distribué, il n'y a pas de déplacement d'une ligne de cache entre deux banques, entre deux caches L2 physiques. Alors qu'avec un cache L3 virtualisé, c'est le cas en cas de remplacement d'une ligne de cache dans le cache L2.
Sur le processeur Telum 1, le partage du cache L2 est assez simple. Un cache L2 fait 32 mébioctets et est découpé en deux banques de 16 mébioctets. En temps normal, les premiers 16 mébioctets sont toujours associé au cache L2, au cœur associé. Les 16 mébioctets restants peuvent soit être attribués au cache L3, soit fusionnés avec les 16 premiers mébioctets. Dans le cas où le cœur associé est en veille, n'est absolument pas utilisé, les 32 mébioctets sont attribués au cache L3. Un partage assez simple, donc. Le partage du cache L2/L3 sur les processeurs Telum 2 n'est pas connu, il est supposé être plus flexible.
==Le réseau d'interconnexion entre cœurs==
Les systèmes avec plusieurs processeurs incorporent un réseau d'interconnexion pour connecter les processeurs entre eux, ainsi qu'à la mémoire RAM. Il s'agit d'un '''réseau d'interconnexion inter-processeur''', placé sur la carte mère. Les CPU multicœurs ont aussi un tel réseau d'interconnexion, pour relier les cœurs entre eux. La différence est que le réseau d'interconnexion est placé dans le processeur, pas sur la carte mère.
Les systèmes multi-cœurs modernes utilisent des réseaux d'interconnexion standardisés, les standards les plus communs étant l'HyperTransport, l'Intel QuickPath Interconnect, l'IBM Elastic Interface, le Intel Ultra Path Interconnect, l'Infinity Fabric, etc. Ils sont aussi utilisés pour faire communiquer entre eux plusieurs processeurs.
[[File:Architecture multicoeurs et réseau sur puce.png|centre|vignette|upright=1.5|Architecture multicoeurs et réseau sur puce]]
===Le bus partagé entre plusieurs cœurs===
Pour un faible nombre de coeurs/processeurs, la solution utilisée relie les processeurs entre eux grâce au bus mémoire. Le bus mémoire est donc un '''bus partagé''', avec tout ce que cela implique.
[[File:Architecture multicoeurs à bus partagé.png|centre|vignette|upright=2|Architecture multiprocesseurs à bus partagé]]
Pour les systèmes multicœurs, l'usage d'un bus partagé doit être adaptée pour tenir compte des caches partagés. Voyons d'abord le cas d'un CPU avec deux niveaux de cache, dont un cache L2 est partagé entre tous les cœurs. Les caches L1 sont reliés au cache L2 partagé par un bus, qui n'a souvent pas de nom. Nous désignerons le bus entre le cache L1 et le cache L2 : '''bus partagé''', sous-entendu partagé entre tous les caches. C'est lui qui sert à connecter les cœurs entre eux.
[[File:Architecture multicoeurs à bus partagé entre caches L1 et L2.png|centre|vignette|upright=2|Architecture multicoeurs à bus partagé entre caches L1 et L2]]
Un processeur multicœur typique a une architecture avec trois niveaux de cache (L1, L2 et L3), avec un niveau L1 dédié par cœur, un niveau L2 partiellement partagé et un L3 totalement partagé. Le bus partagé est alors difficile à décrire, mais il correspond à l'ensemble des bus qui connectent les caches L1 aux caches L2, et les caches L2 au cache L3. Il s'agit alors d'un ensemble de bus, plus que d'un bus partagé unique.
L'usage d'un bus partagé a cependant de nombreux défauts. Par exemple, les processeurs doivent se répartir l'accès au bus mémoire, il faut gérer le cas où deux processeurs accèdent au bus en même temps, etc. Pour cela, un composant dédié s'occupe de l'arbitrage entre processeurs. Il est généralement placé sur la carte mère de l'ordinateur, dans le ''chipset'', dans le pont nord, ou un endroit proche. D'autres défauts très importants seront abordés en détail dans le chapitre sur la cohérence des caches
[[File:Intel486 System Arbitration.png|centre|vignette|upright=2|Système avec deux Intel486 - Arbitrage du bus mémoire.]]
===Le réseau d'interconnexion entre plusieurs cœurs===
Relier plusieurs cœurs avec des bus pose de nombreux problèmes techniques qui sont d'autant plus problématiques que le nombre de cœurs augmente. Le câblage est notamment très complexe, les contraintes électriques pour la transmission des signaux sont beaucoup plus fortes, les problèmes d'arbitrages se font plus fréquents, etc. Pour régler ces problèmes, les processeurs multicoeurs n'utilisent pas de bus partagé, mais un réseau d'interconnexion plus complexe.
Un exemple de réseau d'interconnexion est celui des architectures AMD EPYC, de microarchitecture Zen 1. Elles utilisaient des chiplets, à savoir que le processeur était composé de plusieurs puces interconnectées entre elles. Chaque puce contenait un processeur multicoeurs intégrant un cache L3, avec un réseau d'interconnexion interne au processeur sans doute basé sur un ensemble de bus. De plus, les puces étaient reliées à une puce d'interconnexion qui servait à la fois d'interface entre les processeurs, mais aussi d'interface avec la R1AM, le bus PCI-Express, etc. La puce d'interconnexion était gravée en 14 nm contre 7nm pour les chiplets des cœurs.
{|
|[[File:AMD Epyc 7702 delidded.jpg|centre|vignette|upright=2|AMD Epyc 7702.]]
|[[File:AMD Epyc Rome Aufbau.png|centre|vignette|upright=2|Schéma fonctionnel de l'AMD Epyc.]]
|}
Le réseau d'interconnexion peut être très complexe, avec des connexions réseau, des commutateurs, et des protocoles d'échanges entre processeurs assez complexes basés sur du passage de messages. De telles puces utilisent un '''réseau sur puce''' (''network on chip''). Mais d'autres simplifient le réseau d'interconnexion, qui se résume à un réseau ''crossbar'', voire à des mémoires FIFO pour faire l'interface entre les cœurs.
Le problème principal des réseaux sur puce est que les mémoires FIFOs sont difficiles à implémenter sur une puce de silicium. Elles prennent beaucoup de place, utilisent beaucoup de portes logiques, consomment beaucoup d'énergie, sont difficiles à concevoir pour diverses raisons (les accès concurrents/simultanés sont fréquents et font mauvais ménage avec les ''timings'' serrés de quelques cycles d'horloges requis). Il est donc impossible de placer beaucoup de mémoires FIFO dans un processeur, ce qui fait que les commutateur sont réduits à leur strict minimum : un réseau d'interconnexion, un système d'arbitrage simple parfois sans aucune FIFO, guère plus.
===Les architectures en ''tile''===
Un cas particulier de réseau sur puce est celui des '''architectures en ''tile''''', des architectures avec un grand nombre de cœurs, connectés les unes aux autres par un réseau d'interconnexion "rectangulaire". Chaque cœur est associé à un commutateur (''switch'') qui le connecte au réseau d'interconnexion, l'ensemble formant une ''tile''.
[[File:Tile64-Tile.svg|centre|vignette|upright=1.5|''Tile'' de base du Tile64.]]
Le réseau est souvent organisé en tableau, chaque ''tile'' étant connectée à plusieurs voisines. Dans le cas le plus fréquent, chaque ''tile'' est connectée à quatre voisines : celle du dessus, celle du dessous, celle de gauche et celle de droite. Précisons que cette architecture n'est pas une architecture distribuée dont tous les processeurs seraient placés sur la même puce de silicium. En effet, la comparaison ne marche pas pour ce qui est de la mémoire : tous les cœurs accèdent à une mémoire partagée située en dehors de la puce de silicium. Le réseau ne connecte pas plusieurs ordinateurs séparés avec chacun leur propre mémoire, mais plusieurs cœurs qui accèdent à une mémoire partagée.
Un bon exemple d'architecture en ''tile'' serait les déclinaisons de l'architecture Tilera. Les schémas du-dessous montrent l'architecture du processeur Tile 64. Outre les ''tiles'', qui sont les éléments de calcul de l'architecture, on trouve plusieurs contrôleurs mémoire DDR, divers interfaces réseau, des interfaces série et parallèles, et d'autres entrées-sorties.
[[File:Tile64.svg|centre|vignette|upright=2|Architecture Tile64 du Tilera.]]
==Les interruptions inter-processeurs==
Les '''interruptions inter-processeurs''' sont des interruptions déclenchées sur un processeur et exécutées sur un autre. Elles sont très utiles pour le système d'exploitation, pour des raisons qu'on ne peut pas expliquer ici. Disons simplement qu'elles permettent de répartir des programmes/''threads'' sur plusieurs processeurs. Un programme/''thread'' est démarré par une interruption, et le système d'exploitation détermine sur quel processeur elle doit être exécutée. L'utilité des interruptions inter-processeur est assez variée. Autrefois, elles servaient aussi pour la cohérence des caches, mais nous détaillerons cela dans un futur chapitre.
Une interruption inter-processeurs peut être envoyée soit à un cœur bien précis, soit à n'importe quel cœur, soit à tous les cœurs, voire même revenir à l'envoyeur. Tout dépend de ce que décide le système d'exploitation. Les trois situations ne sont pas identiques, sur un point : comment préciser quel est le processeur de destination ? Si on envoie une interruption à un cœur bien précis, il faut préciser quel est le cœur qui réceptionne l'interruption. Pour cela, il n'y a pas 36 solutions : on numérote les processeurs/cœurs avec un '''numéro de processeur'''. Ce numéro leur est soit attribué au démarrage par le BIOS, soit est gravé dans leur silicium pour les processeurs multicœurs.
Pour le reste, les interruptions inter-processeurs sont identiques aux interruptions normales. Elles ont un système de priorités, certaines devant passer avant les autres, là encore défini par des ''Interrupt Request Levels'' (IRQLs) ou quelque chose de similaire. Il peut y avoir des interruptions inter-processeur de type logicielles, à savoir lancées par une instruction machine. Par exemple, sur les IBM System/360 et les ''mainframes'' z/Architecture, le processeur avait une instruction SIGNAL PROCESSOR pour déclencher des interruptions logicielles inter-processeur.
Pour générer des interruptions inter-processeur, le contrôleur d'interruption doit pouvoir rediriger des interruptions déclenchées par un processeur vers un autre. Pour expliquer comment, nous allons étudier le cas des CPU x86, mais les implémentations ARM ou autres sont très similaires. L'ancien contrôleur d'interruption 8259A ne gérait pas les interruptions inter-processeurs, ce qui fait que les cartes mères multiprocesseurs devaient incorporer un contrôleur d'interruption spécial en complément. Par contre, son successeur, l'APIC, les gérait nativement.
De nos jours, chaque cœur x86 possède son propre contrôleur d’interruption, le '''''local APIC''''', qui gère les interruptions en provenance ou arrivant vers ce processeur. On trouve aussi un '''''IO-APIC''''', qui gère les interruptions en provenance des périphériques et de les redistribuer vers les APIC locaux. L'IO-APIC gère aussi les interruptions inter-processeurs en faisant passer les interruptions d'un local APIC vers un autre. Tous les APIC locaux et l'IO-APIC sont reliés ensembles par un '''bus APIC''' spécialisé, par lequel ils vont pouvoir communiquer et s'échanger des demandes d'interruptions.
[[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 déclenchement d'une interruption inter-processeur se fait en écrivant dans un registre appelé l''''''Interrupt Command Register'''''. Un détail important est que l'écriture se fait dans le ''local APIC'' de l'envoyer, du processeur qui veut envoyer une interruption, pas dans le registre du processeur qui doit réceptionner l'interruption ! Le registre est composé de deux registres de 32 bits, et mémorise : le numéro du processeur de destination, le mode de transfert (à tous, à un cœur, etc), le numéro du vecteur d'interruption (pour préciser quelle interruption exécuter), et quelques informations supplémentaires. À charge de l'IO-APIC de faire ce qu'il faut en fonction du contenu de ce registre.
==Le multiprocesseur/multicœur asymétrique==
Sur les processeurs grand public actuels, les cœurs d'un processeur multicœurs sont tous identiques. Mais ce n'est certainement pas une obligation. On peut très bien regrouper plusieurs cœurs très différents, par exemple un cœur principal avec des cœurs plus spécialisés autour. Il faut ainsi distinguer le '''multicœurs symétrique''', dans lequel on place des processeurs identiques sur la même puce de silicium, du '''multicœurs asymétrique''' où les cœurs ne sont pas identiques. Et il en est de même sur les systèmes avec plusieurs processeurs : on parle de '''multiprocesseur symétrique''' si les processeurs sont identiques, '''multiprocesseur asymétrique''' s'ils sont différents.
Précisons ce que nous entendons par "cœurs différents" ou "identiques". Les processeurs Intel modernes utilisent deux types de cœurs différents : des cœurs P et des cœurs E. Le P est pour ''Performance'', le E est pour "Efficiency". Les deux ont le même jeu d'instruction : ce sont des processeurs x86. Par contre, ils ont des microarchitectures différentes. Et Intel n'est pas le seul à utiliser cette technique : ARM a fait pareil avec ses CPU d'architecture ''Big-little''. Il n'est pas clair si de telles organisation sont du multicœur symétrique ou asymétrique. Le jeu d'instruction est identique, sauf éventuellement pour certaines extension comme l'AVX. Les deux coeurs n'ont pas les mêmes performances, mais est-ce suffisant ? La terminologie n'est pas claire.
Un exemple de multicoeurs asymétrique est celui du processeur CELL de la console de jeu PS3. Il était conçu spécifiquement pour cette console. Il intègre un cœur principal POWER PC v5 et 8 cœurs qui servent de processeurs auxiliaires. Le processeur principal est appelé le PPE et les processeurs auxiliaires sont les SPE. Les SPE sont reliés à une mémoire locale (''local store'') de 256 kibioctets qui communique avec le processeur principal via un bus spécial. Cette fois-ci, les coprocesseurs sont intégrés dans le même processeur.
Les SPE communiquent avec la RAM principale via des contrôleurs DMA. Les SPE possèdent des instructions permettant de commander leur contrôleur DMA et c'est le seul moyen qu'ils ont pour récupérer des informations depuis la mémoire. Et c'est au programmeur de gérer tout ça ! C'est le processeur principal qui va envoyer aux SPE les programmes qu'ils doivent exécuter. Il délègue des calculs aux SPE en écrivant dans le local store du SPE et en lui ordonnant l’exécution du programme qu'il vient d'écrire.
[[File:Schema Cell.png|centre|vignette|upright=2|Architecture du processeur CELL de la PS3. Le PPE est le processeur principal, les SPE sont des processeurs auxiliaires qui comprennent : un ''local store'' noté LS, un processeur noté SXU, et un contrôleur DMA pour échanger des informations avec la mémoire principale.]]
==Annexe : les architectures à cœurs conjoints==
Sur certains processeurs multicœurs, certains circuits sont partagés entre plusieurs cœurs. Typiquement, l'unité de calcul flottante est partagée entre deux coeurs/''threads'', les unités SIMD qu'on verra dans quelques chapitres sont aussi dans ce cas. Le partage de circuits permet d'éviter de dupliquer trop de circuits et donc d'économiser des transistors. Le problème est que ce partage est source de dépendances structurelles, ce qui peut entraîner des pertes de performances.
Cette technique consistant de partage d'unités de calcul entre coeurs s'appelle le '''cluster multithreading''', ou encore les '''architectures à cœurs conjoints''' (''Conjoined Core Architectures''). Elle est notamment utilisée sur les processeurs AMD de microarchitecture Bulldozer, incluant ses trois révisions ultérieures nommées Piledriver, Steamroller et Excavator. Un exemple est celui des processeurs AMD FX-8150 et FX-8120.
Sur ces processeurs, les instructions sont chargées dans deux files d'instructions séparées, une par ''thread'' matériel. Les instructions sont ensuite décodées par un décodeur unique et renommées dans une unité de renommage unique. Par la suite, il y a deux voies entières séparées et une voie flottante partagée. Chaque voie entière a sa propre fenêtre d'instruction entière, son tampon de ré-ordonnancement, ses unités de calcul dédiées, ses registres, sa ''load-store queue'', son cache L1. Par contre, la voie flottante partage les unités de calcul flottantes et n'a qu'une seule fenêtre d'instruction partagée par les deux ''threads''.
[[File:AMD Bulldozer microarchitecture.png|centre|vignette|upright=3|Microarchitecture Bulldozer d'AMD.]]
La révision Steamroller sépara le ''front-end'' en deux voies distinctes, une par ''thread''. Concrètement, elle ajouta un second décodeur d'instruction, une seconde file de micro-opération et une seconde unité de renommage de registres, afin d'améliorer les performances. Niveaux optimisations mineures, les stations de réservation ont été augmentées, elles peuvent mémoriser plus de micro-opérations, idem pour les bancs de registre et les files de lecture/écriture. Un cache de micro-opérations a été ajouté, de même que des optimisations quant au renommage de registre. Des ALU ont aussi été ajoutées, des FPU retirées.
[[File:AMD excavator microarchitecture.png|centre|vignette|upright=3|Microarchitecture Excavator d'AMD.]]
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les architectures parallèles
| prevText=Les architectures parallèles
| next=Architectures multithreadées et Hyperthreading
| nextText=Architectures multithreadées et Hyperthreading
}}
</noinclude>
7v21nyzwqk8n76lxu7ft5mjfu74gg7d
Fonctionnement d'un ordinateur/Le chemin de données
0
69025
763685
747835
2026-04-14T19:57:23Z
Mewtow
31375
/* L'unité de calcul d'adresse */
763685
wikitext
text/x-wiki
Comme vu précédemment, le '''chemin de donnée''' est l'ensemble des composants dans lesquels circulent les données dans le processeur. Il comprend l'unité de calcul, les registres, l'unité de communication avec la mémoire, et le ou les interconnexions qui permettent à tout ce petit monde de communiquer. Dans ce chapitre, nous allons voir ces composants en détail.
==Les unités de calcul==
Le processeur contient des circuits capables de faire des calculs arithmétiques, des opérations logiques, et des comparaisons, qui sont regroupés dans une unité de calcul appelée '''unité arithmétique et logique'''. Certains préfèrent l’appellation anglaise ''arithmetic and logic unit'', ou ALU. Par défaut, ce terme est réservé aux unités de calcul qui manipulent des nombres entiers. Les unités de calcul spécialisées pour les calculs flottants sont désignées par le terme "unité de calcul flottant", ou encore FPU (''Floating Point Unit'').
L'interface d'une unité de calcul est assez simple : on a des entrées pour les opérandes et une sortie pour le résultat du calcul. De plus, les instructions de comparaisons ou de calcul peuvent mettre à jour le registre d'état, qui est relié à une autre sortie de l’unité de calcul. Une autre entrée, l''''entrée de sélection de l'instruction''', spécifie l'opération à effectuer. Elle sert à configurer l'unité de calcul pour faire une addition et pas une multiplication, par exemple. Sur cette entrée, on envoie un numéro qui précise l'opération à effectuer. La correspondance entre ce numéro et l'opération à exécuter dépend de l'unité de calcul. Sur les processeurs où l'encodage des instructions est "simple", une partie de l'opcode de l'instruction est envoyé sur cette entrée.
[[File:Unité de calcul usuelle.png|centre|vignette|upright=2|Unité de calcul usuelle.]]
Il faut signaler que les processeurs modernes possèdent plusieurs unités de calcul, toutes reliées aux registres. Cela permet d’exécuter plusieurs calculs en même temps dans des unités de calcul différentes, afin d'augmenter les performances du processeur. Diverses technologies, abordées dans la suite du cours permettent de profiter au mieux de ces unités de calcul : pipeline, exécution dans le désordre, exécution superscalaire, jeux d'instructions VLIW, etc. Mais laissons cela de côté pour le moment.
===L'ALU entière : additions, soustractions, opérations bit à bit===
Un processeur contient plusieurs ALUs spécialisées. La principale, présente sur tous les processeurs, est l''''ALU entière'''. Elle s'occupe uniquement des opérations sur des nombres entiers, les nombres flottants sont gérés par une ALU à part. Elle gère des opérations simples : additions, soustractions, opérations bit à bit, parfois des décalages/rotations. Par contre, elle ne gère pas la multiplication et la division, qui sont prises en charge par un circuit multiplieur/diviseur à part.
L'ALU entière a déjà été vue dans un chapitre antérieur, nommé "Les unités arithmétiques et logiques entières (simples)", qui expliquait comment en concevoir une. Nous avions vu qu'une ALU entière est une sorte de circuit additionneur-soustracteur amélioré, ce qui explique qu'elle gère des opérations entières simples, mais pas la multiplication ni la division. Nous ne reviendrons pas dessus. Cependant, il y a des choses à dire sur leur intégration au processeur.
Une ALU entière gère souvent une opération particulière, qui ne fait rien et recopie simplement une de ses opérandes sur sa sortie. L'opération en question est appelée l''''opération ''Pass through''''', encore appelée opération NOP. Elle est implémentée en utilisant un simple multiplexeur, placé en sortie de l'ALU. Le fait qu'une ALU puisse effectuer une opération ''Pass through'' permet de fortement simplifier le chemin de donnée, d'économiser des multiplexeurs. Mais nous verrons cela sous peu.
[[File:ALU avec opération NOP.png|centre|vignette|upright=2|ALU avec opération NOP.]]
Avant l'invention du microprocesseur, le processeur n'était pas un circuit intégré unique. L'ALU, le séquenceur et les registres étaient dans des puces séparées. Les ALU étaient vendues séparément et manipulaient des opérandes de 4/8 bits, les ALU 4 bits étaient très fréquentes. Si on voulait créer une ALU pour des opérandes plus grandes, il fallait construire l'ALU en combinant plusieurs ALU 4/8 bits. Par exemple, l'ALU des processeurs AMD Am2900 est une ALU de 16 bits composée de plusieurs sous-ALU de 4 bits. Cette technique qui consiste à créer des unités de calcul à partir d'unités de calcul plus élémentaires s'appelle en jargon technique du '''bit slicing'''. Nous en avions parlé dans le chapitre sur les unités de calcul, aussi nous n'en reparlerons pas plus ici.
L'ALU manipule des opérandes codées sur un certain nombre de bits. Par exemple, une ALU peut manipuler des entiers codés sur 8 bits, sur 16 bits, etc. En général, la taille des opérandes de l'ALU est la même que la taille des registres. Un processeur 32 bits, avec des registres de 32 bit, a une ALU de 32 bits. C'est intuitif, et cela rend l'implémentation du processeur bien plus facile. Mais il y a quelques exceptions, où l'ALU manipule des opérandes plus petits que la taille des registres. Par exemple, de nombreux processeurs 16 bits, avec des registres de 16 bits, utilisent une ALU de 8 bits. Un autre exemple assez connu est celui du Motorola 68000, qui était un processeur 32 bits, mais dont l'ALU faisait juste 16 bits. Son successeur, le 68020, avait lui une ALU de 32 bits.
Sur de tels processeurs, les calculs sont fait en plusieurs passes. Par exemple, avec une ALU 8 bit, les opérations sur des opérandes 8 bits se font en un cycle d'horloge, celles sur 16 bits se font en deux cycles, celles en 32 en quatre, etc. Si un programme manipule assez peu d'opérandes 16/32/64 bits, la perte de performance est assez faible. Diverses techniques visent à améliorer les performances, mais elles ne font pas de miracles. Par exemple, vu que l'ALU est plus courte, il est possible de la faire fonctionner à plus haute fréquence, pour réduire la perte de performance.
Pour comprendre comme est implémenté ce système de passes, prenons l'exemple du processeur 8 bit Z80. Ses registres entiers étaient des registres de 8 bits, alors que l'ALU était de 4 bits. Les calculs étaient faits en deux phases : une qui traite les 4 bits de poids faible, une autre qui traite les 4 bits de poids fort. Pour cela, les opérandes étaient placées dans des registres de 4 bits en entrée de l'ALU, plusieurs multiplexeurs sélectionnaient les 4 bits adéquats, le résultat était mémorisé dans un registre de résultat de 8 bits, un démultiplexeur plaçait les 4 bits du résultat au bon endroit dans ce registre. L'unité de contrôle s'occupait de la commande des multiplexeurs/démultiplexeurs. Les autres processeurs 8 ou 16 bits utilisent des circuits similaires pour faire leurs calculs en plusieurs fois.
[[File:ALU du Z80.png|centre|vignette|upright=2|ALU du Z80]]
Un exemple extrême est celui des des '''processeurs sériels''' (sous-entendu ''bit-sériels''), qui utilisent une '''ALU sérielle''', qui fait leurs calculs bit par bit, un bit à la fois. S'il a existé des processeurs de 1 bit, comme le Motorola MC14500B, la majeure partie des processeurs sériels étaient des processeurs 4, 8 ou 16 bits. L'avantage de ces ALU est qu'elles utilisent peu de transistors, au détriment des performances par rapport aux processeurs non-sériels. Mais un autre avantage est qu'elles peuvent gérer des opérandes de grande taille, avec plus d'une trentaine de bits, sans trop de problèmes.
===Les circuits multiplieurs et diviseurs===
Les processeurs modernes ont une ALU pour les opérations simples (additions, décalages, opérations logiques), couplée à une ALU pour les multiplications, un circuit multiplieur séparé. Précisons qu'il ne sert pas à grand chose de fusionner le circuit multiplieur avec l'ALU, mieux vaut les garder séparés par simplicité. Les processeurs haute performance disposent systématiquement d'un circuit multiplieur et gèrent la multiplication dans leur jeu d'instruction.
Le cas de la division est plus compliqué. La présence d'un circuit multiplieur est commune, mais les circuits diviseurs sont eux très rares. Leur cout en circuit est globalement le même que pour un circuit multiplieur, mais le gain en performance est plus faible. Le gain en performance pour la multiplication est modéré car il s'agit d'une opération très fréquente, alors qu'il est très faible pour la division car celle-ci est beaucoup moins fréquente.
Pour réduire le cout en circuits, il arrive que l'ALU pour les multiplications gère à la fois la multiplication et la division. Les circuits multiplieurs et diviseurs sont en effet très similaires et partagent beaucoup de points communs. Généralement, la fusion se fait pour les multiplieurs/diviseurs itératifs.
===Le ''barrel shifter''===
On vient d'expliquer que la présence de plusieurs ALU spécialisée est très utile pour implémenter des opérations compliquées à insérer dans une unité de calcul normale, comme la multiplication et la division. Mais les décalages sont aussi dans ce cas, de même que les rotations. Nous avions vu il y a quelques chapitres qu'ils sont réalisés par un circuit spécialisé, appelé un ''barrel shifter'', qu'il est difficile de fusionner avec une ALU normale. Aussi, beaucoup de processeurs incorporent un ''barrel shifter'' séparé de l'ALU.
Les processeurs ARM utilise un ''barrel shifter'', mais d'une manière un peu spéciale. On a vu il y a quelques chapitres que si on fait une opération logique, une addition, une soustraction ou une comparaison, la seconde opérande peut être décalée automatiquement. L'instruction incorpore le type de de décalage à faire et par combien de rangs il faut décaler directement à côté de l'opcode. Cela simplifie grandement les calculs d'adresse, qui se font en une seule instruction, contre deux ou trois sur d'autres architectures. Et pour cela, l'ALU proprement dite est précédée par un ''barrel shifter'',une seconde ALU spécialisée dans les décalages. Notons que les instructions MOV font aussi partie des instructions où la seconde opérande (le registre source) peut être décalé : cela signifie que les MOV passent par l'ALU, qui effectue alors un NOP, une opération logique OUI.
===Les unités de calcul spécialisées===
Un processeur peut disposer d’unités de calcul séparées de l'unité de calcul principale, spécialisées dans les décalages, les divisions, etc. Et certaines d'entre elles sont spécialisées dans des opérations spécifiques, qui ne sont techniquement pas des opérations entières, sur des nombres entiers.
[[File:Unité de calcul flottante, intérieur.png|vignette|upright=1|Unité de calcul flottante, intérieur]]
Depuis les années 90-2000, presque tous les processeurs utilisent une unité de calcul spécialisée pour les nombres flottants : la '''Floating-Point Unit''', aussi appelée FPU. En général, elle regroupe un additionneur-soustracteur flottant et un multiplieur flottant. Parfois, elle incorpore un diviseur flottant, tout dépend du processeur. Précisons que sur certains processeurs, la FPU et l'ALU entière ne vont pas à la même fréquence, pour des raisons de performance et de consommation d'énergie !
La FPU intègre un circuit multiplieur entier, utilisé pour les multiplications flottantes, afin de multiplier les mantisses entre elles. Quelques processeurs utilisaient ce multiplieur pour faire les multiplications entières. En clair, au lieu d'avoir un multiplieur entier séparé du multiplieur flottant, les deux sont fusionnés en un seul circuit. Il s'agit d'une optimisation qui a été utilisée sur quelques processeurs 32 bits, qui supportaient les flottants 64 bits (double précision). Les processeurs Atom étaient dans ce cas, idem pour l'Athlon première génération. Les processeurs modernes n'utilisent pas cette optimisation pour des raisons qu'on ne peut pas expliquer ici (réduction des dépendances structurelles, émission multiple).
Il existe des unités de calcul spécialisées pour les calculs d'adresse. Elles ne supportent guère plus que des incrémentations/décrémentations, des additions/soustractions, et des décalages simples. Les autres opérations n'ont pas de sens avec des adresses. L'usage d'ALU spécialisées pour les adresses est un avantage sur les processeurs où les adresses ont une taille différente des données, ce qui est fréquent sur les anciens processeurs 8 bits.
De nombreux processeurs modernes disposent d'une unité de calcul spécialisée dans le calcul des conditions, tests et branchements. C’est notamment le cas sur les processeurs sans registre d'état, qui disposent de registres à prédicats. En général, les registres à prédicats sont placés à part des autres registres, dans un banc de registre séparé. L'unité de calcul normale n'est pas reliée aux registres à prédicats, alors que l'unité de calcul pour les branchements/test/conditions l'est. Les registres à prédicats sont situés juste en sortie de cette unité de calcul.
==Les registres du processeur==
Après avoir vu l'unité de calcul, il est temps de passer aux registres d'un processeur. L'organisation des registres est généralement assez compliquée, avec quelques registres séparés des autres comme le registre d'état ou le ''program counter''. Les registres d'un processeur peuvent se classer en deux camps : soit ce sont des registres isolés, soit ils sont regroupés en paquets appelés banc de registres.
Un '''banc de registres''' (''register file'') est une RAM, dont chaque byte est un registre. Il regroupe un paquet de registres différents dans un seul composant, dans une seule mémoire. Dans processeur moderne, on trouve un ou plusieurs bancs de registres. La répartition des registres, à savoir quels registres sont dans le banc de registre et quels sont ceux isolés, est très variable suivant les processeurs.
[[File:Register File Simple.svg|centre|vignette|upright=1|Banc de registres simplifié.]]
===L'adressage du banc de registres===
Le banc de registre est une mémoire comme une autre, avec une entrée d'adresse qui permet de sélectionner le registre voulu. Plutot que d'adresse, nous allons parler d''''identifiant de registre'''. Le séquenceur forge l'identifiant de registre en fonction des registres sélectionnés. Dans les chapitres précédents, nous avions vu qu'il existe plusieurs méthodes pour sélectionner un registre, qui portent les noms de modes d'adressage. Et bien les modes d'adressage jouent un grand rôle dans la forge de l'identifiant de registre.
Pour rappel, sur la quasi-totalité des processeurs actuels, les registres généraux sont identifiés par un nom de registre, terme trompeur vu que ce nom est en réalité un numéro. En clair, les processeurs numérotent les registres, le numéro/nom du registre permettant de l'identifier. Par exemple, si je veux faire une addition, je dois préciser les deux registres pour les opérandes, et éventuellement le registre pour le résultat : et bien ces registres seront identifiés par un numéro. Mais tous les registres ne sont pas numérotés et ceux qui ne le sont pas sont adressés implicitement. Par exemple, le pointeur de pile sera modifié par les instructions qui manipulent la pile, sans que cela aie besoin d'être précisé par un nom de registre dans l'instruction.
Dans le cas le plus simple, les registres nommés vont dans le banc de registres, les registres adressés implicitement sont en-dehors, dans des registres isolés. L'idéntifiant de registre est alors simplement le nom de registre, le numéro. Le séquenceur extrait ce nom de registre de l'insutrction, avant de l'envoyer sur l'entrée d'adresse du banc de registre.
[[File:Adressage du banc de registres généruax.png|centre|vignette|upright=2|Adressage du banc de registres généraux]]
Dans un cas plus complexe, des registres non-nommés sont placés dans le banc de registres. Par exemple, les pointeurs de pile sont souvent placés dans le banc de registre, même s'ils sont adressés implicitement. Même des registres aussi importants que le ''program counter'' peuvent se mettre dans le banc de registre ! Nous verrons le cas du ''program counter'' dans le chapitre suivant, qui porte sur l'unité de chargement. Dans ce cas, le séquenceur forge l'identifiant de registre de lui-même. Dans le cas des registres nommés, il ajoute quelques bits aux noms de registres. Pour les registres adressés implicitement, il forge l'identifiant à partir de rien.
[[File:Adressage du banc de registre - cas général.png|centre|vignette|upright=2|Adressage du banc de registre - cas général]]
Nous verrons plus bas que dans certains cas, le nom de registre ne suffit pas à adresser un registre dans un banc de registre. Dans ce cas, le séquenceur rajoute des bits, comme dans l'exemple précédent. Tout ce qu'il faut retenir est que l'identifiant de registre est forgé par le séquenceur, qui se base entre autres sur le nom de registre s'il est présent, sur l'instruction exécutée dans le cas d'un registre adressé implicitement.
===Les registres généraux===
Pour rappel, les registres généraux peuvent mémoriser des entiers, des adresses, ou toute autre donnée codée en binaire. Ils sont souvent séparés des registres flottants sur les architectures modernes. Les registres généraux sont rassemblés dans un banc de registre dédié, appelé le '''banc de registres généraux'''. Le banc de registres généraux est une mémoire multiport, avec au moins un port d'écriture et deux ports de lecture. La raison est que les instructions lisent deux opérandes dans les registres et enregistrent leur résultat dans des registres. Le tout se marie bien avec un banc de registre à deux de lecture (pour les opérandes) et un d'écriture (pour le résultat).
[[File:Banc de registre multiports.png|centre|vignette|upright=2|Banc de registre multiports.]]
L'interface exacte dépend de si l'architecture est une architecture 2 ou 3 adresses. Pour rappel, la différence entre les deux tient dans la manière dont on précise le registre où enregistrer le résultat d'une opération. Avec les architectures 2-adresses, on précise deux registres : le premier sert à la fois comme opérande et pour mémoriser le résultat, l'autre sert uniquement d'opérande. Un des registres est donc écrasé pour enregistrer le résultat. Sur les architecture 3-adresses, on précise trois registres : deux pour les opérandes, un pour le résultat.
Les architectures 2-adresses ont un banc de registre où on doit préciser deux "adresses", deux noms de registre. L'interface du banc de registre est donc la suivante :
[[File:Register File Medium.svg|centre|vignette|upright=1.5|Register File d'une architecture à 2-adresses]]
Les architectures 3-adresses doivent rajouter une troisième entrée pour préciser un troisième nom de registre. L'interface du banc de registre est donc la suivante :
[[File:Register File Large.svg|centre|vignette|upright=1.5|Register File d'une architecture à 3-adresses]]
Rien n'empêche d'utiliser plusieurs bancs de registres sur un processeur qui utilise des registres généraux. La raison est une question d'optimisation. Au-delà d'un certain nombre de registres, il devient difficile d'utiliser un seul gros banc de registres. Il faut alors scinder le banc de registres en plusieurs bancs de registres séparés. Le problème est qu'il faut prévoir de quoi échanger des données entre les bancs de registres. Dans la plupart des cas, cette séparation est invisible du point de vue du langage machine. Sur d'autres processeurs, les transferts de données entre bancs de registres se font via une instruction spéciale, souvent appelée COPY.
===Les registres flottants : banc de registre séparé ou unifié===
Passons maintenant aux registres flottants. Intuitivement, on a des registres séparés pour les entiers et les flottants. Il est alors plus simple d'utiliser un banc de registres séparé pour les nombres flottants, à côté d'un banc de registre entiers. L'avantage est que les nombres flottants et entiers n'ont pas forcément la même taille, ce qui se marie bien avec deux bancs de registres, où la taille des registres est différente dans les deux bancs.
Mais d'autres processeurs utilisent un seul '''banc de registres unifié''', qui regroupe tous les registres de données, qu'ils soient entier ou flottants. Par exemple, c'est le cas des Pentium Pro, Pentium II, Pentium III, ou des Pentium M : ces processeurs ont des registres séparés pour les flottants et les entiers, mais ils sont regroupés dans un seul banc de registres. Avec cette organisation, un registre flottant et un registre entier peuvent avoir le même nom de registre en langage machine, mais l'adresse envoyée au banc de registres ne doit pas être la même : le séquenceur ajoute des bits au nom de registre pour former l'adresse finale.
[[File:Désambiguïsation de registres sur un banc de registres unifié.png|centre|vignette|upright=2|Désambiguïsation de registres sur un banc de registres unifié.]]
===Le registre d'état===
Le registre d'état fait souvent bande à part et n'est pas placé dans un banc de registres. En effet, le registre d'état est très lié à l'unité de calcul. Il reçoit des indicateurs/''flags'' provenant de la sortie de l'unité de calcul, et met ceux-ci à disposition du reste du processeur. Son entrée est connectée à l'unité de calcul, sa sortie est reliée au séquenceur et/ou au bus interne au processeur.
Le registre d'état est relié au séquenceur afin que celui-ci puisse gérer les instructions de branchement, qui ont parfois besoin de connaitre certains bits du registre d'état pour savoir si une condition a été remplie ou non. D'autres processeurs relient aussi le registre d'état au bus interne, ce qui permet de lire son contenu et de le copier dans un registre de données. Cela permet d'implémenter certaines instructions, notamment celles qui permettent de mémoriser le registre d'état dans un registre général.
[[File:Place du registre d'état dans le chemin de données.png|centre|vignette|upright=2|Place du registre d'état dans le chemin de données]]
L'ALU fournit une sortie différente pour chaque bit du registre d'état, la connexion du registre d'état est directe, comme indiqué dans le schéma suivant. Vous remarquerez que le bit de retenue est à la fois connecté à la sortie de l'ALU, mais aussi sur son entrée. Ainsi, le bit de retenue calculé par une opération peut être utilisé pour la suivante. Sans cela, diverses instructions comme les opérations ''add with carry'' ne seraient pas possibles.
[[File:AluStatusRegister.svg|centre|vignette|upright=2|Registre d'état et unit de calcul.]]
Il est techniquement possible de mettre le registre d'état dans le banc de registre, pour économiser un registre. La principale difficulté est que les instructions doivent faire deux écritures dans le banc de registre : une pour le registre de destination, une pour le registre d'état. Soit on utilise deux ports d'écriture, soit on fait les deux écritures l'une après l'autre. Dans les deux cas, le cout en performances et en transistors n'en vaut pas le cout. D'ailleurs, je ne connais aucun processeur qui utilise cette technique.
Il faut noter que le registre d'état n'existe pas forcément en tant que tel dans le processeur. Quelques processeurs, dont le 8086 d'Intel, utilisent des bascules dispersées dans le processeur au lieu d'un vrai registre d'état. Les bascules dispersées mémorisent chacune un bit du registre d'état et sont placées là où elles sont le plus utile. Les bascules utilisées pour les branchements sont proches du séquenceur, le bascules pour les bits de retenue sont placées proche de l'ALU, etc.
===Les registres à prédicats===
Les registres à prédicats remplacent le registre d'état sur certains processeurs. Pour rappel, les registres à prédicat sont des registres de 1 bit qui mémorisent les résultats des comparaisons et instructions de test. Ils sont nommés/numérotés, mais les numéros en question sont distincts de ceux utilisés pour les registres généraux.
Ils sont placés à part, dans un banc de registres séparé. Le banc de registres à prédicats a une entrée de 1 bit connectée à l'ALU et une sortie de un bit connectée au séquenceur. Le banc de registres à prédicats est parfois relié à une unité de calcul spécialisée dans les conditions/instructions de test. Pour rappel, certaines instructions permettent de faire un ET, un OU, un XOR entre deux registres à prédicats. Pour cela, l'unité de calcul dédiée aux conditions peut lire les registres à prédicats, pour combiner le contenu de plusieurs d'entre eux.
[[File:Banc de registre pour les registres à prédicats.png|centre|vignette|upright=2|Banc de registre pour les registres à prédicats]]
===Les registres dédiés aux interruptions===
Dans le chapitre sur les registres, nous avions vu que certains processeurs dupliquaient leurs registres architecturaux, pour accélérer les interruptions ou les appels de fonction. Dans le cas qui va nous intéresser, les interruptions avaient accès à leurs propres registres, séparés des registres architecturaux. Les processeurs de ce type ont deux ensembles de registres identiques : un dédié aux interruptions, un autre pour les programmes normaux. Les registres dans les deux ensembles ont les mêmes noms, mais le processeur choisit le bon ensemble suivant s'il est dans une interruption ou non. Si on peut utiliser deux bancs de registres séparés, il est aussi possible d'utiliser un banc de registre unifié pour les deux.
Sur certains processeurs, le banc de registre est dupliqué en plusieurs exemplaires. La technique est utilisée pour les interruptions. Certains processeurs ont deux ensembles de registres identiques : un dédié aux interruptions, un autre pour les programmes normaux. Les registres dans les deux ensembles ont les mêmes noms, mais le processeur choisit le bon ensemble suivant s'il est dans une interruption ou non. On peut utiliser deux bancs de registres séparés, un pour les interruptions, et un pour les programmes.
Sur d'autres processeurs, on utilise un banc de registre unifié pour les deux ensembles de registres. Les registres pour les interruptions sont dans les adresses hautes, les registres pour les programmes dans les adresses basses. Le choix entre les deux est réalisé par un bit qui indique si on est dans une interruption ou non, disponible dans une bascule du processeur. Appelons là la bascule I.
===Le fenêtrage de registres===
[[File:Fenetre de registres.png|vignette|upright=1|Fenêtre de registres.]]
Le '''fenêtrage de registres''' fait que chaque fonction a accès à son propre ensemble de registres, sa propre fenêtre de registres. Là encore, cette technique duplique chaque registre architectural en plusieurs exemplaires qui portent le même nom. Chaque ensemble de registres architecturaux forme une fenêtre de registre, qui contient autant de registres qu'il y a de registres architecturaux. Lorsqu'une fonction s’exécute, elle se réserve une fenêtre inutilisée, et peut utiliser les registres de la fenêtre comme bon lui semble : une fonction manipule le registre architectural de la fenêtre réservée, mais pas les registres avec le même nom dans les autres fenêtres.
Il peut s'implémenter soit avec un banc de registres unifié, soit avec un banc de registre par fenêtre de registres.
Il est possible d'utiliser des bancs de registres dupliqués pour le fenêtrage de registres. Chaque fenêtre de registre a son propre banc de registres. Le choix entre le banc de registre à utiliser est fait par un registre qui mémorise le numéro de la fenêtre en cours. Ce registre commande un multiplexeur qui permet de choisir le banc de registre adéquat.
[[File:Fenêtrage de registres au niveau du banc de registres.png|vignette|Fenêtrage de registres au niveau du banc de registres.]]
L'utilisation d'un banc de registres unifié permet d'implémenter facilement le fenêtrage de registres. Il suffit pour cela de regrouper tous les registres des différentes fenêtres dans un seul banc de registres. Il suffit de faire comme vu au-dessus : rajouter des bits au nom de registre pour faire la différence entre les fenêtres. Cela implique de se souvenir dans quelle fenêtre de registre on est actuellement, cette information étant mémorisée dans un registre qui stocke le numéro de la fenêtre courante. Pour changer de fenêtre, il suffit de modifier le contenu de ce registre lors d'un appel ou retour de fonction avec un petit circuit combinatoire. Bien sûr, il faut aussi prendre en compte le cas où ce registre déborde, ce qui demande d'ajouter des circuits pour gérer la situation.
[[File:Désambiguïsation des fenêtres de registres.png|centre|vignette|upright=2|Désambiguïsation des fenêtres de registres.]]
==L'interface de communication avec la mémoire==
L''''interface avec la mémoire''' est, comme son nom l'indique, des circuits qui servent d'intermédiaire entre le bus mémoire et le processeur. Elle est parfois appelée l'unité mémoire, l'unité d'accès mémoire, la ''load-store unit'', et j'en oublie. Nous utiliserons le terme d''''unité mémoire''', au même titre qu'on utilise le terme d'unité de calcul.
[[File:Unité de communication avec la mémoire, de type simple port.png|centre|vignette|upright=2|Unité de communication avec la mémoire, de type simple port.]]
Sur certains processeurs, elle gère les mémoires multiport.
[[File:Unité de communication avec la mémoire, de type multiport.png|centre|vignette|upright=2|Unité de communication avec la mémoire, de type multiport.]]
===Les registres d'interfaçage mémoire===
L'interface mémoire se résume le plus souvent à des '''registres d’interfaçage mémoire''', intercalés entre le bus mémoire et le chemin de données. Généralement, il y a au moins deux registres d’interfaçage mémoire : un registre relié au bus d'adresse, et autre relié au bus de données.
[[File:Registres d’interfaçage mémoire.png|centre|vignette|upright=2|Registres d’interfaçage mémoire.]]
Au lieu de lire ou écrire directement sur le bus, le processeur lit ou écrit dans ces registres, alors que l'unité mémoire s'occupe des échanges entre registres et bus mémoire. Lors d'une écriture, le processeur place l'adresse dans le registre d'interfaçage d'adresse, met la donnée à écrire dans le registre d'interfaçage de donnée, puis laisse l'unité d'accès mémoire faire son travail. Lors d'une lecture, il place l'adresse à lire sur le registre d'interfaçage d'adresse, il attend que la donnée soit lue, puis récupère la donnée dans le registre d'interfaçage de données.
L'avantage est que le processeur n'a pas à maintenir une donnée/adresse sur le bus durant tout un accès mémoire. Par exemple, prenons le cas où la mémoire met 15 cycles processeurs pour faire une lecture ou une écriture. Sans registres d'interfaçage mémoire, le processeur doit maintenir l'adresse durant 15 cycles, et aussi la donnée dans le cas d'une écriture. Avec ces registres, le processeur écrit dans les registres d'interfaçage mémoire au premier cycle, et passe les 14 cycles suivants à faire quelque chose d'autre. Par exemple, il faut faire un calcul en parallèle, envoyer des signaux de commande au banc de registre pour qu'il soit prêt une fois la donnée lue arrivée, etc. Cet avantage simplifie l'implémentation de certains modes d'adressage, comme on le verra à la fin du chapitre.
===L'unité de calcul d'adresse===
Les registres d'interfaçage sont presque toujours présents, mais le circuit que nous allons voir est complétement facultatif. Il s'agit d'une unité de calcul spécialisée dans les calculs d'adresse, dont nous avons parlé rapidement dans la section sur les ALU. Elle s'appelle l''''''Address generation unit''''', ou AGU. Elle est parfois séparée de l'interface mémoire proprement dit, et est alors considérée comme une unité de calcul à part, mais elle est généralement intimement liée à l'interface mémoire.
Elle sert pour certains modes d'adressage, qui demandent de combiner une adresse avec soit un indice, soit un décalage, plus rarement les deux. Les calculs d'adresse demandent de simplement incrémenter/décrémenter une adresse, de lui ajouter un indice (et de décaler les indices dans certains cas), mais guère plus. Pas besoin d'effectuer de multiplications, de divisions, ou d'autre opération plus complexe. Des décalages et des additions/soustractions suffisent. L'AGU est donc beaucoup plus simple qu'une ALU normale et se résume souvent à un vulgaire additionneur-soustracteur, éventuellement couplée à un décaleur pour multiplier les indices.
[[File:Unité d'accès mémoire avec unité de calcul dédiée.png|centre|vignette|upright=1.5|Unité d'accès mémoire avec unité de calcul dédiée]]
Le fait d'avoir une unité de calcul séparée pour les adresses peut s'expliquer pour plusieurs raisons. Sur les rares processeurs qui ont des registres séparés pour les adresses, un banc de registre dédié est réservé aux registres d'adresses, ce qui rend l'usage d'une unité de calcul d'adresse très pratique et simplifie grandement le câblage du processeur. P pas besoin de relier deux bancs de registres à une seule ALU, elle-même reliée à la fois au bus d'adresse et de données, chaque banc de registre est relié à sa propre ALU, elle-même reliée à un seul bus.
[[File:Unité d'accès mémoire avec registres d'adresse ou d'indice.png|centre|vignette|upright=2|Unité d'accès mémoire avec registres d'adresse ou d'indice]]
Sur les processeurs à registres généraux, la raison est que cela simplifie un peu l'implémentation des modes d'adressage indirects. C'est particulièrement utile pour les modes d'adressage du style "base + indice + décalage", qui additionnent trois opérandes. Au lieu d'utiliser deux additions séparées, on peut utiliser un simple additionneur trois-opérandes séparé, de type ''carry save'', pour un cout en hardware modéré.
Une autre raison se manifestait sur les processeurs 8 bits : ils géraient des données de 8 bits, mais des adresses de 16 bits. Dans ce cas, le processeur avait une ALU simple de 16 bits pour les adresses, et une ALU complexe de 8 bits pour les données.
===La gestion de l'alignement et du boutisme===
L'unité mémoire gère les accès mémoire non-alignés, à cheval sur deux mots mémoire (rappelez-vous le chapitre sur l'alignement mémoire). Elle détecte les accès mémoire non-alignés et réagit en conséquence. Dans le cas où les accès non-alignés sont interdits, elle lève une exception matérielle. Dans le cas où ils sont autorisés, elle les gère automatiquement, à savoir qu'elle charge deux mots mémoire et les combine entre eux pour donner le résultat final. Dans les deux cas, cela demande d'ajouter des circuits de détection des accès non-alignés, et éventuellement des circuits pour le double lecture/écriture.
Les circuits de détection des accès non-alignés sont très simples. Dans le cas où les adresses sont alignées sur une puissance de deux (cas le plus courant), il suffit de vérifier les bits de poids faible de l'adresse à lire. Prenons l'exemple d'un processeur avec des adresses codées sur 64 bits, avec des mots mémoire de 32 bits, alignés sur 32 bits (4 octets). Un mot mémoire contient 4 octets, les contraintes d'alignement font que les adresses autorisées sont des multiples de 4. En conséquence, les 2 bits de poids faible d'une adresse valide sont censés être à 0. En vérifiant la valeur de ces deux bits, on détecte facilement les accès non-alignés.
En clair, détecter les accès non-alignés demande de tester si les bits de poids faibles adéquats sont à 0. Il suffit donc d'un circuit de comparaison avec zéro; qui est une simple porte OU. Cette porte OU génère un bit qui indique si l'accès testé est aligné ou non : 1 si l'accès est non-aligné, 0 sinon. Le signal peut être transmis au séquenceur pour générer une exception matérielle, ou utilisé dans l'unité d'accès mémoire pour la double lecture/écriture.
La gestion automatique des accès non-alignés est plus complexe. Dans ce cas, l'unité mémoire charge deux mots mémoire et les combine entre eux pour donner le résultat final. Charger deux mots mémoires consécutifs est assez simple, si le registre d'interfaçage est un compteur. L'accès initial charge le premier mot mémoire, puis l'adresse stockée dans le registre d'interfaçage est incrémentée pour démarrer un second accès. Le circuit pour combiner deux mots mémoire contient des registres, des circuits de décalage, des multiplexeurs.
===Le rafraichissement mémoire optimisé et le contrôleur mémoire intégré===
Depuis les années 80, les processeurs sont souvent combinés avec une mémoire principale de type DRAM. De telles mémoires doivent être rafraichies régulièrement pour ne pas perdre de données. Le rafraichissement se fait généralement adresse par adresse, ou ligne par ligne (les lignes sont des super-bytes internes à la DRAM). Le rafraichissement est en théorie géré par le contrôleur mémoire installé sur la carte mère. Mais au tout début de l'informatique, du temps des processeurs 8 bits, le rafraichissement mémoire était géré directement par le processeur.
Si quelques processeurs géraient le rafraichissement mémoire avec des interruptions, d'autres processeurs disposaient d’optimisations pour optimiser le rafraichissement mémoire. Divers processeurs implémentaient de quoi faciliter le rafraichissement par adresse. Par exemple, le processeur Zilog Z80 contenait un compteur de ligne, un registre qui contenait le numéro de la prochaine ligne à rafraichir. Il était incrémenté à chaque rafraichissement mémoire, automatiquement, par le processeur lui-même. Un ''timer'' interne permettait de savoir quand rafraichir la mémoire : quand ce ''timer'' atteignait 0, une commande de rafraichissement était envoyée à la mémoire, et le ''timer'' était ''reset''. Et tout cela était intégré à l'unité d'accès mémoire.
Depuis les années 2000, les processeurs modernes ont un contrôleur mémoire DRAM intégré directement dans le processeur. Ce qui fait qu'ils gèrent non seulement le rafraichissement, mais aussi d'autres fonctions bien pus complexes.
===L'interface de l'unité mémoire===
Vu de l'extérieur, l'unité mémoire ressemble à n'importe quel circuit électronique, avec des entrées et des sorties. L'unité mémoire est généralement multiport, avec un port d'entrée et un port de sortie. Le port d'entrée est là où on envoie l'adresse à lire/écrire, ainsi que la donnée à écrire pour les écritures. Si l'unité mémoire incorpore une AGU, on envoie aussi les indices et autres données sur le port d'entrée. Le port de sortie est utilisé pour récupérer le résultat des lectures. Une unité mémoire est donc reliée au chemin de données via deux entrées et une sortie : une entrée d'adresse, une entrée de données, et une sortie pour les données lues.
L'unité mémoire est connectée au reste du processeur grâce à un réseau d'interconnexion qu'on étudiera plus loin. Les connexions principales sont celles avec les registres : les adresses à lire/écrire sont souvent lues depuis les registres, les lectures copient une donnée dans un registre. Il peut y avoir une connexion avec l'unité de calcul pour les opérations ''load-up'', ou pour le calcul d'adresse, mais c'est secondaire.
[[File:Unité d'accès mémoire LOAD-STORE.png|centre|vignette|upright=2|Unité d'accès mémoire LOAD-STORE.]]
==Le chemin de données et son réseau d'interconnexions==
Nous venons de voir que le chemin de données contient une unité de calcul (parfois plusieurs), des registres isolés, un banc de registre, une unité mémoire. Le tout est chapeauté par une unité de contrôle qui commande le chemin de données, qui fera l'objet des prochains chapitres. Mais il faut maintenant relier registres, ALU et unité mémoire pour que l'ensemble fonctionne. Pour cela, diverses interconnexions internes au processeur se chargent de relier le tout.
Sur les anciens processeurs, les interconnexions sont assez simples et se résument à un ou deux '''bus internes au processeur''', reliés au bus mémoire. C'était la norme sur des architectures assez ancienne, qu'on n'a pas encore vu à ce point du cours, appelées les architectures à accumulateur et à pile. Mais ce n'est plus la solution utilisée actuellement. De nos jours, le réseaux d'interconnexion intra-processeur est un ensemble de connexions point à point entre ALU/registres/unité mémoire. Et paradoxalement, cela rend plus facile de comprendre ce réseau d'interconnexion.
===Introduction propédeutique : l'implémentation des modes d'adressage principaux===
L'organisation interne du processeur dépend fortement des modes d'adressage supportés. Pour simplifier les explications, nous allons séparer les modes d'adressage qui gèrent les pointeurs et les autres. Suivant que le processeur supporte les pointeurs ou non, l'organisation des bus interne est légèrement différente. La différence se voit sur les connexions avec le bus d'adresse et de données.
Tout processeur gère au minimum le '''mode d'adressage absolu''', où l'adresse est intégrée à l'instruction. Le séquenceur extrait l'adresse mémoire de l'instruction, et l'envoie sur le bus d'adresse. Pour cela, le séquenceur est relié au bus d'adresse, le chemin de donnée est relié au bus de données. Le chemin de donnée n'est pas connecté au bus d'adresse, il n'y a pas d'autres connexions.
[[File:Chemin de données sans support des pointeurs.png|centre|vignette|upright=2|Chemin de données sans support des pointeurs]]
Le '''support des pointeurs''' demande d'intégrer des modes d'adressage dédiés : l'adressage indirect à registre, l'adresse base + indice, et les autres. Les pointeurs sont stockés dans le banc de registre et sont modifiés par l'unité de calcul. Pour supporter les pointeurs, le chemin de données est connecté sur le bus d'adresse avec le séquenceur. Suivant le mode d'adressage, le bus d'adresse est relié soit au chemin de données, soit au séquenceur.
[[File:Chemin de données avec support des pointeurs.png|centre|vignette|upright=2|Chemin de données avec support des pointeurs]]
Pour terminer, il faut parler des instructions de '''copie mémoire vers mémoire''', qui copient une donnée d'une adresse mémoire vers une autre. Elles ne se passent pas vraiment dans le chemin de données, mais se passent purement au niveau des registres d’interfaçage. L'usage d'un registre d’interfaçage unique permet d'implémenter ces instructions très facilement. Elle se fait en deux étapes : on copie la donnée dans le registre d’interfaçage, on l'écrit en mémoire RAM. L'adresse envoyée sur le bus d'adresse n'est pas la même lors des deux étapes.
===Le banc de registre est multi-port, pour gérer nativement les opérations dyadiques===
Les architectures RISC et CISC incorporent un banc de registre, qui est connecté aux unités de calcul et au bus mémoire. Et ce banc de registre peut être mono-port ou multiport. S'il a existé d'anciennes architectures utilisant un banc de registre mono-port, elles sont actuellement obsolètes. Nous les aborderons dans un chapitre dédié aux architectures dites canoniques, mais nous pouvons les laisser de côté pour le moment. De nos jours, tous les processeurs utilisent un banc de registre multi-port.
[[File:Chemin de données minimal d'une architecture LOAD-STORE (sans MOV inter-registres).png|centre|vignette|upright=2|Chemin de données minimal d'une architecture LOAD-STORE (sans MOV inter-registres)]]
Le banc de registre multiport est optimisé pour les opérations dyadiques. Il dispose précisément de deux ports de lecture et d'un port d'écriture pour l'écriture. Un port de lecture par opérande et le port d'écriture pour enregistrer le résultat. En clair, le processeur peut lire deux opérandes et écrire un résultat en un seul cycle d'horloge. L'avantage est que les opérations simples ne nécessitent qu'une micro-opération, pas plus.
[[File:ALU data paths.svg|centre|vignette|upright=1.5|Processeur LOAD-STORE avec un banc de registre multiport, avec les trois ports mis en évidence.]]
===Une architecture LOAD-STORE basique, avec adressage absolu===
Voyons maintenant comment l'implémentation d'une architecture RISC très simple, qui ne supporte pas les adressages pour les pointeurs, juste les adressages inhérent (à registres) et absolu (par adresse mémoire). Les instructions LOAD et STORE utilisent l'adressage absolu, géré par le séquenceur, reste à gérer l'échange entre banc de registres et bus de données. Une lecture LOAD relie le bus de données au port d'écriture du banc de registres, alors que l'écriture relie le bus au port de lecture du banc de registre. Pour cela, il faut ajouter des multiplexeurs sur les chemins existants, comme illustré par le schéma ci-dessous.
[[File:Bus interne au processeur sur archi LOAD STORE avec banc de registres multiport.png|centre|vignette|upright=2|Organisation interne d'une architecture LOAD STORE avec banc de registres multiport. Nous n'avons pas représenté les signaux de commandes envoyés par le séquenceur au chemin de données.]]
Ajoutons ensuite les instructions de copie entre registres, souvent appelées instruction COPY ou MOV. Elles existent sur la plupart des architectures LOAD-STORE. Une première solution boucle l'entrée du banc de registres sur son entrée, ce qui ne sert que pour les copies de registres.
[[File:Chemin de données d'une architecture LOAD-STORE.png|centre|vignette|upright=2|Chemin de données d'une architecture LOAD-STORE]]
Mais il existe une seconde solution, qui ne demande pas de modifier le chemin de données. Il est possible de faire passer les copies de données entre registres par l'ALU. Lors de ces copies, l'ALU une opération ''Pass through'', à savoir qu'elle recopie une des opérandes sur sa sortie. Le fait qu'une ALU puisse effectuer une opération ''Pass through'' permet de fortement simplifier le chemin de donnée, dans le sens où cela permet d'économiser des multiplexeurs. Mais nous verrons cela sous peu. D'ailleurs, dans la suite du chapitre, nous allons partir du principe que les copies entre registres passent par l'ALU, afin de simplifier les schémas.
===L'ajout des modes d'adressage indirects à registre pour les pointeurs===
Passons maintenant à l'implémentation des modes d'adressages pour les pointeurs. Avec eux, l'adresse mémoire à lire/écrire n'est pas intégrée dans une instruction, mais est soit dans un registre, soit calculée par l'ALU.
Le premier mode d'adressage de ce type est le mode d'adressage indirect à registre, où l'adresse à lire/écrire est dans un registre. L'implémenter demande donc de connecter la sortie du banc de registres au bus d'adresse. Il suffit d'ajouter un MUX en sortie d'un port de lecture.
[[File:Chemin de données à trois bus.png|centre|vignette|upright=2|Chemin de données à trois bus.]]
Le mode d'adressage base + indice est un mode d'adressage où l'adresse à lire/écrire est calculée à partir d'une adresse et d'un indice, tous deux présents dans un registre. Le calcul de l'adresse implique au minimum une addition et donc l'ALU. Dans ce cas, on doit connecter la sortie de l'unité de calcul au bus d'adresse.
[[File:Bus avec adressage base+index.png|centre|vignette|upright=2|Bus avec adressage base+index]]
Le chemin de données précédent gère aussi le mode d'adressage indirect avec pré-décrément. Pour rappel, ce mode d'adressage est une variante du mode d'adressage indirect, qui utilise une pointeur/adresse stocké dans un registre. La différence est que ce pointeur est décrémenté avant d'être envoyé sur le bus d'adresse. L'implémentation matérielle est la même que pour le mode Base + Indice : l'adresse est lue depuis les registres, décrémentée dans l'ALU, et envoyée sur le bus d'adresse.
Le schéma précédent montre que le bus d'adresse est connecté à un MUX avant l'ALU et un autre MUX après. Mais il est possible de se passer du premier MUX, utilisé pour le mode d'adressage indirect à registre. La condition est que l'ALU supporte l'opération ''pass through'', un NOP, qui recopie une opérande sur sa sortie. L'ALU fera une opération NOP pour le mode d'adressage indirect à registre, un calcul d'adresse pour le mode d'adressage base + indice. Par contre, faire ainsi rendra l'adressage indirect légèrement plus lent, vu que le temps de passage dans l'ALU sera compté.
[[File:Bus avec adressage indirect.png|centre|vignette|upright=2|Bus avec adressages pour les pointeurs, simplifié.]]
Dans ce qui va suivre, nous allons partir du principe que le processeur est implémenté en suivant le schéma précédent, afin d'avoir des schéma plus lisibles.
===L'adressage immédiat et les modes d'adressages exotiques===
Passons maintenant au mode d’adressage immédiat, qui permet de préciser une constante dans une instruction directement. La constante est extraite de l'instruction par le séquenceur, puis insérée au bon endroit dans le chemin de données. Pour les opérations arithmétiques/logiques/branchements, il faut insérer la constante extraite sur l'entrée de l'ALU. Sur certains processeurs, la constante peut être négative et doit alors subir une extension de signe dans un circuit spécialisé.
[[File:Chemin de données - Adressage immédiat avec extension de signe.png|centre|vignette|upright=2|Chemin de données - Adressage immédiat avec extension de signe.]]
L'implémentation précédente gère aussi les modes d'adressage base + décalage et absolu indexé. Pour rappel, le premier ajoute une constante à une adresse prise dans les registres, le second prend une adresse constante et lui ajoute un indice pris dans les registres. Dans les deux cas, on lit un registre, extrait une constante/adresse de l’instruction, additionne les deux dans l'ALU, avant d'envoyer le résultat sur le bus d'adresse. La seule difficulté est de désactiver l'extension de signe pour les adresses.
Le mode d'adressage absolu peut être traité de la même manière, si l'ALU est capable de faire des NOPs. L'adresse est insérée au même endroit que pour le mode d'adressage immédiat, parcours l'unité de calcul inchangée parce que NOP, et termine sur le bus d'adresse.
[[File:Chemin de données avec une ALU capable de faire des NOP.png|centre|vignette|upright=2|Chemin de données avec adressage immédiat étendu pour gérer des adresses.]]
Passons maintenant au cas particulier d'une instruction MOV qui copie une constante dans un registre. Il n'y a rien à faire si l'unité de calcul est capable d'effectuer une opération NOP/''pass through''. Pour charger une constante dans un registre, l'ALU est configurée pour faire un NOP, la constante traverse l'ALU et se retrouve dans les registres. Si l'ALU ne gère pas les NOP, la constante doit être envoyée sur l'entrée d'écriture du banc de registres, à travers un MUX dédié.
[[File:Implémentation de l'adressage immédiat dans le chemin de données.png|centre|vignette|upright=2|Implémentation de l'adressage immédiat dans le chemin de données]]
===Les architectures CISC : les opérations ''load-op''===
Tout ce qu'on a vu précédemment porte sur les processeurs de type LOAD-STORE, souvent confondus avec les processeurs de type RISC, où les accès mémoire sont séparés des instructions utilisant l'ALU. Il est maintenant temps de voir les processeurs CISC, qui gèrent des instructions ''load-op'', qui peuvent lire une opérande depuis la mémoire.
L'implémentation des opérations ''load-op'' relie le bus de donnée directement sur une entrée de l'unité de calcul, en utilisant encore une fois un multiplexeur. L'implémentation parait simple, mais c'est parce que toute la complexité est déportée dans le séquenceur. C'est lui qui se charge de détecter quand la lecture de l'opérande est terminée, quand l'opérande est disponible.
Les instructions ''load-op'' s'exécutent en plusieurs étapes, en plusieurs micro-opérations. Il y a typiquement une étape pour l'opérande à lire en mémoire et une étape de calcul. L'usage d'un registre d’interfaçage permet d'implémenter les instructions ''load-op'' très facilement. Une opération ''load-op'' charge l'opérande en mémoire dans un registre d’interfaçage, puis relier ce registre d’interfaçage sur une des entrées de l'ALU. Un simple multiplexeur suffit pour implémenter le tout, en plus des modifications adéquates du séquenceur.
[[File:Chemin de données d'un CPU CISC avec lecture des opérandes en mémoire.png|centre|vignette|upright=2|Chemin de données d'un CPU CISC avec lecture des opérandes en mémoire]]
Supporter les instructions multi-accès (qui font plusieurs accès mémoire) ne modifie pas fondamentalement le réseau d'interconnexion, ni le chemin de données La raison est que supporter les instructions multi-accès se fait au niveau du séquenceur. En réalité, les accès mémoire se font en série, l'un après l'autre, sous la commande du séquenceur qui émet plusieurs micro-opérations mémoire consécutives. Les données lues sont placées dans des registres d’interactivement mémoire, ce qui demande d'ajouter des registres d’interfaçage mémoire en plus.
==Annexe : le cas particulier du pointeur de pile==
Le pointeur de pile est un registre un peu particulier. Il peut être placé dans le chemin de données ou dans le séquenceur, voire dans l'unité de chargement, tout dépend du processeur. Tout dépend de si le pointeur de pile gère une pile d'adresses de retour ou une pile d'appel.
===Le pointeur de pile non-adressable explicitement===
Avec une pile d'adresse de retour, le pointeur de pile n'est pas adressable explicitement, il est juste adressé implicitement par des instructions d'appel de fonction CALL et des instructions de retour de fonction RET. Le pointeur de pile est alors juste incrémenté ou décrémenté par un pas constant, il ne subit pas d'autres opérations, son adressage est implicite. Il est juste incrémenté/décrémenté par pas constants, qui sont fournis par le séquenceur. Il n'y a pas besoin de le relier au chemin de données, vu qu'il n'échange pas de données avec les autres registres. Il y a alors plusieurs solutions, mais la plus simple est de placer le pointeur de pile dans le séquenceur et de l'incrémenter par un incrémenteur dédié.
Quelques processeurs simples disposent d'une pile d'appel très limitée, où le pointeur de pile n'est pas adressable explicitement. Il est adressé implicitement par les instruction CALL, RET, mais aussi PUSH et POP, mais aucune autre instruction ne permet cela. Là encore, le pointeur de pile ne communique pas avec les autres registres. Il est juste incrémenté/décrémenté par pas constants, qui sont fournis par le séquenceur. Là encore, le plus simple est de placer le pointeur de pile dans le séquenceur et de l'incrémenter par un incrémenteur dédié.
Dans les deux cas, le pointeur de pile est placé dans l'unité de contrôle, le séquenceur, et est associé à un incrémenteur dédié. Il se trouve que cet incrémenteur est souvent partagé avec le ''program counter''. En effet, les deux sont des adresses mémoire, qui sont incrémentées et décrémentées par pas constants, ne subissent pas d'autres opérations (si ce n'est des branchements, mais passons). Les ressemblances sont suffisantes pour fusionner les deux circuits. Ils peuvent donc avoir un '''incrémenteur partagé'''.
L'incrémenteur en question est donc partagé entre pointeur de pile, ''program counter'' et quelques autres registres similaires. Par exemple, le Z80 intégrait un registre pour le rafraichissement mémoire, qui était réalisé par le CPU à l'époque. Ce registre contenait la prochaine adresse mémoire à rafraichir, et était incrémenté à chaque rafraichissement d'une adresse. Et il était lui aussi intégré au séquenceur et incrémenté par l'incrémenteur partagé.
[[File:Organisation interne d'une architecture à pile.png|centre|vignette|upright=2|Organisation interne d'une architecture à pile]]
===Le pointeur de pile adressable explicitement===
Maintenant, étudions le cas d'une pile d'appel, précisément d'une pile d'appel avec des cadres de pile de taille variable. Sous ces conditions, le pointeur de pile est un registre adressable, avec un nom/numéro de registre dédié. Tel est par exemple le cas des processeurs x86 avec le registre ESP (''Extended Stack Pointer''). Il est manipulé par les instructions CALL, RET, PUSH et POP, mais aussi par les instructions d'addition/soustraction pour gérer des cadres de pile de taille variable. De plus, il peut servir d'opérande pour des calculs d'adresse, afin de lire/écrire des variables locales, les arguments d'une fonction, et autres.
Dans ce cas, la meilleure solution est de placer le pointeur de pile dans le banc de registre généraux, avec les autres registres entiers. En faisant cela, la manipulation du pointeur de pile est faite par l'unité de calcul entière, pas besoin d'utiliser un incrémenteur dédiée. Il a existé des processeurs qui mettaient le pointeur de pile dans le banc de registre, mais l'incrémentaient avec un incrémenteur dédié, mais nous les verrons dans le chapitre sur les architectures à accumulateur. La raison est que sur les processeurs concernés, les adresses ne faisaient pas la même taille que les données : c'était des processeurs 8 bits, qui géraient des adresses de 16 bits.
==Annexe : l'implémentation du système d'''aliasing'' des registres des CPU x86==
Il y a quelques chapitres, nous avions parlé du système d'''aliasing'' des registres des CPU x86. Pour rappel, il permet de donner plusieurs noms de registre pour un même registre. Plus précisément, pour un registre 64 bits, le registre complet aura un nom de registre, les 32 bits de poids faible auront leur nom de registre dédié, idem pour les 16 bits de poids faible, etc. Il est possible de faire des calculs sur ces moitiés/quarts/huitièmes de registres sans problème.
===L'''aliasing'' du 8086, pour les registres 16 bits===
[[File:Register 8086.PNG|vignette|Register 8086]]
L'implémentation de l'''aliasing'' est apparue sur les premiers CPU Intel 16 bits, notamment le 8086. En tout, ils avaient quatre registres généraux 16 bits : AX, BX, CX et DX. Ces quatre registres 16 bits étaient coupés en deux octets, chacun adressable. Par exemple, le registre AX était coupé en deux octets nommés AH et AL, chacun ayant son propre nom/numéro de registre. Les instructions d'addition/soustraction pouvaient manipuler le registre AL, ou le registre AH, ce qui modifiait les 8 bits de poids faible ou fort selon le registre choisit.
Le banc de registre ne gére que 4 registres de 16 bits, à savoir AX, BX, CX et DX. Lors d'une lecture d'un registre 8 bits, le registre 16 bit entier est lu depuis le banc de registre, mais les bits inutiles sont ignorés. Par contre, l'écriture peut se faire soit avec 16 bits d'un coup, soit pour seulement un octet. Le port d'écriture du banc de registre peut être configuré de manière à autoriser l'écriture soit sur les 16 bits du registre, soit seulement sur les 8 bits de poids faible, soit écrire dans les 8 bits de poids fort.
[[File:Port d'écriture du banc de registre du 8086.png|centre|vignette|upright=2.5|Port d'écriture du banc de registre du 8086]]
Une opération sur un registre 8 bits se passe comme suit. Premièrement, on lit le registre 16 bits complet depuis le banc de registre. Si l'on a sélectionné l'octet de poids faible, il ne se passe rien de particulier, l'opérande 16 bits est envoyée directement à l'ALU. Mais si on a sélectionné l'octet de poids fort, la valeur lue est décalée de 7 rangs pour atterrir dans les 8 octets de poids faible. Ensuite, l'unité de calcul fait un calcul avec cet opérande, un calcul 16 bits tout ce qu'il y a de plus classique. Troisièmement, le résultat est enregistré dans le banc de registre, en le configurant convenablement. La configuration précise s'il faut enregistrer le résultat dans un registre 16 bits, soit seulement dans l'octet de poids faible/fort.
Afin de simplifier le câblage, les 16 bits des registres AX/BX/CX/DX sont entrelacés d'une manière un peu particulière. Intuitivement, on s'attend à ce que les bits soient physiquement dans le même ordre que dans le registre : le bit 0 est placé à côté du bit 1, suivi par le bit 2, etc. Mais à la place, l'octet de poids fort et de poids faible sont mélangés. Deux bits consécutifs appartiennent à deux octets différents. Le tout est décrit dans le tableau ci-dessous.
{|class="wikitable"
|-
! Registre 16 bits normal
| class="f_bleu" | 15
| class="f_bleu" | 14
| class="f_bleu" | 13
| class="f_bleu" | 12
| class="f_bleu" | 11
| class="f_bleu" | 10
| class="f_bleu" | 9
| class="f_bleu" | 8
| class="f_rouge" | 7
| class="f_rouge" | 6
| class="f_rouge" | 5
| class="f_rouge" | 4
| class="f_rouge" | 3
| class="f_rouge" | 2
| class="f_rouge" | 1
| class="f_rouge" | 0
|-
! Registre 16 bits du 8086
| class="f_bleu" | 15
| class="f_rouge" | 7
| class="f_bleu" | 14
| class="f_rouge" | 6
| class="f_bleu" | 13
| class="f_rouge" | 5
| class="f_bleu" | 12
| class="f_rouge" | 4
| class="f_bleu" | 11
| class="f_rouge" | 3
| class="f_bleu" | 10
| class="f_rouge" | 2
| class="f_bleu" | 9
| class="f_rouge" | 1
| class="f_bleu" | 8
| class="f_rouge" | 0
|}
En faisant cela, le décaleur en entrée de l'ALU est bien plus simple. Il y a 8 multiplexeurs, mais le câblage est bien plus simple. Par contre, en sortie de l'ALU, il faut remettre les bits du résultat dans l'ordre adéquat, celui du registre 8086. Pour cela, les interconnexions sur le port d'écriture sont conçues pour. Il faut juste mettre les fils de sortie de l'ALU sur la bonne entrée, par besoin de multiplexeurs.
===L'''aliasing'' sur les processeurs x86 32/64 bits===
Les processeurs x86 32 et 64 bits ont un système d'''aliasing'' qui complète le système précédent. Les processeurs 32 bits étendent les registres 16 bits existants à 32 bits. Pour ce faire, le registre 32 bit a un nouveau nom de registre, distincts du nom de registre utilisé pour l'ancien registre 16 bits. Il est possible d'adresser les 16 bits de poids faible de ce registre, avec le même nom de registre que celui utilisé pour le registre 16 sur les processeurs d'avant. Même chose avec les processeurs 64, avec l'ajout d'un nouveau nom de registre pour adresser un registre de 64 bit complet.
En soit, implémenter ce système n'est pas compliqué. Prenons le cas du registre RAX (64 bits), et de ses subdivisions nommées EAX (32 bits), AX (16 bits). À l'intérieur du banc de registre, il n'y a que le registre RAX. Le banc de registre ne comprend qu'un seul nom de registre : RAX. Les subdivisions EAX et AX n'existent qu'au niveau de l'écriture dans le banc de registre. L'écriture dans le banc de registre est configurable, de manière à ne modifier que les bits adéquats. Le résultat d'un calcul de l'ALU fait 64 bits, il est envoyé sur le port d'écriture. À ce niveau, soit les 64 bits sont écrits dans le registre, soit seulement les 32/16 bits de poids faible. Le système du 8086 est préservé pour les écritures dans les 16 bits de poids faible.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les composants d'un processeur
| prevText=Les composants d'un processeur
| next=L'unité de chargement et le program counter
| nextText=L'unité de chargement et le program counter
}}
</noinclude>
0wrc7u51aww5ltthup64ovuxoi78ncs
763703
763685
2026-04-15T00:56:02Z
Mewtow
31375
/* L'unité de calcul d'adresse */
763703
wikitext
text/x-wiki
Comme vu précédemment, le '''chemin de donnée''' est l'ensemble des composants dans lesquels circulent les données dans le processeur. Il comprend l'unité de calcul, les registres, l'unité de communication avec la mémoire, et le ou les interconnexions qui permettent à tout ce petit monde de communiquer. Dans ce chapitre, nous allons voir ces composants en détail.
==Les unités de calcul==
Le processeur contient des circuits capables de faire des calculs arithmétiques, des opérations logiques, et des comparaisons, qui sont regroupés dans une unité de calcul appelée '''unité arithmétique et logique'''. Certains préfèrent l’appellation anglaise ''arithmetic and logic unit'', ou ALU. Par défaut, ce terme est réservé aux unités de calcul qui manipulent des nombres entiers. Les unités de calcul spécialisées pour les calculs flottants sont désignées par le terme "unité de calcul flottant", ou encore FPU (''Floating Point Unit'').
L'interface d'une unité de calcul est assez simple : on a des entrées pour les opérandes et une sortie pour le résultat du calcul. De plus, les instructions de comparaisons ou de calcul peuvent mettre à jour le registre d'état, qui est relié à une autre sortie de l’unité de calcul. Une autre entrée, l''''entrée de sélection de l'instruction''', spécifie l'opération à effectuer. Elle sert à configurer l'unité de calcul pour faire une addition et pas une multiplication, par exemple. Sur cette entrée, on envoie un numéro qui précise l'opération à effectuer. La correspondance entre ce numéro et l'opération à exécuter dépend de l'unité de calcul. Sur les processeurs où l'encodage des instructions est "simple", une partie de l'opcode de l'instruction est envoyé sur cette entrée.
[[File:Unité de calcul usuelle.png|centre|vignette|upright=2|Unité de calcul usuelle.]]
Il faut signaler que les processeurs modernes possèdent plusieurs unités de calcul, toutes reliées aux registres. Cela permet d’exécuter plusieurs calculs en même temps dans des unités de calcul différentes, afin d'augmenter les performances du processeur. Diverses technologies, abordées dans la suite du cours permettent de profiter au mieux de ces unités de calcul : pipeline, exécution dans le désordre, exécution superscalaire, jeux d'instructions VLIW, etc. Mais laissons cela de côté pour le moment.
===L'ALU entière : additions, soustractions, opérations bit à bit===
Un processeur contient plusieurs ALUs spécialisées. La principale, présente sur tous les processeurs, est l''''ALU entière'''. Elle s'occupe uniquement des opérations sur des nombres entiers, les nombres flottants sont gérés par une ALU à part. Elle gère des opérations simples : additions, soustractions, opérations bit à bit, parfois des décalages/rotations. Par contre, elle ne gère pas la multiplication et la division, qui sont prises en charge par un circuit multiplieur/diviseur à part.
L'ALU entière a déjà été vue dans un chapitre antérieur, nommé "Les unités arithmétiques et logiques entières (simples)", qui expliquait comment en concevoir une. Nous avions vu qu'une ALU entière est une sorte de circuit additionneur-soustracteur amélioré, ce qui explique qu'elle gère des opérations entières simples, mais pas la multiplication ni la division. Nous ne reviendrons pas dessus. Cependant, il y a des choses à dire sur leur intégration au processeur.
Une ALU entière gère souvent une opération particulière, qui ne fait rien et recopie simplement une de ses opérandes sur sa sortie. L'opération en question est appelée l''''opération ''Pass through''''', encore appelée opération NOP. Elle est implémentée en utilisant un simple multiplexeur, placé en sortie de l'ALU. Le fait qu'une ALU puisse effectuer une opération ''Pass through'' permet de fortement simplifier le chemin de donnée, d'économiser des multiplexeurs. Mais nous verrons cela sous peu.
[[File:ALU avec opération NOP.png|centre|vignette|upright=2|ALU avec opération NOP.]]
Avant l'invention du microprocesseur, le processeur n'était pas un circuit intégré unique. L'ALU, le séquenceur et les registres étaient dans des puces séparées. Les ALU étaient vendues séparément et manipulaient des opérandes de 4/8 bits, les ALU 4 bits étaient très fréquentes. Si on voulait créer une ALU pour des opérandes plus grandes, il fallait construire l'ALU en combinant plusieurs ALU 4/8 bits. Par exemple, l'ALU des processeurs AMD Am2900 est une ALU de 16 bits composée de plusieurs sous-ALU de 4 bits. Cette technique qui consiste à créer des unités de calcul à partir d'unités de calcul plus élémentaires s'appelle en jargon technique du '''bit slicing'''. Nous en avions parlé dans le chapitre sur les unités de calcul, aussi nous n'en reparlerons pas plus ici.
L'ALU manipule des opérandes codées sur un certain nombre de bits. Par exemple, une ALU peut manipuler des entiers codés sur 8 bits, sur 16 bits, etc. En général, la taille des opérandes de l'ALU est la même que la taille des registres. Un processeur 32 bits, avec des registres de 32 bit, a une ALU de 32 bits. C'est intuitif, et cela rend l'implémentation du processeur bien plus facile. Mais il y a quelques exceptions, où l'ALU manipule des opérandes plus petits que la taille des registres. Par exemple, de nombreux processeurs 16 bits, avec des registres de 16 bits, utilisent une ALU de 8 bits. Un autre exemple assez connu est celui du Motorola 68000, qui était un processeur 32 bits, mais dont l'ALU faisait juste 16 bits. Son successeur, le 68020, avait lui une ALU de 32 bits.
Sur de tels processeurs, les calculs sont fait en plusieurs passes. Par exemple, avec une ALU 8 bit, les opérations sur des opérandes 8 bits se font en un cycle d'horloge, celles sur 16 bits se font en deux cycles, celles en 32 en quatre, etc. Si un programme manipule assez peu d'opérandes 16/32/64 bits, la perte de performance est assez faible. Diverses techniques visent à améliorer les performances, mais elles ne font pas de miracles. Par exemple, vu que l'ALU est plus courte, il est possible de la faire fonctionner à plus haute fréquence, pour réduire la perte de performance.
Pour comprendre comme est implémenté ce système de passes, prenons l'exemple du processeur 8 bit Z80. Ses registres entiers étaient des registres de 8 bits, alors que l'ALU était de 4 bits. Les calculs étaient faits en deux phases : une qui traite les 4 bits de poids faible, une autre qui traite les 4 bits de poids fort. Pour cela, les opérandes étaient placées dans des registres de 4 bits en entrée de l'ALU, plusieurs multiplexeurs sélectionnaient les 4 bits adéquats, le résultat était mémorisé dans un registre de résultat de 8 bits, un démultiplexeur plaçait les 4 bits du résultat au bon endroit dans ce registre. L'unité de contrôle s'occupait de la commande des multiplexeurs/démultiplexeurs. Les autres processeurs 8 ou 16 bits utilisent des circuits similaires pour faire leurs calculs en plusieurs fois.
[[File:ALU du Z80.png|centre|vignette|upright=2|ALU du Z80]]
Un exemple extrême est celui des des '''processeurs sériels''' (sous-entendu ''bit-sériels''), qui utilisent une '''ALU sérielle''', qui fait leurs calculs bit par bit, un bit à la fois. S'il a existé des processeurs de 1 bit, comme le Motorola MC14500B, la majeure partie des processeurs sériels étaient des processeurs 4, 8 ou 16 bits. L'avantage de ces ALU est qu'elles utilisent peu de transistors, au détriment des performances par rapport aux processeurs non-sériels. Mais un autre avantage est qu'elles peuvent gérer des opérandes de grande taille, avec plus d'une trentaine de bits, sans trop de problèmes.
===Les circuits multiplieurs et diviseurs===
Les processeurs modernes ont une ALU pour les opérations simples (additions, décalages, opérations logiques), couplée à une ALU pour les multiplications, un circuit multiplieur séparé. Précisons qu'il ne sert pas à grand chose de fusionner le circuit multiplieur avec l'ALU, mieux vaut les garder séparés par simplicité. Les processeurs haute performance disposent systématiquement d'un circuit multiplieur et gèrent la multiplication dans leur jeu d'instruction.
Le cas de la division est plus compliqué. La présence d'un circuit multiplieur est commune, mais les circuits diviseurs sont eux très rares. Leur cout en circuit est globalement le même que pour un circuit multiplieur, mais le gain en performance est plus faible. Le gain en performance pour la multiplication est modéré car il s'agit d'une opération très fréquente, alors qu'il est très faible pour la division car celle-ci est beaucoup moins fréquente.
Pour réduire le cout en circuits, il arrive que l'ALU pour les multiplications gère à la fois la multiplication et la division. Les circuits multiplieurs et diviseurs sont en effet très similaires et partagent beaucoup de points communs. Généralement, la fusion se fait pour les multiplieurs/diviseurs itératifs.
===Le ''barrel shifter''===
On vient d'expliquer que la présence de plusieurs ALU spécialisée est très utile pour implémenter des opérations compliquées à insérer dans une unité de calcul normale, comme la multiplication et la division. Mais les décalages sont aussi dans ce cas, de même que les rotations. Nous avions vu il y a quelques chapitres qu'ils sont réalisés par un circuit spécialisé, appelé un ''barrel shifter'', qu'il est difficile de fusionner avec une ALU normale. Aussi, beaucoup de processeurs incorporent un ''barrel shifter'' séparé de l'ALU.
Les processeurs ARM utilise un ''barrel shifter'', mais d'une manière un peu spéciale. On a vu il y a quelques chapitres que si on fait une opération logique, une addition, une soustraction ou une comparaison, la seconde opérande peut être décalée automatiquement. L'instruction incorpore le type de de décalage à faire et par combien de rangs il faut décaler directement à côté de l'opcode. Cela simplifie grandement les calculs d'adresse, qui se font en une seule instruction, contre deux ou trois sur d'autres architectures. Et pour cela, l'ALU proprement dite est précédée par un ''barrel shifter'',une seconde ALU spécialisée dans les décalages. Notons que les instructions MOV font aussi partie des instructions où la seconde opérande (le registre source) peut être décalé : cela signifie que les MOV passent par l'ALU, qui effectue alors un NOP, une opération logique OUI.
===Les unités de calcul spécialisées===
Un processeur peut disposer d’unités de calcul séparées de l'unité de calcul principale, spécialisées dans les décalages, les divisions, etc. Et certaines d'entre elles sont spécialisées dans des opérations spécifiques, qui ne sont techniquement pas des opérations entières, sur des nombres entiers.
[[File:Unité de calcul flottante, intérieur.png|vignette|upright=1|Unité de calcul flottante, intérieur]]
Depuis les années 90-2000, presque tous les processeurs utilisent une unité de calcul spécialisée pour les nombres flottants : la '''Floating-Point Unit''', aussi appelée FPU. En général, elle regroupe un additionneur-soustracteur flottant et un multiplieur flottant. Parfois, elle incorpore un diviseur flottant, tout dépend du processeur. Précisons que sur certains processeurs, la FPU et l'ALU entière ne vont pas à la même fréquence, pour des raisons de performance et de consommation d'énergie !
La FPU intègre un circuit multiplieur entier, utilisé pour les multiplications flottantes, afin de multiplier les mantisses entre elles. Quelques processeurs utilisaient ce multiplieur pour faire les multiplications entières. En clair, au lieu d'avoir un multiplieur entier séparé du multiplieur flottant, les deux sont fusionnés en un seul circuit. Il s'agit d'une optimisation qui a été utilisée sur quelques processeurs 32 bits, qui supportaient les flottants 64 bits (double précision). Les processeurs Atom étaient dans ce cas, idem pour l'Athlon première génération. Les processeurs modernes n'utilisent pas cette optimisation pour des raisons qu'on ne peut pas expliquer ici (réduction des dépendances structurelles, émission multiple).
Il existe des unités de calcul spécialisées pour les calculs d'adresse. Elles ne supportent guère plus que des incrémentations/décrémentations, des additions/soustractions, et des décalages simples. Les autres opérations n'ont pas de sens avec des adresses. L'usage d'ALU spécialisées pour les adresses est un avantage sur les processeurs où les adresses ont une taille différente des données, ce qui est fréquent sur les anciens processeurs 8 bits.
De nombreux processeurs modernes disposent d'une unité de calcul spécialisée dans le calcul des conditions, tests et branchements. C’est notamment le cas sur les processeurs sans registre d'état, qui disposent de registres à prédicats. En général, les registres à prédicats sont placés à part des autres registres, dans un banc de registre séparé. L'unité de calcul normale n'est pas reliée aux registres à prédicats, alors que l'unité de calcul pour les branchements/test/conditions l'est. Les registres à prédicats sont situés juste en sortie de cette unité de calcul.
==Les registres du processeur==
Après avoir vu l'unité de calcul, il est temps de passer aux registres d'un processeur. L'organisation des registres est généralement assez compliquée, avec quelques registres séparés des autres comme le registre d'état ou le ''program counter''. Les registres d'un processeur peuvent se classer en deux camps : soit ce sont des registres isolés, soit ils sont regroupés en paquets appelés banc de registres.
Un '''banc de registres''' (''register file'') est une RAM, dont chaque byte est un registre. Il regroupe un paquet de registres différents dans un seul composant, dans une seule mémoire. Dans processeur moderne, on trouve un ou plusieurs bancs de registres. La répartition des registres, à savoir quels registres sont dans le banc de registre et quels sont ceux isolés, est très variable suivant les processeurs.
[[File:Register File Simple.svg|centre|vignette|upright=1|Banc de registres simplifié.]]
===L'adressage du banc de registres===
Le banc de registre est une mémoire comme une autre, avec une entrée d'adresse qui permet de sélectionner le registre voulu. Plutot que d'adresse, nous allons parler d''''identifiant de registre'''. Le séquenceur forge l'identifiant de registre en fonction des registres sélectionnés. Dans les chapitres précédents, nous avions vu qu'il existe plusieurs méthodes pour sélectionner un registre, qui portent les noms de modes d'adressage. Et bien les modes d'adressage jouent un grand rôle dans la forge de l'identifiant de registre.
Pour rappel, sur la quasi-totalité des processeurs actuels, les registres généraux sont identifiés par un nom de registre, terme trompeur vu que ce nom est en réalité un numéro. En clair, les processeurs numérotent les registres, le numéro/nom du registre permettant de l'identifier. Par exemple, si je veux faire une addition, je dois préciser les deux registres pour les opérandes, et éventuellement le registre pour le résultat : et bien ces registres seront identifiés par un numéro. Mais tous les registres ne sont pas numérotés et ceux qui ne le sont pas sont adressés implicitement. Par exemple, le pointeur de pile sera modifié par les instructions qui manipulent la pile, sans que cela aie besoin d'être précisé par un nom de registre dans l'instruction.
Dans le cas le plus simple, les registres nommés vont dans le banc de registres, les registres adressés implicitement sont en-dehors, dans des registres isolés. L'idéntifiant de registre est alors simplement le nom de registre, le numéro. Le séquenceur extrait ce nom de registre de l'insutrction, avant de l'envoyer sur l'entrée d'adresse du banc de registre.
[[File:Adressage du banc de registres généruax.png|centre|vignette|upright=2|Adressage du banc de registres généraux]]
Dans un cas plus complexe, des registres non-nommés sont placés dans le banc de registres. Par exemple, les pointeurs de pile sont souvent placés dans le banc de registre, même s'ils sont adressés implicitement. Même des registres aussi importants que le ''program counter'' peuvent se mettre dans le banc de registre ! Nous verrons le cas du ''program counter'' dans le chapitre suivant, qui porte sur l'unité de chargement. Dans ce cas, le séquenceur forge l'identifiant de registre de lui-même. Dans le cas des registres nommés, il ajoute quelques bits aux noms de registres. Pour les registres adressés implicitement, il forge l'identifiant à partir de rien.
[[File:Adressage du banc de registre - cas général.png|centre|vignette|upright=2|Adressage du banc de registre - cas général]]
Nous verrons plus bas que dans certains cas, le nom de registre ne suffit pas à adresser un registre dans un banc de registre. Dans ce cas, le séquenceur rajoute des bits, comme dans l'exemple précédent. Tout ce qu'il faut retenir est que l'identifiant de registre est forgé par le séquenceur, qui se base entre autres sur le nom de registre s'il est présent, sur l'instruction exécutée dans le cas d'un registre adressé implicitement.
===Les registres généraux===
Pour rappel, les registres généraux peuvent mémoriser des entiers, des adresses, ou toute autre donnée codée en binaire. Ils sont souvent séparés des registres flottants sur les architectures modernes. Les registres généraux sont rassemblés dans un banc de registre dédié, appelé le '''banc de registres généraux'''. Le banc de registres généraux est une mémoire multiport, avec au moins un port d'écriture et deux ports de lecture. La raison est que les instructions lisent deux opérandes dans les registres et enregistrent leur résultat dans des registres. Le tout se marie bien avec un banc de registre à deux de lecture (pour les opérandes) et un d'écriture (pour le résultat).
[[File:Banc de registre multiports.png|centre|vignette|upright=2|Banc de registre multiports.]]
L'interface exacte dépend de si l'architecture est une architecture 2 ou 3 adresses. Pour rappel, la différence entre les deux tient dans la manière dont on précise le registre où enregistrer le résultat d'une opération. Avec les architectures 2-adresses, on précise deux registres : le premier sert à la fois comme opérande et pour mémoriser le résultat, l'autre sert uniquement d'opérande. Un des registres est donc écrasé pour enregistrer le résultat. Sur les architecture 3-adresses, on précise trois registres : deux pour les opérandes, un pour le résultat.
Les architectures 2-adresses ont un banc de registre où on doit préciser deux "adresses", deux noms de registre. L'interface du banc de registre est donc la suivante :
[[File:Register File Medium.svg|centre|vignette|upright=1.5|Register File d'une architecture à 2-adresses]]
Les architectures 3-adresses doivent rajouter une troisième entrée pour préciser un troisième nom de registre. L'interface du banc de registre est donc la suivante :
[[File:Register File Large.svg|centre|vignette|upright=1.5|Register File d'une architecture à 3-adresses]]
Rien n'empêche d'utiliser plusieurs bancs de registres sur un processeur qui utilise des registres généraux. La raison est une question d'optimisation. Au-delà d'un certain nombre de registres, il devient difficile d'utiliser un seul gros banc de registres. Il faut alors scinder le banc de registres en plusieurs bancs de registres séparés. Le problème est qu'il faut prévoir de quoi échanger des données entre les bancs de registres. Dans la plupart des cas, cette séparation est invisible du point de vue du langage machine. Sur d'autres processeurs, les transferts de données entre bancs de registres se font via une instruction spéciale, souvent appelée COPY.
===Les registres flottants : banc de registre séparé ou unifié===
Passons maintenant aux registres flottants. Intuitivement, on a des registres séparés pour les entiers et les flottants. Il est alors plus simple d'utiliser un banc de registres séparé pour les nombres flottants, à côté d'un banc de registre entiers. L'avantage est que les nombres flottants et entiers n'ont pas forcément la même taille, ce qui se marie bien avec deux bancs de registres, où la taille des registres est différente dans les deux bancs.
Mais d'autres processeurs utilisent un seul '''banc de registres unifié''', qui regroupe tous les registres de données, qu'ils soient entier ou flottants. Par exemple, c'est le cas des Pentium Pro, Pentium II, Pentium III, ou des Pentium M : ces processeurs ont des registres séparés pour les flottants et les entiers, mais ils sont regroupés dans un seul banc de registres. Avec cette organisation, un registre flottant et un registre entier peuvent avoir le même nom de registre en langage machine, mais l'adresse envoyée au banc de registres ne doit pas être la même : le séquenceur ajoute des bits au nom de registre pour former l'adresse finale.
[[File:Désambiguïsation de registres sur un banc de registres unifié.png|centre|vignette|upright=2|Désambiguïsation de registres sur un banc de registres unifié.]]
===Le registre d'état===
Le registre d'état fait souvent bande à part et n'est pas placé dans un banc de registres. En effet, le registre d'état est très lié à l'unité de calcul. Il reçoit des indicateurs/''flags'' provenant de la sortie de l'unité de calcul, et met ceux-ci à disposition du reste du processeur. Son entrée est connectée à l'unité de calcul, sa sortie est reliée au séquenceur et/ou au bus interne au processeur.
Le registre d'état est relié au séquenceur afin que celui-ci puisse gérer les instructions de branchement, qui ont parfois besoin de connaitre certains bits du registre d'état pour savoir si une condition a été remplie ou non. D'autres processeurs relient aussi le registre d'état au bus interne, ce qui permet de lire son contenu et de le copier dans un registre de données. Cela permet d'implémenter certaines instructions, notamment celles qui permettent de mémoriser le registre d'état dans un registre général.
[[File:Place du registre d'état dans le chemin de données.png|centre|vignette|upright=2|Place du registre d'état dans le chemin de données]]
L'ALU fournit une sortie différente pour chaque bit du registre d'état, la connexion du registre d'état est directe, comme indiqué dans le schéma suivant. Vous remarquerez que le bit de retenue est à la fois connecté à la sortie de l'ALU, mais aussi sur son entrée. Ainsi, le bit de retenue calculé par une opération peut être utilisé pour la suivante. Sans cela, diverses instructions comme les opérations ''add with carry'' ne seraient pas possibles.
[[File:AluStatusRegister.svg|centre|vignette|upright=2|Registre d'état et unit de calcul.]]
Il est techniquement possible de mettre le registre d'état dans le banc de registre, pour économiser un registre. La principale difficulté est que les instructions doivent faire deux écritures dans le banc de registre : une pour le registre de destination, une pour le registre d'état. Soit on utilise deux ports d'écriture, soit on fait les deux écritures l'une après l'autre. Dans les deux cas, le cout en performances et en transistors n'en vaut pas le cout. D'ailleurs, je ne connais aucun processeur qui utilise cette technique.
Il faut noter que le registre d'état n'existe pas forcément en tant que tel dans le processeur. Quelques processeurs, dont le 8086 d'Intel, utilisent des bascules dispersées dans le processeur au lieu d'un vrai registre d'état. Les bascules dispersées mémorisent chacune un bit du registre d'état et sont placées là où elles sont le plus utile. Les bascules utilisées pour les branchements sont proches du séquenceur, le bascules pour les bits de retenue sont placées proche de l'ALU, etc.
===Les registres à prédicats===
Les registres à prédicats remplacent le registre d'état sur certains processeurs. Pour rappel, les registres à prédicat sont des registres de 1 bit qui mémorisent les résultats des comparaisons et instructions de test. Ils sont nommés/numérotés, mais les numéros en question sont distincts de ceux utilisés pour les registres généraux.
Ils sont placés à part, dans un banc de registres séparé. Le banc de registres à prédicats a une entrée de 1 bit connectée à l'ALU et une sortie de un bit connectée au séquenceur. Le banc de registres à prédicats est parfois relié à une unité de calcul spécialisée dans les conditions/instructions de test. Pour rappel, certaines instructions permettent de faire un ET, un OU, un XOR entre deux registres à prédicats. Pour cela, l'unité de calcul dédiée aux conditions peut lire les registres à prédicats, pour combiner le contenu de plusieurs d'entre eux.
[[File:Banc de registre pour les registres à prédicats.png|centre|vignette|upright=2|Banc de registre pour les registres à prédicats]]
===Les registres dédiés aux interruptions===
Dans le chapitre sur les registres, nous avions vu que certains processeurs dupliquaient leurs registres architecturaux, pour accélérer les interruptions ou les appels de fonction. Dans le cas qui va nous intéresser, les interruptions avaient accès à leurs propres registres, séparés des registres architecturaux. Les processeurs de ce type ont deux ensembles de registres identiques : un dédié aux interruptions, un autre pour les programmes normaux. Les registres dans les deux ensembles ont les mêmes noms, mais le processeur choisit le bon ensemble suivant s'il est dans une interruption ou non. Si on peut utiliser deux bancs de registres séparés, il est aussi possible d'utiliser un banc de registre unifié pour les deux.
Sur certains processeurs, le banc de registre est dupliqué en plusieurs exemplaires. La technique est utilisée pour les interruptions. Certains processeurs ont deux ensembles de registres identiques : un dédié aux interruptions, un autre pour les programmes normaux. Les registres dans les deux ensembles ont les mêmes noms, mais le processeur choisit le bon ensemble suivant s'il est dans une interruption ou non. On peut utiliser deux bancs de registres séparés, un pour les interruptions, et un pour les programmes.
Sur d'autres processeurs, on utilise un banc de registre unifié pour les deux ensembles de registres. Les registres pour les interruptions sont dans les adresses hautes, les registres pour les programmes dans les adresses basses. Le choix entre les deux est réalisé par un bit qui indique si on est dans une interruption ou non, disponible dans une bascule du processeur. Appelons là la bascule I.
===Le fenêtrage de registres===
[[File:Fenetre de registres.png|vignette|upright=1|Fenêtre de registres.]]
Le '''fenêtrage de registres''' fait que chaque fonction a accès à son propre ensemble de registres, sa propre fenêtre de registres. Là encore, cette technique duplique chaque registre architectural en plusieurs exemplaires qui portent le même nom. Chaque ensemble de registres architecturaux forme une fenêtre de registre, qui contient autant de registres qu'il y a de registres architecturaux. Lorsqu'une fonction s’exécute, elle se réserve une fenêtre inutilisée, et peut utiliser les registres de la fenêtre comme bon lui semble : une fonction manipule le registre architectural de la fenêtre réservée, mais pas les registres avec le même nom dans les autres fenêtres.
Il peut s'implémenter soit avec un banc de registres unifié, soit avec un banc de registre par fenêtre de registres.
Il est possible d'utiliser des bancs de registres dupliqués pour le fenêtrage de registres. Chaque fenêtre de registre a son propre banc de registres. Le choix entre le banc de registre à utiliser est fait par un registre qui mémorise le numéro de la fenêtre en cours. Ce registre commande un multiplexeur qui permet de choisir le banc de registre adéquat.
[[File:Fenêtrage de registres au niveau du banc de registres.png|vignette|Fenêtrage de registres au niveau du banc de registres.]]
L'utilisation d'un banc de registres unifié permet d'implémenter facilement le fenêtrage de registres. Il suffit pour cela de regrouper tous les registres des différentes fenêtres dans un seul banc de registres. Il suffit de faire comme vu au-dessus : rajouter des bits au nom de registre pour faire la différence entre les fenêtres. Cela implique de se souvenir dans quelle fenêtre de registre on est actuellement, cette information étant mémorisée dans un registre qui stocke le numéro de la fenêtre courante. Pour changer de fenêtre, il suffit de modifier le contenu de ce registre lors d'un appel ou retour de fonction avec un petit circuit combinatoire. Bien sûr, il faut aussi prendre en compte le cas où ce registre déborde, ce qui demande d'ajouter des circuits pour gérer la situation.
[[File:Désambiguïsation des fenêtres de registres.png|centre|vignette|upright=2|Désambiguïsation des fenêtres de registres.]]
==L'interface de communication avec la mémoire==
L''''interface avec la mémoire''' est, comme son nom l'indique, des circuits qui servent d'intermédiaire entre le bus mémoire et le processeur. Elle est parfois appelée l'unité mémoire, l'unité d'accès mémoire, la ''load-store unit'', et j'en oublie. Nous utiliserons le terme d''''unité mémoire''', au même titre qu'on utilise le terme d'unité de calcul.
[[File:Unité de communication avec la mémoire, de type simple port.png|centre|vignette|upright=2|Unité de communication avec la mémoire, de type simple port.]]
Sur certains processeurs, elle gère les mémoires multiport.
[[File:Unité de communication avec la mémoire, de type multiport.png|centre|vignette|upright=2|Unité de communication avec la mémoire, de type multiport.]]
===Les registres d'interfaçage mémoire===
L'interface mémoire se résume le plus souvent à des '''registres d’interfaçage mémoire''', intercalés entre le bus mémoire et le chemin de données. Généralement, il y a au moins deux registres d’interfaçage mémoire : un registre relié au bus d'adresse, et autre relié au bus de données.
[[File:Registres d’interfaçage mémoire.png|centre|vignette|upright=2|Registres d’interfaçage mémoire.]]
Au lieu de lire ou écrire directement sur le bus, le processeur lit ou écrit dans ces registres, alors que l'unité mémoire s'occupe des échanges entre registres et bus mémoire. Lors d'une écriture, le processeur place l'adresse dans le registre d'interfaçage d'adresse, met la donnée à écrire dans le registre d'interfaçage de donnée, puis laisse l'unité d'accès mémoire faire son travail. Lors d'une lecture, il place l'adresse à lire sur le registre d'interfaçage d'adresse, il attend que la donnée soit lue, puis récupère la donnée dans le registre d'interfaçage de données.
L'avantage est que le processeur n'a pas à maintenir une donnée/adresse sur le bus durant tout un accès mémoire. Par exemple, prenons le cas où la mémoire met 15 cycles processeurs pour faire une lecture ou une écriture. Sans registres d'interfaçage mémoire, le processeur doit maintenir l'adresse durant 15 cycles, et aussi la donnée dans le cas d'une écriture. Avec ces registres, le processeur écrit dans les registres d'interfaçage mémoire au premier cycle, et passe les 14 cycles suivants à faire quelque chose d'autre. Par exemple, il faut faire un calcul en parallèle, envoyer des signaux de commande au banc de registre pour qu'il soit prêt une fois la donnée lue arrivée, etc. Cet avantage simplifie l'implémentation de certains modes d'adressage, comme on le verra à la fin du chapitre.
===L'unité de calcul d'adresse===
Les registres d'interfaçage sont presque toujours présents, mais le circuit que nous allons voir est complétement facultatif. Il s'agit d'une unité de calcul spécialisée dans les calculs d'adresse, dont nous avons parlé rapidement dans la section sur les ALU. Elle s'appelle l''''''Address generation unit''''', ou AGU. Elle est parfois séparée de l'interface mémoire proprement dit, et est alors considérée comme une unité de calcul à part, mais elle est généralement intimement liée à l'interface mémoire.
Elle sert pour certains modes d'adressage, qui demandent de combiner une adresse avec soit un indice, soit un décalage, plus rarement les deux. Les calculs d'adresse demandent de simplement incrémenter/décrémenter une adresse, de lui ajouter un indice (et de décaler les indices dans certains cas), mais guère plus. Pas besoin d'effectuer de multiplications, de divisions, ou d'autre opération plus complexe. Des décalages et des additions/soustractions suffisent. L'AGU est donc beaucoup plus simple qu'une ALU normale et se résume souvent à un vulgaire additionneur-soustracteur, éventuellement couplée à un décaleur pour multiplier les indices.
[[File:Unité d'accès mémoire avec unité de calcul dédiée.png|centre|vignette|upright=1.5|Unité d'accès mémoire avec unité de calcul dédiée]]
Le fait d'avoir une unité de calcul séparée pour les adresses peut s'expliquer pour plusieurs raisons. Sur les rares processeurs qui ont des registres séparés pour les adresses, un banc de registre dédié est réservé aux registres d'adresses, ce qui rend l'usage d'une unité de calcul d'adresse très pratique et simplifie grandement le câblage du processeur. P pas besoin de relier deux bancs de registres à une seule ALU, elle-même reliée à la fois au bus d'adresse et de données, chaque banc de registre est relié à sa propre ALU, elle-même reliée à un seul bus.
[[File:Unité d'accès mémoire avec registres d'adresse ou d'indice.png|centre|vignette|upright=2|Unité d'accès mémoire avec registres d'adresse ou d'indice]]
Sur les processeurs à registres généraux, la raison est que cela simplifie un peu l'implémentation des modes d'adressage indirects. C'est particulièrement utile pour les modes d'adressage du style "base + indice + décalage", qui additionnent trois opérandes. Au lieu d'utiliser deux additions séparées, on peut utiliser un simple additionneur trois-opérandes séparé, de type ''carry save'', pour un cout en hardware modéré. Ironiquement, les premiers processeurs Intel supportaient ce mode d'adressage, avaient une unité de calcul d'adresse, mais celle-ci n'utilisait pas d'additionneur ''carry save'', et faisait deux additions pour ces modes d'adressage.
Une autre raison se manifestait sur les processeurs 8 bits : ils géraient des données de 8 bits, mais des adresses de 16 bits. Dans ce cas, le processeur avait une ALU simple de 16 bits pour les adresses, et une ALU complexe de 8 bits pour les données.
===La gestion de l'alignement et du boutisme===
L'unité mémoire gère les accès mémoire non-alignés, à cheval sur deux mots mémoire (rappelez-vous le chapitre sur l'alignement mémoire). Elle détecte les accès mémoire non-alignés et réagit en conséquence. Dans le cas où les accès non-alignés sont interdits, elle lève une exception matérielle. Dans le cas où ils sont autorisés, elle les gère automatiquement, à savoir qu'elle charge deux mots mémoire et les combine entre eux pour donner le résultat final. Dans les deux cas, cela demande d'ajouter des circuits de détection des accès non-alignés, et éventuellement des circuits pour le double lecture/écriture.
Les circuits de détection des accès non-alignés sont très simples. Dans le cas où les adresses sont alignées sur une puissance de deux (cas le plus courant), il suffit de vérifier les bits de poids faible de l'adresse à lire. Prenons l'exemple d'un processeur avec des adresses codées sur 64 bits, avec des mots mémoire de 32 bits, alignés sur 32 bits (4 octets). Un mot mémoire contient 4 octets, les contraintes d'alignement font que les adresses autorisées sont des multiples de 4. En conséquence, les 2 bits de poids faible d'une adresse valide sont censés être à 0. En vérifiant la valeur de ces deux bits, on détecte facilement les accès non-alignés.
En clair, détecter les accès non-alignés demande de tester si les bits de poids faibles adéquats sont à 0. Il suffit donc d'un circuit de comparaison avec zéro; qui est une simple porte OU. Cette porte OU génère un bit qui indique si l'accès testé est aligné ou non : 1 si l'accès est non-aligné, 0 sinon. Le signal peut être transmis au séquenceur pour générer une exception matérielle, ou utilisé dans l'unité d'accès mémoire pour la double lecture/écriture.
La gestion automatique des accès non-alignés est plus complexe. Dans ce cas, l'unité mémoire charge deux mots mémoire et les combine entre eux pour donner le résultat final. Charger deux mots mémoires consécutifs est assez simple, si le registre d'interfaçage est un compteur. L'accès initial charge le premier mot mémoire, puis l'adresse stockée dans le registre d'interfaçage est incrémentée pour démarrer un second accès. Le circuit pour combiner deux mots mémoire contient des registres, des circuits de décalage, des multiplexeurs.
===Le rafraichissement mémoire optimisé et le contrôleur mémoire intégré===
Depuis les années 80, les processeurs sont souvent combinés avec une mémoire principale de type DRAM. De telles mémoires doivent être rafraichies régulièrement pour ne pas perdre de données. Le rafraichissement se fait généralement adresse par adresse, ou ligne par ligne (les lignes sont des super-bytes internes à la DRAM). Le rafraichissement est en théorie géré par le contrôleur mémoire installé sur la carte mère. Mais au tout début de l'informatique, du temps des processeurs 8 bits, le rafraichissement mémoire était géré directement par le processeur.
Si quelques processeurs géraient le rafraichissement mémoire avec des interruptions, d'autres processeurs disposaient d’optimisations pour optimiser le rafraichissement mémoire. Divers processeurs implémentaient de quoi faciliter le rafraichissement par adresse. Par exemple, le processeur Zilog Z80 contenait un compteur de ligne, un registre qui contenait le numéro de la prochaine ligne à rafraichir. Il était incrémenté à chaque rafraichissement mémoire, automatiquement, par le processeur lui-même. Un ''timer'' interne permettait de savoir quand rafraichir la mémoire : quand ce ''timer'' atteignait 0, une commande de rafraichissement était envoyée à la mémoire, et le ''timer'' était ''reset''. Et tout cela était intégré à l'unité d'accès mémoire.
Depuis les années 2000, les processeurs modernes ont un contrôleur mémoire DRAM intégré directement dans le processeur. Ce qui fait qu'ils gèrent non seulement le rafraichissement, mais aussi d'autres fonctions bien pus complexes.
===L'interface de l'unité mémoire===
Vu de l'extérieur, l'unité mémoire ressemble à n'importe quel circuit électronique, avec des entrées et des sorties. L'unité mémoire est généralement multiport, avec un port d'entrée et un port de sortie. Le port d'entrée est là où on envoie l'adresse à lire/écrire, ainsi que la donnée à écrire pour les écritures. Si l'unité mémoire incorpore une AGU, on envoie aussi les indices et autres données sur le port d'entrée. Le port de sortie est utilisé pour récupérer le résultat des lectures. Une unité mémoire est donc reliée au chemin de données via deux entrées et une sortie : une entrée d'adresse, une entrée de données, et une sortie pour les données lues.
L'unité mémoire est connectée au reste du processeur grâce à un réseau d'interconnexion qu'on étudiera plus loin. Les connexions principales sont celles avec les registres : les adresses à lire/écrire sont souvent lues depuis les registres, les lectures copient une donnée dans un registre. Il peut y avoir une connexion avec l'unité de calcul pour les opérations ''load-up'', ou pour le calcul d'adresse, mais c'est secondaire.
[[File:Unité d'accès mémoire LOAD-STORE.png|centre|vignette|upright=2|Unité d'accès mémoire LOAD-STORE.]]
==Le chemin de données et son réseau d'interconnexions==
Nous venons de voir que le chemin de données contient une unité de calcul (parfois plusieurs), des registres isolés, un banc de registre, une unité mémoire. Le tout est chapeauté par une unité de contrôle qui commande le chemin de données, qui fera l'objet des prochains chapitres. Mais il faut maintenant relier registres, ALU et unité mémoire pour que l'ensemble fonctionne. Pour cela, diverses interconnexions internes au processeur se chargent de relier le tout.
Sur les anciens processeurs, les interconnexions sont assez simples et se résument à un ou deux '''bus internes au processeur''', reliés au bus mémoire. C'était la norme sur des architectures assez ancienne, qu'on n'a pas encore vu à ce point du cours, appelées les architectures à accumulateur et à pile. Mais ce n'est plus la solution utilisée actuellement. De nos jours, le réseaux d'interconnexion intra-processeur est un ensemble de connexions point à point entre ALU/registres/unité mémoire. Et paradoxalement, cela rend plus facile de comprendre ce réseau d'interconnexion.
===Introduction propédeutique : l'implémentation des modes d'adressage principaux===
L'organisation interne du processeur dépend fortement des modes d'adressage supportés. Pour simplifier les explications, nous allons séparer les modes d'adressage qui gèrent les pointeurs et les autres. Suivant que le processeur supporte les pointeurs ou non, l'organisation des bus interne est légèrement différente. La différence se voit sur les connexions avec le bus d'adresse et de données.
Tout processeur gère au minimum le '''mode d'adressage absolu''', où l'adresse est intégrée à l'instruction. Le séquenceur extrait l'adresse mémoire de l'instruction, et l'envoie sur le bus d'adresse. Pour cela, le séquenceur est relié au bus d'adresse, le chemin de donnée est relié au bus de données. Le chemin de donnée n'est pas connecté au bus d'adresse, il n'y a pas d'autres connexions.
[[File:Chemin de données sans support des pointeurs.png|centre|vignette|upright=2|Chemin de données sans support des pointeurs]]
Le '''support des pointeurs''' demande d'intégrer des modes d'adressage dédiés : l'adressage indirect à registre, l'adresse base + indice, et les autres. Les pointeurs sont stockés dans le banc de registre et sont modifiés par l'unité de calcul. Pour supporter les pointeurs, le chemin de données est connecté sur le bus d'adresse avec le séquenceur. Suivant le mode d'adressage, le bus d'adresse est relié soit au chemin de données, soit au séquenceur.
[[File:Chemin de données avec support des pointeurs.png|centre|vignette|upright=2|Chemin de données avec support des pointeurs]]
Pour terminer, il faut parler des instructions de '''copie mémoire vers mémoire''', qui copient une donnée d'une adresse mémoire vers une autre. Elles ne se passent pas vraiment dans le chemin de données, mais se passent purement au niveau des registres d’interfaçage. L'usage d'un registre d’interfaçage unique permet d'implémenter ces instructions très facilement. Elle se fait en deux étapes : on copie la donnée dans le registre d’interfaçage, on l'écrit en mémoire RAM. L'adresse envoyée sur le bus d'adresse n'est pas la même lors des deux étapes.
===Le banc de registre est multi-port, pour gérer nativement les opérations dyadiques===
Les architectures RISC et CISC incorporent un banc de registre, qui est connecté aux unités de calcul et au bus mémoire. Et ce banc de registre peut être mono-port ou multiport. S'il a existé d'anciennes architectures utilisant un banc de registre mono-port, elles sont actuellement obsolètes. Nous les aborderons dans un chapitre dédié aux architectures dites canoniques, mais nous pouvons les laisser de côté pour le moment. De nos jours, tous les processeurs utilisent un banc de registre multi-port.
[[File:Chemin de données minimal d'une architecture LOAD-STORE (sans MOV inter-registres).png|centre|vignette|upright=2|Chemin de données minimal d'une architecture LOAD-STORE (sans MOV inter-registres)]]
Le banc de registre multiport est optimisé pour les opérations dyadiques. Il dispose précisément de deux ports de lecture et d'un port d'écriture pour l'écriture. Un port de lecture par opérande et le port d'écriture pour enregistrer le résultat. En clair, le processeur peut lire deux opérandes et écrire un résultat en un seul cycle d'horloge. L'avantage est que les opérations simples ne nécessitent qu'une micro-opération, pas plus.
[[File:ALU data paths.svg|centre|vignette|upright=1.5|Processeur LOAD-STORE avec un banc de registre multiport, avec les trois ports mis en évidence.]]
===Une architecture LOAD-STORE basique, avec adressage absolu===
Voyons maintenant comment l'implémentation d'une architecture RISC très simple, qui ne supporte pas les adressages pour les pointeurs, juste les adressages inhérent (à registres) et absolu (par adresse mémoire). Les instructions LOAD et STORE utilisent l'adressage absolu, géré par le séquenceur, reste à gérer l'échange entre banc de registres et bus de données. Une lecture LOAD relie le bus de données au port d'écriture du banc de registres, alors que l'écriture relie le bus au port de lecture du banc de registre. Pour cela, il faut ajouter des multiplexeurs sur les chemins existants, comme illustré par le schéma ci-dessous.
[[File:Bus interne au processeur sur archi LOAD STORE avec banc de registres multiport.png|centre|vignette|upright=2|Organisation interne d'une architecture LOAD STORE avec banc de registres multiport. Nous n'avons pas représenté les signaux de commandes envoyés par le séquenceur au chemin de données.]]
Ajoutons ensuite les instructions de copie entre registres, souvent appelées instruction COPY ou MOV. Elles existent sur la plupart des architectures LOAD-STORE. Une première solution boucle l'entrée du banc de registres sur son entrée, ce qui ne sert que pour les copies de registres.
[[File:Chemin de données d'une architecture LOAD-STORE.png|centre|vignette|upright=2|Chemin de données d'une architecture LOAD-STORE]]
Mais il existe une seconde solution, qui ne demande pas de modifier le chemin de données. Il est possible de faire passer les copies de données entre registres par l'ALU. Lors de ces copies, l'ALU une opération ''Pass through'', à savoir qu'elle recopie une des opérandes sur sa sortie. Le fait qu'une ALU puisse effectuer une opération ''Pass through'' permet de fortement simplifier le chemin de donnée, dans le sens où cela permet d'économiser des multiplexeurs. Mais nous verrons cela sous peu. D'ailleurs, dans la suite du chapitre, nous allons partir du principe que les copies entre registres passent par l'ALU, afin de simplifier les schémas.
===L'ajout des modes d'adressage indirects à registre pour les pointeurs===
Passons maintenant à l'implémentation des modes d'adressages pour les pointeurs. Avec eux, l'adresse mémoire à lire/écrire n'est pas intégrée dans une instruction, mais est soit dans un registre, soit calculée par l'ALU.
Le premier mode d'adressage de ce type est le mode d'adressage indirect à registre, où l'adresse à lire/écrire est dans un registre. L'implémenter demande donc de connecter la sortie du banc de registres au bus d'adresse. Il suffit d'ajouter un MUX en sortie d'un port de lecture.
[[File:Chemin de données à trois bus.png|centre|vignette|upright=2|Chemin de données à trois bus.]]
Le mode d'adressage base + indice est un mode d'adressage où l'adresse à lire/écrire est calculée à partir d'une adresse et d'un indice, tous deux présents dans un registre. Le calcul de l'adresse implique au minimum une addition et donc l'ALU. Dans ce cas, on doit connecter la sortie de l'unité de calcul au bus d'adresse.
[[File:Bus avec adressage base+index.png|centre|vignette|upright=2|Bus avec adressage base+index]]
Le chemin de données précédent gère aussi le mode d'adressage indirect avec pré-décrément. Pour rappel, ce mode d'adressage est une variante du mode d'adressage indirect, qui utilise une pointeur/adresse stocké dans un registre. La différence est que ce pointeur est décrémenté avant d'être envoyé sur le bus d'adresse. L'implémentation matérielle est la même que pour le mode Base + Indice : l'adresse est lue depuis les registres, décrémentée dans l'ALU, et envoyée sur le bus d'adresse.
Le schéma précédent montre que le bus d'adresse est connecté à un MUX avant l'ALU et un autre MUX après. Mais il est possible de se passer du premier MUX, utilisé pour le mode d'adressage indirect à registre. La condition est que l'ALU supporte l'opération ''pass through'', un NOP, qui recopie une opérande sur sa sortie. L'ALU fera une opération NOP pour le mode d'adressage indirect à registre, un calcul d'adresse pour le mode d'adressage base + indice. Par contre, faire ainsi rendra l'adressage indirect légèrement plus lent, vu que le temps de passage dans l'ALU sera compté.
[[File:Bus avec adressage indirect.png|centre|vignette|upright=2|Bus avec adressages pour les pointeurs, simplifié.]]
Dans ce qui va suivre, nous allons partir du principe que le processeur est implémenté en suivant le schéma précédent, afin d'avoir des schéma plus lisibles.
===L'adressage immédiat et les modes d'adressages exotiques===
Passons maintenant au mode d’adressage immédiat, qui permet de préciser une constante dans une instruction directement. La constante est extraite de l'instruction par le séquenceur, puis insérée au bon endroit dans le chemin de données. Pour les opérations arithmétiques/logiques/branchements, il faut insérer la constante extraite sur l'entrée de l'ALU. Sur certains processeurs, la constante peut être négative et doit alors subir une extension de signe dans un circuit spécialisé.
[[File:Chemin de données - Adressage immédiat avec extension de signe.png|centre|vignette|upright=2|Chemin de données - Adressage immédiat avec extension de signe.]]
L'implémentation précédente gère aussi les modes d'adressage base + décalage et absolu indexé. Pour rappel, le premier ajoute une constante à une adresse prise dans les registres, le second prend une adresse constante et lui ajoute un indice pris dans les registres. Dans les deux cas, on lit un registre, extrait une constante/adresse de l’instruction, additionne les deux dans l'ALU, avant d'envoyer le résultat sur le bus d'adresse. La seule difficulté est de désactiver l'extension de signe pour les adresses.
Le mode d'adressage absolu peut être traité de la même manière, si l'ALU est capable de faire des NOPs. L'adresse est insérée au même endroit que pour le mode d'adressage immédiat, parcours l'unité de calcul inchangée parce que NOP, et termine sur le bus d'adresse.
[[File:Chemin de données avec une ALU capable de faire des NOP.png|centre|vignette|upright=2|Chemin de données avec adressage immédiat étendu pour gérer des adresses.]]
Passons maintenant au cas particulier d'une instruction MOV qui copie une constante dans un registre. Il n'y a rien à faire si l'unité de calcul est capable d'effectuer une opération NOP/''pass through''. Pour charger une constante dans un registre, l'ALU est configurée pour faire un NOP, la constante traverse l'ALU et se retrouve dans les registres. Si l'ALU ne gère pas les NOP, la constante doit être envoyée sur l'entrée d'écriture du banc de registres, à travers un MUX dédié.
[[File:Implémentation de l'adressage immédiat dans le chemin de données.png|centre|vignette|upright=2|Implémentation de l'adressage immédiat dans le chemin de données]]
===Les architectures CISC : les opérations ''load-op''===
Tout ce qu'on a vu précédemment porte sur les processeurs de type LOAD-STORE, souvent confondus avec les processeurs de type RISC, où les accès mémoire sont séparés des instructions utilisant l'ALU. Il est maintenant temps de voir les processeurs CISC, qui gèrent des instructions ''load-op'', qui peuvent lire une opérande depuis la mémoire.
L'implémentation des opérations ''load-op'' relie le bus de donnée directement sur une entrée de l'unité de calcul, en utilisant encore une fois un multiplexeur. L'implémentation parait simple, mais c'est parce que toute la complexité est déportée dans le séquenceur. C'est lui qui se charge de détecter quand la lecture de l'opérande est terminée, quand l'opérande est disponible.
Les instructions ''load-op'' s'exécutent en plusieurs étapes, en plusieurs micro-opérations. Il y a typiquement une étape pour l'opérande à lire en mémoire et une étape de calcul. L'usage d'un registre d’interfaçage permet d'implémenter les instructions ''load-op'' très facilement. Une opération ''load-op'' charge l'opérande en mémoire dans un registre d’interfaçage, puis relier ce registre d’interfaçage sur une des entrées de l'ALU. Un simple multiplexeur suffit pour implémenter le tout, en plus des modifications adéquates du séquenceur.
[[File:Chemin de données d'un CPU CISC avec lecture des opérandes en mémoire.png|centre|vignette|upright=2|Chemin de données d'un CPU CISC avec lecture des opérandes en mémoire]]
Supporter les instructions multi-accès (qui font plusieurs accès mémoire) ne modifie pas fondamentalement le réseau d'interconnexion, ni le chemin de données La raison est que supporter les instructions multi-accès se fait au niveau du séquenceur. En réalité, les accès mémoire se font en série, l'un après l'autre, sous la commande du séquenceur qui émet plusieurs micro-opérations mémoire consécutives. Les données lues sont placées dans des registres d’interactivement mémoire, ce qui demande d'ajouter des registres d’interfaçage mémoire en plus.
==Annexe : le cas particulier du pointeur de pile==
Le pointeur de pile est un registre un peu particulier. Il peut être placé dans le chemin de données ou dans le séquenceur, voire dans l'unité de chargement, tout dépend du processeur. Tout dépend de si le pointeur de pile gère une pile d'adresses de retour ou une pile d'appel.
===Le pointeur de pile non-adressable explicitement===
Avec une pile d'adresse de retour, le pointeur de pile n'est pas adressable explicitement, il est juste adressé implicitement par des instructions d'appel de fonction CALL et des instructions de retour de fonction RET. Le pointeur de pile est alors juste incrémenté ou décrémenté par un pas constant, il ne subit pas d'autres opérations, son adressage est implicite. Il est juste incrémenté/décrémenté par pas constants, qui sont fournis par le séquenceur. Il n'y a pas besoin de le relier au chemin de données, vu qu'il n'échange pas de données avec les autres registres. Il y a alors plusieurs solutions, mais la plus simple est de placer le pointeur de pile dans le séquenceur et de l'incrémenter par un incrémenteur dédié.
Quelques processeurs simples disposent d'une pile d'appel très limitée, où le pointeur de pile n'est pas adressable explicitement. Il est adressé implicitement par les instruction CALL, RET, mais aussi PUSH et POP, mais aucune autre instruction ne permet cela. Là encore, le pointeur de pile ne communique pas avec les autres registres. Il est juste incrémenté/décrémenté par pas constants, qui sont fournis par le séquenceur. Là encore, le plus simple est de placer le pointeur de pile dans le séquenceur et de l'incrémenter par un incrémenteur dédié.
Dans les deux cas, le pointeur de pile est placé dans l'unité de contrôle, le séquenceur, et est associé à un incrémenteur dédié. Il se trouve que cet incrémenteur est souvent partagé avec le ''program counter''. En effet, les deux sont des adresses mémoire, qui sont incrémentées et décrémentées par pas constants, ne subissent pas d'autres opérations (si ce n'est des branchements, mais passons). Les ressemblances sont suffisantes pour fusionner les deux circuits. Ils peuvent donc avoir un '''incrémenteur partagé'''.
L'incrémenteur en question est donc partagé entre pointeur de pile, ''program counter'' et quelques autres registres similaires. Par exemple, le Z80 intégrait un registre pour le rafraichissement mémoire, qui était réalisé par le CPU à l'époque. Ce registre contenait la prochaine adresse mémoire à rafraichir, et était incrémenté à chaque rafraichissement d'une adresse. Et il était lui aussi intégré au séquenceur et incrémenté par l'incrémenteur partagé.
[[File:Organisation interne d'une architecture à pile.png|centre|vignette|upright=2|Organisation interne d'une architecture à pile]]
===Le pointeur de pile adressable explicitement===
Maintenant, étudions le cas d'une pile d'appel, précisément d'une pile d'appel avec des cadres de pile de taille variable. Sous ces conditions, le pointeur de pile est un registre adressable, avec un nom/numéro de registre dédié. Tel est par exemple le cas des processeurs x86 avec le registre ESP (''Extended Stack Pointer''). Il est manipulé par les instructions CALL, RET, PUSH et POP, mais aussi par les instructions d'addition/soustraction pour gérer des cadres de pile de taille variable. De plus, il peut servir d'opérande pour des calculs d'adresse, afin de lire/écrire des variables locales, les arguments d'une fonction, et autres.
Dans ce cas, la meilleure solution est de placer le pointeur de pile dans le banc de registre généraux, avec les autres registres entiers. En faisant cela, la manipulation du pointeur de pile est faite par l'unité de calcul entière, pas besoin d'utiliser un incrémenteur dédiée. Il a existé des processeurs qui mettaient le pointeur de pile dans le banc de registre, mais l'incrémentaient avec un incrémenteur dédié, mais nous les verrons dans le chapitre sur les architectures à accumulateur. La raison est que sur les processeurs concernés, les adresses ne faisaient pas la même taille que les données : c'était des processeurs 8 bits, qui géraient des adresses de 16 bits.
==Annexe : l'implémentation du système d'''aliasing'' des registres des CPU x86==
Il y a quelques chapitres, nous avions parlé du système d'''aliasing'' des registres des CPU x86. Pour rappel, il permet de donner plusieurs noms de registre pour un même registre. Plus précisément, pour un registre 64 bits, le registre complet aura un nom de registre, les 32 bits de poids faible auront leur nom de registre dédié, idem pour les 16 bits de poids faible, etc. Il est possible de faire des calculs sur ces moitiés/quarts/huitièmes de registres sans problème.
===L'''aliasing'' du 8086, pour les registres 16 bits===
[[File:Register 8086.PNG|vignette|Register 8086]]
L'implémentation de l'''aliasing'' est apparue sur les premiers CPU Intel 16 bits, notamment le 8086. En tout, ils avaient quatre registres généraux 16 bits : AX, BX, CX et DX. Ces quatre registres 16 bits étaient coupés en deux octets, chacun adressable. Par exemple, le registre AX était coupé en deux octets nommés AH et AL, chacun ayant son propre nom/numéro de registre. Les instructions d'addition/soustraction pouvaient manipuler le registre AL, ou le registre AH, ce qui modifiait les 8 bits de poids faible ou fort selon le registre choisit.
Le banc de registre ne gére que 4 registres de 16 bits, à savoir AX, BX, CX et DX. Lors d'une lecture d'un registre 8 bits, le registre 16 bit entier est lu depuis le banc de registre, mais les bits inutiles sont ignorés. Par contre, l'écriture peut se faire soit avec 16 bits d'un coup, soit pour seulement un octet. Le port d'écriture du banc de registre peut être configuré de manière à autoriser l'écriture soit sur les 16 bits du registre, soit seulement sur les 8 bits de poids faible, soit écrire dans les 8 bits de poids fort.
[[File:Port d'écriture du banc de registre du 8086.png|centre|vignette|upright=2.5|Port d'écriture du banc de registre du 8086]]
Une opération sur un registre 8 bits se passe comme suit. Premièrement, on lit le registre 16 bits complet depuis le banc de registre. Si l'on a sélectionné l'octet de poids faible, il ne se passe rien de particulier, l'opérande 16 bits est envoyée directement à l'ALU. Mais si on a sélectionné l'octet de poids fort, la valeur lue est décalée de 7 rangs pour atterrir dans les 8 octets de poids faible. Ensuite, l'unité de calcul fait un calcul avec cet opérande, un calcul 16 bits tout ce qu'il y a de plus classique. Troisièmement, le résultat est enregistré dans le banc de registre, en le configurant convenablement. La configuration précise s'il faut enregistrer le résultat dans un registre 16 bits, soit seulement dans l'octet de poids faible/fort.
Afin de simplifier le câblage, les 16 bits des registres AX/BX/CX/DX sont entrelacés d'une manière un peu particulière. Intuitivement, on s'attend à ce que les bits soient physiquement dans le même ordre que dans le registre : le bit 0 est placé à côté du bit 1, suivi par le bit 2, etc. Mais à la place, l'octet de poids fort et de poids faible sont mélangés. Deux bits consécutifs appartiennent à deux octets différents. Le tout est décrit dans le tableau ci-dessous.
{|class="wikitable"
|-
! Registre 16 bits normal
| class="f_bleu" | 15
| class="f_bleu" | 14
| class="f_bleu" | 13
| class="f_bleu" | 12
| class="f_bleu" | 11
| class="f_bleu" | 10
| class="f_bleu" | 9
| class="f_bleu" | 8
| class="f_rouge" | 7
| class="f_rouge" | 6
| class="f_rouge" | 5
| class="f_rouge" | 4
| class="f_rouge" | 3
| class="f_rouge" | 2
| class="f_rouge" | 1
| class="f_rouge" | 0
|-
! Registre 16 bits du 8086
| class="f_bleu" | 15
| class="f_rouge" | 7
| class="f_bleu" | 14
| class="f_rouge" | 6
| class="f_bleu" | 13
| class="f_rouge" | 5
| class="f_bleu" | 12
| class="f_rouge" | 4
| class="f_bleu" | 11
| class="f_rouge" | 3
| class="f_bleu" | 10
| class="f_rouge" | 2
| class="f_bleu" | 9
| class="f_rouge" | 1
| class="f_bleu" | 8
| class="f_rouge" | 0
|}
En faisant cela, le décaleur en entrée de l'ALU est bien plus simple. Il y a 8 multiplexeurs, mais le câblage est bien plus simple. Par contre, en sortie de l'ALU, il faut remettre les bits du résultat dans l'ordre adéquat, celui du registre 8086. Pour cela, les interconnexions sur le port d'écriture sont conçues pour. Il faut juste mettre les fils de sortie de l'ALU sur la bonne entrée, par besoin de multiplexeurs.
===L'''aliasing'' sur les processeurs x86 32/64 bits===
Les processeurs x86 32 et 64 bits ont un système d'''aliasing'' qui complète le système précédent. Les processeurs 32 bits étendent les registres 16 bits existants à 32 bits. Pour ce faire, le registre 32 bit a un nouveau nom de registre, distincts du nom de registre utilisé pour l'ancien registre 16 bits. Il est possible d'adresser les 16 bits de poids faible de ce registre, avec le même nom de registre que celui utilisé pour le registre 16 sur les processeurs d'avant. Même chose avec les processeurs 64, avec l'ajout d'un nouveau nom de registre pour adresser un registre de 64 bit complet.
En soit, implémenter ce système n'est pas compliqué. Prenons le cas du registre RAX (64 bits), et de ses subdivisions nommées EAX (32 bits), AX (16 bits). À l'intérieur du banc de registre, il n'y a que le registre RAX. Le banc de registre ne comprend qu'un seul nom de registre : RAX. Les subdivisions EAX et AX n'existent qu'au niveau de l'écriture dans le banc de registre. L'écriture dans le banc de registre est configurable, de manière à ne modifier que les bits adéquats. Le résultat d'un calcul de l'ALU fait 64 bits, il est envoyé sur le port d'écriture. À ce niveau, soit les 64 bits sont écrits dans le registre, soit seulement les 32/16 bits de poids faible. Le système du 8086 est préservé pour les écritures dans les 16 bits de poids faible.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les composants d'un processeur
| prevText=Les composants d'un processeur
| next=L'unité de chargement et le program counter
| nextText=L'unité de chargement et le program counter
}}
</noinclude>
swmyi867vj4s6jeii4pqtf7q6svniz1
Fonctionnement d'un ordinateur/Les circuits de décalage et de rotation
0
75477
763713
759868
2026-04-15T09:59:52Z
Mewtow
31375
/* Le barrel shifter de l'Intel 386 */
763713
wikitext
text/x-wiki
Dans ce chapitre, nous allons voir des opérations appelées les décalages et les rotations. Nous allons voir ce que sont ces opérations, puis les nombreux circuits qui permettent d'implémenter ces opérations. Mais expliquons d'abord les différentes opérations de décalage et de rotation.
==Les opérations de décalage==
Les ''décalages'' décalent un nombre de un ou plusieurs rangs vers la gauche, ou la droite. Il existe plusieurs opérations de décalage différentes et on peut les classer en plusieurs types. Dans les grandes lignes, on distingue les rotations, les décalages logiques et les décalages arithmétiques. Elles se distinguent sur plusieurs points, les principaux étant les suivants :
* ce qu'on fait des bits qui sortent du nombre lors du décalage ;
* comment on remplit les vides qui apparaissent lors du décalage ;
* la manière dont est géré le signe du nombre décalé.
[[File:Décalages, gestion des bits entrants et sortants.png|vignette|Décalages, gestion des bits entrants et sortants]]
Pour comprendre les deux premiers points, prenons l'exemple ci-contre. L'exemple montre le décalage de deux rangs vers la droite, d'un opérande de 8 bits valant 01011101. On obtient 010111 : les deux bits de poids forts sont vides et les deux bits de poids faible (01) sortent du nombre. Et cela vaut pour tout décalage : d'un côté le décalage fait sortir des bits du nombre, de l'autre certains bits sont inconnus ce qui laisse des vides dans le nombre. Si on décale de n rangs, alors cela laissera n vides et fera sortir n bits. Ces deux points, la gestion des vides et des bits sortants, sont assez liés.
===Le différents types de décalages===
Au-delà de la distinction assez intuitive entre les décalages vers la gauche et vers la droite, parlons de ce qu'on fait des bits qui sortent du nombre lors du décalage. Que fait-on de ces bits ?
La première solution est de les faire rentrer de l'autre côté, de les remettre au début du nombre décalé. L'opération en question est alors appelée une '''rotation'''. Il existe des rotations à droite et à gauche.
{|
|MSB : bit de poids fort
(Most Significant Bit)
LSB : bit de poids faible
(Least Significant Bit)
|
|[[File:Rotate left.svg|vignette|Rotation à gauche.]]
|[[File:Rotate right.svg|vignette|Rotation à droite.]]
|}
L'autre solution est d'oublier les bits sortants. L’opération est alors appelée un '''décalage''', qui peut être soit un décalage logique, soit un décalage arithmétique. Le fait que l'on oublie les bits sortants fait que les vides ne sont pas remplis et qu'il faut trouver de quoi les combler. Et c'est là qu'on peut faire la distinction entre décalages logiques et arithmétiques.
Avec un '''décalage logique''', les vides sont remplis par des zéros, aussi bien pour un décalage à gauche et un décalage à droite.
{|
|[[File:Rotate left logically.svg|vignette|Décalage logique à gauche.]]
|[[File:Rotate right logically.svg|vignette|Décalage logique à droite.]]
|}
[[File:Shift Arithmetic Right.svg|vignette|Décalage arithmétique à droite.]]
Avec un '''décalage arithmétique''', la situation est différente pour un décalage à gauche et à droite. Le principe des décalages arithmétique est qu'ils conservent le bit de signe de l'opérande décalé (qui est supposé être signé), contrairement aux autres décalages. Pour un décalage à droite, les vides dans les vides de poids forts sont remplis par le bit de signe. Ce remplissage est une sorte d'extension de signe, ce qui fait que la conservation du signe est automatique.
[[File:Shift Left and Shift Arithmetic Left.svg|vignette|Décalage arithmétique à gauche qui ne conserve pas le bit de signe.]]
Pour un décalage à gauche, les vides sont remplis par des zéros, comme pour un décalage logique. Mais pour ce qui est de la conservation du bit de signe, c'est plus compliqué. On a deux écoles : la première ne conserve pas le bit de signe, la seconde le fait. Dans le premier cas, le décalage est identique à un décalage logique à gauche. Dans le second cas, le bit de signe n'est pas concerné par le décalage et il se produit une forme particulière de débordement d'entier.
L'utilité principale des opérations de décalage est qu'elles permettent de faire simplement des multiplications ou divisions par une puissance de 2. Un décalage logique/arithmétique correspond à une multiplication ou division entière par 2^n : multiplication pour les décalages à gauche, division pour les décalages à droite. Les décalages logiques fonctionnent seulement pour les entiers non signés, alors que les décalages arithmétiques fonctionnent sur les entiers signés. Le fait est qu'un décalage logique ne préserve pas le bit de signe.
[[File:Modulo et quotient d'une division par une puissance de deux en binaire.png|centre|vignette|upright=2.5|Modulo et quotient d'une division par une puissance de deux en binaire]]
===Les arrondis lors des décalages===
Les décalages à droite entraînent l'apparition d{{'}}''arrondis''. Lorsqu'on effectue un décalage à droite, les bits qui sortent du résultat sont perdus. L’équivalent en décimal est que les chiffres après la virgule sont perdus, ce qui arrondit le résultat. Mais cet arrondi dépend de la représentation des nombres utilisé. Pour comprendre pourquoi, il faut faire un rapide rappel sur les types d'arrondis en décimal.
En décimal, on peut arrondir de deux manières : soit on arrondit à l'entier au-dessus, soit on arrondi à l'entier au-dessous. Par exemple, prenons la division 29/4, qui a pour résultat 7.25. Cela donne 7 dans le premier cas et 8 dans le second. Pour un résultat négatif, c'est la même chose, mais le fait que le signe soit inversé change la donne. Par exemple, prenons le résultat de -29 / 4, soit -7.25. On peut l'arrondir soit à -7, soit à -8. En combinant les deux cas négatifs avec les deux cas positifs, on se trouve face à quatre possibilités :
* l'arrondi vers la plus basse valeur absolue (vers zéro), qui donne respectivement 7 et -7 dans l'exemple précédent.
* l'arrondi vers la plus basse valeur (vers moins l'infini), qui donne -8 et 7 dans l'exemple précédent ;
* l'arrondi vers la plus haute valeur (vers plus l'infini), qui donne -7 et 8 dans l'exemple précédent ;
* l'arrondi vers la plus haute valeur absolue (vers l'infini), qui donne 8 et -8 dans l'exemple précédent.
En binaire, c'est la même chose. Par exemple, 11100,1010 peut s'arrondir en 11100 ou en 11101, suivant qu'on arrondisse vers le bas ou vers le haut, et la même chose est possible pour les nombres négatifs. Vu que les bits sortants sont simplement éliminés, on pourrait croire que cela correspond à un arrondi vers zéro (vers la valeur inférieure). C'est bien le cas pour les décalages logiques, peu importe la représentation, l'arrondi se fait vers zéro (vu que tous les nombres sont traités comme positifs). Mais pour les décalages arithmétiques, tout dépend de la représentation binaire utilisée. L'arrondi se fait bien vers zéro en complément à 1, mais pas en complément à deux, où l'arrondi se fait à la valeur inférieure, vers moins l'infini.
Précisons que ces arrondis n'ont lieu que si le résultat du décalage n'est pas exact. Pour un décalage d'un rang, à savoir une division par deux, seuls les nombres impairs donnent un arrondi, pas les nombres pairs. De manière générale, pour un décalage de n rangs, les nombres divisibles par 2^n ne donnent pas d'arrondi, alors que les autres si.
===Les débordements d'entiers lors des décalages===
Les décalages peuvent aussi causer des ''débordements d'entier''. Pour rappel un débordement d'entier est une situation où le résultat d'un calcul devient trop gros pour être codé. Pour donner un exemple, prenons une situation équivalente mais en décimal. Par exemple supposons que l'ordinateur sur lequel vous travailler manipule des données codées sur 5 chiffres décimaux, pas plus. Si on prend le nombre 4512, le décalage à gauche d'un cran donne 45120, qui tient sur 5 chiffres : on n'a pas de débordement. Mais si je prends le nombre 97426, un décalage à gauche d'un cran donne 974260, ce qui ne tient pas dans 5 chiffres : on a un débordement d’entier. Celui-ci se traduit par le fait qu'un chiffre non-nul sorte du nombre. La même chose a lieu en binaire, avec les décalages à gauche : si au moins un bit non-nul sorte à gauche, c'est un débordement d'entier.
La manière habituelle de gérer les débordements d'entiers est simplement de ne rien faire, mais de prévenir qu'un débordement a eu lieu. Pour cela, le circuit qui effectue le décalage a une sortie qui indique qu'un débordement a eu lieu lors du décalage. Cette sortie fournit un simple bit qui vaut 1 en cas de débordement et 0 sinon (ou l'inverse). Une autre solution est de corriger le débordement, mais elle est utilisée uniquement pour les opérations arithmétiques, pas pour les décalages.
Toujours est-il que déterminer l’occurrence d'un débordement n'est pas compliqué. Pour les décalages logiques, il suffit de prendre les bits sortants et de vérifier qu'un au moins d'entre eux vaut 1. Une simple porte OU sur les bits sortants fait l'affaire. Pour les décalages arithmétiques, il faut aussi tenir compte de la présence du bit de signe. Si le nombre décalé est positif, seuls des zéros doivent sortir, la présence d'un 1 indiquant un débordement d'entier. Pour un nombre négatif, c'est l'inverse : seuls des 1 doivent sortir (du fait des règles d'extension de signe), alors que l’occurrence d'un zéro trahit un débordement d'entier. Pour résumer le tout, les bits sortants sont censés être égaux au bit de signe, un débordement a eu lieu dans le cas contraire. L’occurrence d'un débordement se détermine en décomposant le décalage en une succession de décalages de 1 bit. Si un seul de ces décalages de 1 rang altère le bit de signe (change sa valeur), alors on a un débordement.
Il est possible de déterminer l’occurrence d'un débordement en analysant l'opérande, sans même avoir à faire le décalage. Pour un décalage vers la gauche de <math>n</math> rangs, on sait que les bits sortants sont les <math>n</math> bits de poids fort de l'opérande. En clair, on peut déterminer si un débordement a lieu en sélectionnant seulement les <math>n</math> bits de poids fort de l'opérande. Pour cela, on peut simplement prendre l'opérande et lui appliquer un masque adéquat. Par exemple, prenons le cas d'un débordement pour un décalage logique, qui a lieu si au moins un bit sortant est à 1. Il suffit de prendre l'opérande, conserver les <math>n</math> rangs bits de poids fort et mettre les autres à zéro, puis faire un ET entre les bits du résultat. La même logique prévaut pour les décalages arithmétiques, même s'il faut faire quelques adaptations.
[[File:Calcul du bit de débordement pour un décalage à gauche de trois rangs.png|centre|vignette|upright=2|Calcul du bit de débordement pour un décalage à gauche de trois rangs.]]
Toujours est-il que le calcul des débordements peut se faire en parallèle du décalage, ce qui est utile. Précisons que le masque se calcule dans un circuit à part, qui ressemble beaucoup à un encodeur. Le masque calculé peut être utilisé sur certains circuits de décalages, pour transformer des rotations en décalage logiques, par exemple. Mais nous verrons cela plus tard.
==Les décaleurs et rotateurs élémentaires==
[[File:Décaleur - interface.png|vignette|Décaleur - interface]]
Pour commencer, nous allons voir deux types de circuits : les '''décaleurs''' qui effectuent un décalage (logique ou arithmétique, peu importe) et les '''rotateurs''' qui effectuent une rotation. Les deux circuits sont conceptuellement séparés, même s’ils se ressemblent. Faire la distinction sera utile dans la suite du cours. Leur interface est la même pour tous les décaleurs et rotateurs élémentaires. On doit fournir l'opérande à décaler et le nombre de rangs qu'on veut décaler en entrée, et on récupère l'opérande décalé en sortie.
Nous allons d'abord voir comment créer un décaleur. Pour cela, on peut faire une remarque simple : décaler de 6 rangs, c'est équivalent à décaler de 4 rangs et redécaler le tout de 2 rangs. Même chose pour 7 rangs : cela consiste à décaler de 4 rangs, redécaler de 2 rangs et enfin redécaler d'un rang. En suivant l'idée jusqu'au bout, on peut créer un décaleur à partir de décaleurs plus simples, reliés en cascade, qu'on active ou désactive suivant le nombre de rangs. Les décaleurs élémentaires décalent par 1, 2, 4, 8, etc ; bref : par une puissance de 2. La raison à cela est que le nombre de rangs par lequel on va devoir décaler est un nombre codé en binaire, qui s'écrit donc sous la forme d'une somme de puissances de deux. Le énième bit du nombre de rang servira à actionner le décaleur par 2^n.
[[File:Décaleur logique - principe.png|centre|vignette|upright=2|Décaleur logique - principe]]
La même logique s'applique pour les rotateurs, la seule différence étant qu'il faut remplacer les décaleurs par 1, 2, 4, 8, etc ; par des rotateurs par 1, 2, 4, 8, etc. Reste à savoir comment créer ces décaleurs qu'on peut activer ou désactiver à la demande. Surtout que le circuit n'est pas le même selon que l'on parle d'un décalage logique, d'un décalage arithmétique ou d'une rotation. Néanmoins, tous les circuits de décalage/rotation sont fabriqués avec des multiplexeurs à deux entrées et une sortie.
===Le circuit décaleur logique===
Commençons par étudier le cas du décalage logique par 4 rangs à droite. La sortie vaudra soit le nombre tel qu'il est passé en entrée (le décaleur est inactif), soit le nombre décalé de 4 rangs. Ainsi, si je prends un nombre A, composé des bits a7, a6, a5, a4, a3, a2, a1, a0 ; (cités dans l'ordre), le résultat sera :
* soit le nombre composé des chiffres a7, a6, a5, a4, a3, a2, a1, a0 (on n'effectue pas de décalage) ;
* soit le nombre composé des chiffres 0, 0, 0, 0, a7, a6, a5, a4 (on effectue un décalage par 4).
Chaque bit de sortie peut prendre deux valeurs, qui valent soit zéro, soit un bit du nombre d'entrée. On peut donc utiliser un multiplexeur pour choisir quel bit envoyer sur la sortie. Par exemple, pour le choix du bit de poids fort du résultat, celui-ci vaut soit a7, soit 0 : il suffit d’utiliser un multiplexeur prenant le bit a7 sur son entrée 1, et un 0 sur son entrée 0.
[[File:Décaleur par 4.png|centre|vignette|upright=2|Exemple d'un décaleur par 4.]]
Le tout peut être adapté pour créer des décaleurs par 1, par 2, par 8, etc. Il suffit de faire la même chose pour tous les autres bits, et le tour est joué. En utilisant des décaleurs basiques par 4, 2 et 1 bit, on obtient le circuit suivant :
[[File:Décaleur logique 8 bits.png|centre|vignette|upright=2|Décaleur logique 8 bits.]]
===Le circuit décaleur arithmétique===
Les décalages arithmétiques sont basés sur le même principe, à une différence près : on n'envoie pas un zéro dans les bits de poids fort, mais le bit de signe (le bit de poids fort du nombre d'entrée). Un décaleur arithmétique ressemble beaucoup à un décaleur logique, la seule différence étant que c'est le bit de poids fort qui est relié aux entrées des multiplexeurs, là où c'était le zéro avec le décaleur logique. Par exemple, reprenons un nombre A, composé des bits a7, a6, a5, a4, a3, a2, a1, a0 ; (cités dans l'ordre). La sortie d'un décaleur arithmétique par 4 sera :
* soit le nombre composé des chiffres a7, a6, a5, a4, a3, a2, a1, a0 (on n'effectue pas de décalage) ;
* soit le nombre composé des chiffres a7, a7, a7, a7, a7, a6, a5, a4 (on effectue un décalage arithmétique par 4).
[[File:Décaleur arithmétique par 4.png|centre|vignette|upright=2|Exemple d'un décaleur arithmétique par 4]]
En combinant des décaleurs basiques par 4, 2 et 1 bits, on obtient le circuit suivant :
[[File:Décaleur arithmétique 8 bits.png|centre|vignette|upright=2|Décaleur arithmétique 8 bits]]
===Le circuit rotateur===
Les rotations sont elles aussi basées sur le même principe, sauf que ce sont les bits de poids faible qu'on injecte dans les bits de poids forts, au lieu d'un zéro ou du bit de signe. Le circuit est donc le même, sauf que les connexions ne sont pas identiques. Là où il y avait un zéro sur les entrées des multiplexeurs, on doit envoyer le bon bit de poids faible. Par exemple, reprenons un nombre A, composé des bits a7, a6, a5, a4, a3, a2, a1, a0 ; (cités dans l'ordre). La sortie d'un rotateur arithmétique par 4 sera :
* soit le nombre composé des chiffres a7, a6, a5, a4, a3, a2, a1, a0 (on n'effectue pas de décalage) ;
* soit le nombre composé des chiffres a3, a2, a1, a0, a7, a6, a5, a4 (on effectue un décalage arithmétique par 4).
==Les ''barell shifters'' unidirectionnels==
[[File:Barrel shifter - interface.png|vignette|Barrel shifter - interface]]
Dans ce qui précède, on a appris à créer un circuit qui fait des décalages logiques, un autre pour les décalages arithmétiques et un autre pour les rotations. Il nous reste à voir les '''décaleurs-rotateurs''', aussi appelés des '''''barrel shifters''''', qui sont capables de faire à la fois des décalages et des rotations. Certains décaleur-rotateurs sont capables de faire des rotations et des décalages logiques, d'autres savent aussi réaliser les décalages arithmétiques en plus. Un tel circuit a la même interface qu'un décaleur, sauf qu'on rajoute une entrée qui précise quelle opération faire. Cette entrée indique s'il faut faire un décalage logique, un décalage arithmétique ou une rotation.
Précisons dès maintenant qu'il faut faire la différence entre un ''barrel shifter'' unidirectionnel et un ''barrel shifter'' bidirectionnel. La différence entre les deux tient dans le sens possible des décalages. Le ''barrel shifter'' unidirectionnel ne peut faire que des décalages à gauche ou que des décalages à droite, mais pas les deux. À l'inverse, un ''barrel shifter'' bidirectionnel peut faire des décalages à droite et à gauche, suivant ce qu'on lui demande. Dans cette section, nous allons nous concentrer sur les ''barrel shifters'' unidirectionnels, qui font des décalages/rotations vers la droite. Les explications seront valides aussi pour des décalages/rotations à gauche, avec quelques petites modifications triviales.
Il existe trois grandes méthodes pour fabriquer un décaleur-rotateur.
* La manière la plus naïve est de prendre un décaleur logique, un décaleur arithmétique et un rotateur, et de prendre le résultat adéquat suivant l’opération voulue. Le choix du bon résultat est effectué par une couche de multiplexeur adaptée. Mais cette solution est inutilement gourmande en multiplexeurs. Après tout, les trois circuits se ressemblent et partagent une même structure.
* Une autre solution, bien plus économe en multiplexeurs, élimine ces redondances en fusionnant les trois circuits en un seul. Elle part d'un circuit qui effectue des décalages logiques, auquel on ajoute des multiplexeurs pour le rendre capable de faire aussi les décalages arithmétiques et les rotations.
* La dernière méthode part d'un rotateur et on lui ajoute de quoi faire des décalages logiques.
===Le décaleur-rotateur à base de multiplexeurs===
Avec la seconde méthode, on part d'un circuit qui effectue des décalages logiques, auquel on ajoute des multiplexeurs pour le rendre capable de faire aussi les décalages arithmétiques et les rotations. Ces nouveaux multiplexeurs ne font que choisir les bits à envoyer sur les entrées des décaleurs. Par exemple, prenons un décalage/rotation par 4 crans. La seule différence entre décalage logique, arithmétique et rotation est ce qu'on met sur les 4 bits de poids fort : un 0 pour un décalage logique, le bit de poids fort pour un décalage arithmétique et les 4 bits de poids faible pour une rotation. Pour choisir entre ces trois valeurs, il suffit de rajouter des multiplexeurs.
Nous allons d'abord ajouter des multiplexeurs pour prendre en charge les rotations, un peu de la même manière qu'on modifie un décaleur logique pour lui faire faire aussi des décalages arithmétiques. Pour cela, prenons un décaleur par 4 et étudions les 4 bits de poids fort. Suivant le type de décalage, on doit envoyer soit un zéro, soit le bit de poids faible adéquat sur certaines entrées. Ce choix peut être réalisé par un multiplexeur, tant qu'il est commandé correctement. En clair, il suffit d'ajouter un ou plusieurs multiplexeurs pour chaque décaleur élémentaire par 1, 2, 4, etc. Ces multiplexeurs choisissent quoi envoyer sur l'entrée de l'ancienne couche : soit un 0 (décalage logique), soit le bit de poids faible (rotation). Notons qu'on doit utiliser un multiplexeur par entrée, contrairement au décaleur complet. La raison est qu'un décalage arithmétique envoie toujours le même bit dans les entrées de poids fort, alors qu'une rotation envoie un bit différent sur chaque entrée de poids fort, ce qui demande un multiplexeur par entrée.
[[File:Décaleur-rotateur par 4.png|centre|vignette|upright=2|Décaleur-rotateur par 4.]]
Il est possible d'étendre le décaleur logique pour lui permettre de faire des décalages arithmétiques. Pour cela, même recette que dans le cas précédent. Encore une fois, suivant le type de décalage, on doit envoyer soit un zéro, soit le bit de poids fort sur certaines entrées. Il est possible d'utiliser un seul multiplexeur dans ce cas précis, car on envoie le même bit sur les entrées de poids fort.
[[File:Exemple avec un décaleur par 4.png|centre|vignette|upright=2|Exemple avec un décaleur par 4.]]
En combinant des décaleurs basiques par 4, 2 et 1 bits, on obtient un circuit qui fait tous les types de décalages. Pas étonnant que ce circuit soit nommé un '''décaleur complet'''. Notons qu'on peut se contenter d'un seul mutiplexeur pour tout le ''barrel shifter'', en utilisant le câblage astucieusement. Après tout, le choix entre 0 ou bit de poids fort est le même pour toutes les entrées concernées. Autant ne le faire qu'une seule fois et connecter toutes les entrées concernées au multiplexeur.
[[File:Décaleur complet 8 bits.png|centre|vignette|upright=2|Décaleur complet 8 bits]]
En utilisant les deux modifications en même temps, on se retrouve avec un ''barrel-shifter'' complet, capable de faire des décalages et rotations sur 4 bits.
[[File:Circuit de rotation partiel.png|centre|vignette|upright=2|Circuit de rotation partiel.]]
===Les ''mask barrel shifters''===
Les ''mask barrel shifters'' sont des décaleurs-rotateurs basés autour d'un rotateur, qui est modifié afin de supporter les décalages logiques/arithmétiques. L'idée est de faire une rotation et de corriger le résultat si c'est un décalage qui est demandé. La correction à effectuer dépend du type de décalage demandé, suivant qu'il soit logique ou arithmétique.
Pour un décalage logique, il suffit de mettre les n bits de poids fort à zéro pour un décalage de n bits vers la droite (inversement, les n bits de poids faible pour un décalage vers la gauche). Et pour mettre des bits de poids fort à zéro sous une certaine condition, on doit utiliser un masque qui est calculé par un circuit dédié. Le circuit de calcul du masque est un encodeur modifié, qu'on peut concevoir avec les techniques des chapitres précédents.
Le circuit qui combine le masque avec le résultat de la rotation est composé d'une couche de portes ET et d'une couche de multiplexeurs. La couche de portes ET applique le masque sur le résultat du rotateur. Les multiplexeurs choisissent entre le résultat du rotateur et le résultat avec masque appliqué. Les multiplexeurs sont commandés par un bit de commande qui indique s'il faut faire un décalage ou une rotation.
[[File:Décaleur-rotateur basé sur un masque.png|centre|vignette|upright=1.5|Décaleur-rotateur basé sur un masque.]]
==Les ''barrel shifters'' bidirectionnels (à double sens de décalage/rotation)==
Le circuit précédent est capable d'effectuer des décalages et rotations, mais seulement vers la droite. On peut évidemment concevoir un circuit similaire capable de faire des décalages/rotations vers la gauche, mais il est intéressant d'essayer de créer un circuit capable de faire les deux. Un tel circuit est appelé un '''''barrel shifter'' bidirectionnel'''. Notons qu'on doit obligatoirement fournir un bit qui indique dans quelle direction faire le décalage. Précédemment, nous avons vu qu'il existe deux méthodes pour créer un ''barrel shifter''. La première se base sur un décaleur auquel on ajoute de quoi faire les rotations, alors que l'autre se base sur l'application d'un masque en sortie d'un rotateur. Dans ce qui va suivre, nous allons voir comment ces deux types de circuits peuvent être rendus bidirectionnels.
[[File:Barrel shifter bidirectionnel - interface.png|centre|vignette|upright=2|Barrel shifter bidirectionnel - interface]]
===Les ''barrel shifters'' bidirectionnels basé sur des multiplexeurs===
Commençons par voir comment rendre bidirectionnel un ''barrel shifter'' basé sur des multiplexeurs. Pour rappel, ces derniers sont basés sur un décaleur qu'on rend capable de faire des rotations en ajoutant des multiplexeurs.
Une première solution est d'utiliser des '''''barrel shifters'' bidirectionnels série''', série signifiant que les deux sens sont calculés en série, l'un après l'autre. Ils sont composés de décaleurs qui sont capables de faire des décalages/rotations vers la gauche et vers la droite. De tels décaleurs peuvent se concevoir de diverses façons, mais la plus simple se base sur le principe qui veut qu'un décaleur est composé de décaleurs de 1, 2, 4, 8 bits, etc. Chaque décaleur est en double : une version qui décale vers la gauche, et une autre qui décale vers la droite. Lors d'un décalage vers la droite, les décaleurs élémentaire à gauche sont désactivés alors que les décaleurs vers la droite sont actifs (et réciproquement lors d'un décalage à gauche). Le bit qui indique la direction du décalage est envoyé à chaque décaleur et lui indique s'il doit décaler ou non.
[[File:Décaleur bidirectionnel.png|centre|vignette|upright=2|Décaleur bidirectionnel]]
Une autre solution, bien plus simple, est de prendre un décaleur/rotateur vers la gauche et un autre vers la droite, et de prendre la sortie adéquate en fonction de l'opération demandée. Le choix du résultat se fait encore une fois avec une couche de multiplexeurs. Le résultat est ce qu'on appelle un '''''barrel shifter'' bidirectionnel parallèle''', parallèle signifiant que les deux sens sont calculés en parallèle, en même temps. Notons que cette solution ressemble beaucoup à la précédente. À vrai dire, si on prend la première solution et qu'on regroupe ensemble les décaleur/rotateurs allant dans la même direction, on retombe sur un circuit presque identique à un ''barrel shifter'' bidirectionnel parallèle.
Les deux techniques précédentes utilisent beaucoup de portes logiques et il est possible de faire bien plus efficace. L'idée est simplement d'inverser l'ordre des bits avant de faire le décalage ou la rotation, puis de remettre le résultat dans l'ordre. Par exemple, pour faire un décalage à gauche, on inverse les bits du nombre à décaler, on fait un décalage à droite, puis on remet les bits dans l'ordre originel, et voilà ! Pour cela, il suffit de prendre un décaleur/rotateur à droite, et d'ajouter deux circuits qui inversent l'ordre des bits : un avant le décaleur/rotateur, un après. Ce circuit d'inversion est une simple couche de multiplexeurs. Le résultat est ce qu'on appelle un '''''barrel shifter'' bidirectionnel à inversion de bits'''.
[[File:Barrel shifter à inversion de bits.png|centre|vignette|upright=1.5|Barrel shifter à inversion de bits.]]
===Le décaleur-rotateur bidirectionnel basé sur des masques===
Dans cette section, nous allons voir concevoir un rotateur bidirectionnel avec des masques. Pour cela, il faut juste créer un rotateur bidirectionnel et utiliser des masques pour obtenir des décalages.
Pour créer le rotateur bidirectionnel, nous allons devoir étudier ce qui se passe quand on enchaine deux rotations successives. N'allons pas par quatre chemins : l'enchainement de deux rotations successives donne un résultat qui aurait pu être obtenu en ne faisant qu'une seule rotation. Par exemple, faire une rotation à droite par 5 rangs suivie d'une rotation à droite de 8 rangs est équivalent à faire une rotation à droite de 5+8 rangs, soit 13 rangs. Le résultat issu de la succession de deux rotations est identique à celui d'une ''rotation équivalente''. Et on peut calculer le nombre de rangs de la rotation équivalente à partir des rangs des deux rotations initiales. Pour cela, il suffit d'additionner les rangs en question.
La logique est la même quand on enchaine des rotations à droite et à gauche. Il suffit de compter les rangs d'une rotation en les comptant positifs pour une rotation à droite et négatifs pour une rotation à gauche. Par exemple, une rotation de -5 rangs sera une rotation à gauche de 5 rangs, alors qu'une rotation de 10 rangs sera une rotation à droite de 10 rangs. On pourrait faire l'inverse, mais prenons cette convention pour l'explication qui suit. Toujours est-il qu'avec cette convention, l'addition des rangs donne le bon résultat pour la rotation équivalente. Par exemple, si je fais une rotation à droite de 15 rangs et une rotation à gauche de 6 rangs, le résultat sera une rotation de 15-6 rangs : c'est équivalent à une rotation à droite de 9 rangs.
Faisons dès maintenant remarquer quelque chose d'important. Prenons un nombre de n bits. Avec un peu de logique et quelques expériences, on remarque facilement qu'une rotation par <math>n</math> ne fait rien, dans le sens où les bits reviennent à leur place initiale. Une rotation par <math>n</math> est donc égale à pas de rotation du tout, ce qui est équivalent à faire une rotation par zéro rangs.
Pour le moment, ce détail nous permet de gérer le cas où l'addition de deux rangs donne un résultat supérieur à <math>n</math>. Par exemple, prenons une rotation par 56 rangs pour un nombre de 9 bits. La division nous dit que 56 = 9*6 + 2. En clair, faire un décalage par 56 rangs est équivalent à faire 6 rotations totales par 9, suivie d'une rotation par 2 rangs. Les rotations par 9 ne comptant pas, cela revient en fait à faire une rotation par 2 rangs. Le même raisonnement fonctionne dans le cas général, et revient à faire ce qu'on appelle une '''addition modulo n'''. C'est à dire qu'une fois le résultat de l'addition connu, on le divise par <math>n</math> et l'on garde le reste de la division. Avec cette méthode, le nombre de rangs de la rotation équivalente est compris entre 0 et <math>n-1</math>.
: ''Les additions modulo n seront notées comme suit : <math>(a+b)\mod n</math>.''
Armé de ces explications, on peut maintenant expliquer comment fonctionne le rotateur bidirectionnel. L'idée derrière ce circuit est de remplacer une rotation à droitepar une rotation à gauche équivalente (ou inversement, mais nous allons supposer que le rotateur fait des rotations vers la gauche). Dans ce qui suit, nous utiliserons la notation suivante : <math>r_\text{équivalent}</math> est le nombre de rangs de la rotation équivalente, <math>n</math> la taille du nombre à décaler et <math>r</math> le nombre de rangs du décalage initial. En soi, ce n'est pas compliqué de trouver une rotation équivalente : une rotation à droite de <math>r</math> rangs est équivalente à une rotation de <math>r + n</math> rangs, à une rotation de <math>r + 2 \times n</math> rangs, et de manière générale à toute rotation de <math>r + k \times n</math> rangs. La raison est que les rotations par n ne comptent pas, elles sont éliminées par la division par <math>n</math>. Pour résumer, on a :
: <math>r_\text{équivalent} = (r \pm k \times n)\mod n</math>
Ls propriétés des calculs modulo n font que cela marche aussi quand on retranche n. Les bizarreries de l'arithmétique modulaire font que, quand on fait les additions modulo n, on peut remplacer tout nombre positif r par <math>r \pm k \times n</math> sans changer les résultats. Mais tous les cas possibles ne nous intéressent pas. En effet, on sait que le nombre de rangs de la rotation équivalente est compris entre 0 et <math>n-1</math>. Le résultat que l'on recherche doit donc être compris entre 0 et <math>n-1</math>. Et seul un cas respecte cette contrainte : celui où l'on retranche n une seule fois. On a alors :
: <math>r_\text{équivalent} = r - n</math>
L'équation nous dit qu'il est possible de remplacer une rotation à droite par une rotation à gauche équivalente. Par exemple, sur 8 bits et pour une rotation à droite de 6 bits, on a <math>r_\text{équivalent} = 6 - 8 = -2</math>. En clair, la rotation équivalente est ici une rotation à gauche de 2 crans. Vous pouvez essayer avec d'autres exemples, vous trouverez la même chose. Par exemple, sur 16 bits, une rotation à gauche de 3 rangs est équivalente à une rotation à droite de 13 rangs.
Le calcul ci-dessus peut être simplifié en utilisant quelques astuces. Sur la plupart des ordinateurs, n est égal à 8, 16, 32, 64, ou toute autre nombre de la forme <math>2^n</math>. Les cas où n vaut 3, 7, 14 ou autres sont tellement rares que l'on peut les considérer comme anecdotiques. De plus, <math>r</math> est compris entre 0 et <math>n-1</math>. On peut donc coder le rang sur un nombre bien précis de bits, tel que n est la valeur haute de débordement (en clair, n-1 est la plus grande valeur codable, n entraine un débordement d'entier). Grâce à cela, on peut coder le nombre de rangs en complément à un ou en complément à deux. Rappelons que ces deux représentations des nombres utilisent l'arithmétique modulaire, c'est à dire que l'addition et la soustraction se font modulo n, et que leur principe est de représenter tout n négatif par un n positif équivalent. Ainsi, tout <math>r_\text{équivalent}</math> négatif est codé par un <math>r</math> positif équivalent. Et dans ces représentations, on a obligatoirement <math>r - n = - r</math>. En appliquant cette formule dans l'équation précédente, on a :
: <math>r_\text{équivalent} = - r</math>
Reprenons l'exemple d'une rotation à gauche de 2 crans pour un nombre de 8 bits, ce qui est équivalent à une rotation de 6 crans à droite: on a bien 6 = -2 en complément à deux. Reste à faire le calcul ci-dessus par le circuit de rotation.
En complément à un, le calcul de l'opposé d'un nombre consiste simplement à inverser les bits de <math>r_\text{initial}</math>. En conséquence, le circuit est plus simple en complément à un. Le calcul du nombre de rangs demande juste un inverseur commandable, qu'on sait fabriquer depuis quelques chapitres.
[[File:Rotateur bidirectionnel en complèment à un.png|centre|vignette|upright=2|Rotateur bidirectionnel en complément à un.]]
En complément à deux, le calcul est le suivant :
: <math>r_\text{équivalent} = \overline{r_\text{initial}} + 1</math>
On pourrait utiliser un circuit pour faire l'addition, mais il y a une autre manière plus simple de faire. L'idée est simplement de prendre le circuit en complément à un et d'y ajouter de quoi corriger le résultat final. En clair, on fait le calcul comme en complément à un, mais la rotation effectuée ne sera pas équivalente, du fait du +1 dans le calcul. Ce +1 indique simplement qu'il faut décaler le résultat obtenu d'un cran supplémentaire. Pour cela, on ajoute un rotateur d'un cran à la fin du circuit.
[[File:Rotateur bidirectionnel en complément à deux.png|centre|vignette|upright=2|Rotateur bidirectionnel en complément à deux.]]
On peut transformer ce circuit en décaleur-rotateur en appliquant la méthode vue plus haut, à savoir en appliquant un masque en sortie du rotateur. Le circuit obtenu est le suivant :
[[File:Décaleur rotateur bidirectionnel basé sur un masque.png|centre|vignette|upright=2|Décaleur rotateur bidirectionnel basé sur un masque.]]
==Le ''barrel shifter'' de l'Intel 386==
Le ''barrel shifter'' de l'Intel 386 est différent des ''barrel shifter'' vus précédemment. Il gère nativement décalages, rotations, et quelques autres opérations. Il a pour particularité de faire toutes ses opérations à partir de décalages à droite. Et pour que cela fonctionne, il manipule ses opérandes d'une manière assez inédite.
Pour comprendre pourquoi, précisons que l'Intel 386 est le tout premier processeur 32 bits d'Intel. Intuitivement, on se dirait que son ''barrel shifter'' prend un opérande de 32 bits, et fournit une sortie de 32 bits. Mais la réalité est qu'il prend un opérande de 64 bits, répartie dans deux registres de 32 bits chacun. Le ''barrel shifter'' est de 64 bits. La sortie du circuit correspond aux 32 bits de poids faible du résultat du décalage.
{|class="wikitable"
|-
! Registre 32 bits !! Registre 32 bits
|-
| colspan="2" | ''Barrel Shifter'' hybride 64 - 32 bits
|-
! colspan="2" | Sortie 32 bits
|}
Ce décaleur hybride 64-32 bits était composé de deux sous-circuits placés en série. Le premier décale le résultat par paquets de 4 rangs, à savoir de 0, 4, 8, 12, 16, 20, 24 ou 28 rangs. Le second décale le résultat du premier de 0, 1, 2 ou 3 rangs. Faire ainsi économise des transistors comparé à un ''barrel shifter'' usuel, du moins pour un ''barrel shifter'' hybride 64-32 bits. Et malgré cela, le ''barrel shifter'' faisait environ 2000 transistors, ce qui était énorme pour l'époque. Pour comparer, le processeur 6502 de Motorola tout entier faisait le double.
Pour un décalage à droite logique, le décalage se fait normalement. Les 32 bits de poids fort sont remplis par des zéros, les 32 bits de poids faible sont remplis avec l'opérande à décaler. Les décalages arithmétiques se font d'une manière similaire, la seule différence étant que les 32 bits de poids fort sont remplis avec le bit de signe.
Pour un décalage à gauche, la situation est inversée. L'opérande est placé dans les 32 bits de poids fort, alors que les zéros sont dans les 32 bits de poids fort. Le ''barrel shifter'' fait un décalage à droite, qui émule le décalage à gauche. Pour un décalage à gauche de N rangs, il est émulé par un décalage à droite de 32 − N rangs.
Pour les rotations, les 32 bits de poids fort sont remplis avec l'opérande, idem avec les bits de poids faible. Les rotations à droite se font avec un simple décalage à droite, les rotations à gauche se font avec le même décalage mais le nombre de rangs est altéré de la même manière qu'avec les décalages à droite.
{|class="wikitable"
|-
!
! 32 bits de poids fort !! 32 bits de poids faible
! Nombre de rangs du décalage
|-
! Décalage à droite logique (N rangs)
| 0 || Opérande
| N
|-
! Décalage à droite arithmétique (N rangs)
| Bit de signe x32 || Opérande
| N
|-
! Décalage à gauche (N rangs)
| Opérande || 0
| 32 - N
|-
! Rotation à droite (N rangs)
| Opérande || Opérande
| N
|-
! Rotation à gauche (N rangs)
| Opérande || Opérande
| 32 − N
|}
L'avantage d'un tel circuit est qu'il facilite l'implémentation de certaines opérations qu'on n'a pas encore abordées, comme des rotations avec retenue, ou l'opération ''Bit Test''. Le processeur gérait aussi nativement des décalages sur 64 bits, deux instructions étaient prévues pour. Ce n'était pas très utile pour un processeur 32 bits, mais l'implémentation était aisée, alors pourquoi pas ?
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les timers et diviseurs de fréquence
| prevText=Les timers et diviseurs de fréquence
| next=Les circuits pour l'addition et la soustraction
| nextText=Les circuits pour l'addition et la soustraction
}}
</noinclude>
ixi9yjcn14pkqizwtbaqrh7k4p0j1p7
Fonctionnement d'un ordinateur/Les circuits pour la multiplication et la division
0
79266
763715
749231
2026-04-15T10:44:40Z
Mewtow
31375
/* Les multiplieurs itératifs */
763715
wikitext
text/x-wiki
[[File:Algebra1 05 fig019.svg|vignette|Exemple de multiplication en binaire.]]
Nous allons maintenant aborder un circuit appelé le '''multiplieur''', qui multiplie deux opérandes. La multiplication se fait en binaire de la même façon qu'on a appris à le faire en primaire, si ce n'est que la table de multiplication est vraiment très simple en binaire, jugez plutôt !
* 0 × 0 = 0.
* 0 × 1 = 0.
* 1 × 0 = 0.
* 1 × 1 = 1.
Pour commencer, petite précision de vocabulaire : une multiplication s'effectue sur deux nombres, le ''multiplicande'' et le ''multiplicateur''. Une multiplication génère des résultats temporaires, chacun provenant de la multiplication du multiplicande par un chiffre du multiplicateur : ces résultats temporaires sont appelés des '''produits partiels'''. Multiplier deux nombres en binaire demande de générer les produits partiels, de les décaler, avant de les additionner.
La génération des produits partiels est assez simple. Sur le principe, la table de multiplication binaire est un simple ET logique. Générer un produit partiel demande donc, à minima, de faire un ET entre un bit du multiplicateur et le multiplicande. Le circuit pour cela est trivial.
La seconde étape est ensuite de décaler le résultat du ET pour tenir compte du poids du bit choisit. En effet, regarder le schéma de droite qui montre comment faire une multiplication en binaire. Vous voyez que c'est comme en décimal : chaque ligne correspond à un produit partiel, et chaque produit partiel est décalé d'un cran par rapport au précédent. Il faut donc ajouter de quoi faire ce décalage. Intuitivement, on se dit qu'il faut ajouter des circuits décaleurs, un pour chaque bit du multiplicateur. Ce ne sera pas toujours le cas, mais il y en aura parfois besoin.
Nous allons d'abord commencer par les multiplieurs qui font de la multiplication non-signée. La multiplication de deux nombres signés est en effet un peu particulière et demande des techniques particulières, là où la multiplication non-signée est beaucoup plus simple.
==Les multiplieurs non-itératifs==
Les '''multiplieurs non-itératifs''' calculent tous les produits partiels en parallèle, en même temps, avant de les additionner avec un additionneur multi-opérandes non-itératif, composé d'additionneurs carry-save. C'est une solution simple, qui utilise beaucoup de circuits, mais est très rapide. C'est la solution utilisée dans les processeurs haute performance moderne, dans presque tous les processeurs grand public, depuis plusieurs décennies. Notons que la génération des produits partiels se passe de circuits décaleur, elle se contente d'utiliser un paquet de portes ET. Le câblage permet de câbler les sorties des portes ET aux bonnes entrées de l'additionneur, ce qui permet de se passer de circuits décaleurs.
[[File:Multiplieur tableau.jpg|centre|vignette|upright=2.5|Multiplieur en arbre.]]
Les '''multiplieurs diviser pour régner''' sont un autre type de multiplieur non-itératif. Pour comprendre le principe, nous allons prendre un multiplieur qui multiplie deux nombres de 32 bits. Les deux opérandes A et B peuvent être décomposées en deux morceaux de 16 bits, qu'il suffit de multiplier entre eux pour obtenir les produits partiels voulus : une seule multiplication 32 bits se transforme en quatre multiplications d'opérandes de 16 bits. En clair, ces multiplieurs sont composés de multiplieurs qui travaillent sur des opérandes plus petites, associés à des additionneurs.
==Les multiplieurs itératifs==
Les '''multiplieurs itératifs''' génèrent les produits partiels les uns après les autres, et les additionnent au fur et à mesure. Le multiplicateur et le multiplicande sont mémorisés dans deux registres. Un multiplieur itératif est composé d'un circuit de génération des produits partiels, suivi d'un additionneur multiopérande itératif. La multiplication est finie quand tous les bits du multiplicateur ont étés traités (ce qui peut se déterminer avec un compteur).
[[File:Circuit itératif de multiplication sans optimisation.png|centre|vignette|upright=1.5|Circuit itératif de multiplication sans optimisation.]]
Rappelons que l'additionneur multiopérande itératif est composé d'un additionneur à deux opérandes, couplé à un ''registre accumulateur''. Il mémorise le résultat temporaire de l'addition des produits partiels. A la fin de la multiplication, l'accumulateur contient le résultat.
[[File:Multiplieur simple.png|centre|vignette|upright=1.5|Circuit itératif de multiplication sans optimisation, détaillée.]]
Un exemple de multiplieur de ce type était celui du processeur Intel i386. Il faut noter qu'il n'utilisait pas un multiplieur séparé de l'ALU. A la place, l'ALU intégrait un additionneur-soustracteur, qui était réutilisé pour les multiplications et divisions. A l'époque, les le nombre de transistor par puce était très limité, et processeurs devaient économiser des transistors par tous les moyens possibles. Mutualiser l'additionneur-soustracteur et le ''barrel shifter'' entre l'ALU et le multiplieur était un bon moyen pour cela. Après tout, rajouter quelques circuits autour d'une ALU pour qu'elle fasse multiplication et division n'est clairement pas une mauvaise idée.
===Les deux types de multiplieurs itératifs===
Les multiplieurs itératifs différent par le sens de parcours du multiplicateur : certains traitent les bits du multiplicateur de droite à gauche, les autres dans le sens inverse. Précisément, le traitement se fait soit des bits de poids faible vers les bits de poids fort, ou inversement des bits de poids fort vers les bits de poids faible. Pour cela, on stocke le multiplieur dans un registre à décalage, le bit qui en sort à chaque cycle est utilisé pour générer le produit partiel.
Il faut noter que le contenu du registre accumulateur est aussi décalé d'un cran vers la gauche ou la droite à chaque cycle. Rappelez-vous que les produits partiels ne sont pas alignés sur une même colonne : ils sont chacun décalés d'un cran par rapport au précédent. Vu qu'on additionne les produits partiels un par un, on doit donc faire un décalage d'un cran entre chaque addition. La solution idéale effectue ce décalage directement dans le registre accumulateur, qui devient alors un registre à décalage ! Avec cette technique, les produits partiels générés ont la même taille que le multiplicateur, le même nombre de bits. On économise pas mal de circuit : pas besoin de circuits décaleur, moins d'entrées sur l'additionneur.
Le sens de décalage du multiplicateur et du registre accumulateur sont identiques. Si on effectue la multiplication de droite à gauche, en commençant par les bits de poids faible, alors le registre accumulateur est aussi décalé vers la droite. Cela permet au produit partiel suivant d'être placé un cran à gauche du précédent. A l'inverse, si on effectue la multiplication en commençant par les bits de poids fort, alors on décale le registre accumulateur vers la gauche, histoire de placer un produit partiel à la droite du précédent. La seconde solution a un avantage qu'on va expliquer.
Prenons un multiplicateur de N bits. Avec une multiplication qui commence par les bits de poids fort, l'addition donne un résultat sur 2N bits, la totalité d'entre eux étant utiles. En allant dans l'autre sens, l'addition donne un résultat qui a N bits effectifs, à savoir que le reste sont systématiquement à zéro et sont en réalité pris en charge par le décalage de l'accumulateur. Commencer par les bits de poids faible permet d'utiliser des produits partiels sur n bits, donc d'utiliser un additionneur sur N bits. Les produits partiels aussi sont de N bits. Le registre accumulateur reste de 2N bits, mais seuls les N bits de poids fort sont utilisés dans l'addition.
[[File:Circuit itératif de multiplication, avec optimisation de la taille des produits partiels.png|centre|vignette|upright=1.5|Circuit itératif de multiplication, avec optimisation de la taille des produits partiels.]]
Il est même possible de ruser encore plus : on peut se passer du registre pour le multiplicateur. Il suffit d'initialiser les bits de poids faible du registre accumulateur avec le multiplicateur au démarrage de la multiplication. Le bit du multiplicateur choisi pour le calcul du produit partiel est simplement le bit de poids faible du résultat.
[[File:Multiplieur partagé.png|centre|vignette|upright=1.5|Multiplieur partagé]]
Voici ce que donne une multiplication commençant par les bits de poids faible.
[[File:Fonctionnement multiplieur.gif|centre|vignette|upright=1.5|Fonctionnement multiplieur.]]
===Les optimisations liées aux opérandes===
Les circuits précédents peuvent incorporer des optimisations liées à la valeur des opérandes. Avec ces optimisations, la multiplication sera plus ou moins rapide suivant l'opérande : certaines opérandes donneront une multiplication en 32 cycles, d'autres en 12 cycles, d'autres en 20, etc.
La première optimisation consiste à terminer l'opération une fois que le multiplieur décalé atteint 0. Dans ce cas, on a multiplié tous les bits à 1 du multiplieur, tous les produits partiels restants valent 0, pas besoin de les calculer. L'optimisation ne marche cependant que si on commence les calculs à partir du bit de poids faible du multiplieur. Si on commence par les bits de poids fort, il faudra faire plusieurs décalages pour obtenir le bon résultat. Par exemple, si les N bits de poids faible du multiplieurs valent 0, alors il faudra décaler le résultat dans le registre accumulateur de N rangs vers la gauche.
Une seconde optimisation commence la multiplication pour le premier bit du multiplieur à 1, et zapper les 0 de poids fort précédents. Ce faisant, on calcule le nombre de 0 de poids fort avec un circuit adéquat (un circuit de ''count leading zeros''), puis décale le multiplieur et le reste partiel de ce nombre. Les zéros de poids faible sont eux traités en simplement comparant le multiplieur avec zéro. La même optimisation s'applique si on commence la multiplication à partir du bit de poids faible, il faut cependant compter les 0 de poids faibles pour savoir de combien décaler vers la droite.
==Les multiplieurs en base 4, 8, 16==
Avec les multiplieurs itératifs précédents, la multiplication se fait produit partiel par produit partiel. À l'inverse, avec les multiplieurs non-itératifs, on génère tous les produits partiels en même temps, pour les additionner. Il existe cependant des multiplieurs intermédiaires, qui génèrent et additionnent plusieurs produits partiels à la fois, tout en restant des multiplieurs itératifs. L'idée est de générer deux, trois, quatre produits partiels en même temps et de les additionner au résultat temporaire, avec un additionneur multiopérandes. On parle alors de '''multiplieurs en base 4, 8, ou 16'''. Le multiplieur en base 4 génère deux produits partiels à la fois, celui en base 8 en génère 3, celui en base 16 en génère 4, etc.
Il existe plusieurs manières de fabriquer un multiplieur en base 4, 8, 16, etc. La première, la plus simple, utilise un multiplieur hybride, qui mélange un multiplieur itératif et un autre non-itératif. La seconde, plus complexe, modifie le circuit d'un multiplieur itératif en rajoutant des circuits annexes.
===Les multiplieurs hybrides===
Une solution, assez évidente, rajoute des circuits de génération des produits partiels et on remplace l'additionner normal par un additionneur multiopérande. Voici ce que cela donne quand on prend deux produits partiels à la fois :
[[File:Multiplieur en base 4.png|centre|vignette|upright=2|Multiplieur en base 4]]
La seule difficulté est de gérer les décalages des registres. Dans l'exemple avec deux produits partiels, vu qu'on traite deux bits à la fois, on doit décaler le registre accumulateur de deux rangs. De plus, les deux bits du multiplieur utilisés n'ont pas le même poids. Un des produits partiel doit être décalé d'un rang par rapport à l'autre. En théorie, on devrait user d'un circuit décaleur, mais on peut s'en passer avec des bidouilles de câblage. La même chose a lieu quand on génère trois produits partiels à la fois : l'un n'est pas décalé, le suivant l'est d'un rang, l'autre de trois rangs. Et ainsi de suite avec quatre produits partiels simultanés. Rien d'insurmontable en soi, cela ne fait que marginalement complexifier le circuit.
Il est possible d'optimiser le circuit en faisant les additions en ''carry save'' uniquement, sans passer par un résultat temporaire en binaire. Pour cela, il faut déplacer l'additionneur normal après le registre accumulateur. Le registre accumulateur mémorise alors le résultat temporaire en ''carry save''. Il est donc dupliqué, avec un registre pour les retenues, et l'autre pour la somme. Une fois que tous les produits partiels ont été additionnés, on traduit le résultat temporaire en ''carry save'' en binaire normal, avec l'additionneur normal.
[[File:Multiplieur itératif optimisé en base 4.png|centre|vignette|upright=2|Multiplieur itératif en base 4 optimisé.]]
Le design précédent peut être amélioré en tenant compte d'un détail portant sur le registre accumulateur. Il s'agit d'un registre synchrone, commandé par un signal d’horloge non-représenté dans les schémas précédents. Une implémentation de ce registre utilise des bascules dites master-slave, composées de deux bascules D non-synchrones à entrée Enable qui se suivent, comme nous l'avions vu dans le chapitre sur les circuits synchrones. Le registre synchrone est donc composé de deux registres non-synchrones qui se suivent. Avec ce type de registres, il est possible de modifier le multiplieur précédent de manière à doubler le nombre de produits partiels additionnés à chaque cycle d'horloge. L'idée est très simple : on insère un second additionneur ''carry save'' entre les deux registres ! On obtient alors un '''multiplieur ''multibeat'''''.
[[File:Multiplieur itératif de type multibeat.png|centre|vignette|upright=2|Multiplieur itératif de type multibeat]]
===Les multiplieurs itératifs en base 4, 8, 16===
Une implémentation alternative utilise un additionneur 2 opérandes normal, mais précalcule les produits partiels. Prenons l'exemple le plus simple : celui d'un multiplieur en base 4, qui travaille deux produits partiels à la fois. A chaque cycle, il génère deux produits partiels, qui sont additionnés avec le registre accumulateur. Une autre manière de voir les choses est que ces produits partiels sont additionnés entre eux, et le résultat est additionné avec le registre accumulateur. Pour un multiplicande A, la somme des produits partiels vaut : O, A, 2A, 3A. Et c'est cette somme qu'il faut additionner au contenu du registre accumulateur. Les quatre sommes possibles sont toujours les mêmes, et on peut les précalculer et les mémoriser dans des registres dédiés. On peut choisir la bonne somme en fonction des deux bits du multiplieur
[[File:Multiplieur itératif non-hybride en base 4.png|centre|vignette|upright=2|Multiplieur itératif non-hybride en base 4]]
Il est cependant possible de ruser afin d'éliminer certains registres. Par exemple, pas besoin d'un registre pour le 0 : juste d'un circuit de mise à zéro, comme dans n'importe quel circuit de génération de produit partiel. Pareil pour le registre contenant le double du multiplicande : un simple décalage suffit pour le calculer à la volée (une simple bidouille de câblage permet de se passer de circuit décaleur). Seuls restent les registres pour le multiplicande et son triple. Il est généré par l'additionneur normal, en fin de circuit, au tout début de l'addition.
La solution marche aussi quand on veut générer trois produits partiels à la fois, ou quatre, ou cinq, mais deviennent rapidement inutiles. Par exemple, pour générer trois produits partiels à la fois, il faut calculer 0, A, 2A, 3A, 5A et 7A, et calculer le reste à partir de cela. Mais le jeu n'en vaut pas la chandelle. Certes, calculer trois produits partiels à la fois divise par trois le nombre d'additions, sauf que générer à l'avance les produits partiels rajoute quelques additions. Ce qu'on gagne d'un côté, on le perd de l'autre. Autant dire que cette méthode n'est que rarement utilisée, car elle utilise plus de circuits ou sont moins performantes.
==La multiplication de nombres signés==
Tous les circuits qu'on a vus plus haut sont capables de multiplier des nombres entiers positifs, mais on peut les adapter pour qu'ils fassent des calculs sur des entiers signés. Et la manière de faire la multiplication dépend de la représentation utilisée. Les nombres en signe-magnitude ne se multiplient pas de la même manière que ceux en complément à deux ou en représentation par excès. Dans ce qui va suivre, nous allons voir ce qu'il en est pour la représentation signe-magnitude et pour le complément à deux. La représentation par excès est volontairement mise de côté, car ce cas est assez compliqué à gérer et qu'il n'existe pas de solutions simples à ce problème. Cela explique le peu d'utilisation de cette représentation, qui est limitée aux cas où l'on sait qu'on ne fera que des additions/multiplications, le cas de l'exposant des nombres flottants en étant un cas particulier.
===Multiplier les valeurs absolues et convertir===
Une première solution pour multiplier des entiers signés est simple : on prend les valeurs absolues des opérandes, on multiplie, et on inverse le résultat si besoin. Mathématiquement, la valeur absolue du résultat est le produit des valeurs absolues des opérandes. Quant au signe, on apprend dans les petites classes le tableau suivant. On s’aperçoit qu'on doit inverser le résultat si et seulement si une seule opérande est négative, pas les deux.
{|class="wikitable"
|-
! Signe du multiplicande !! Signe du multiplieur !! Signe du résultat
|-
! + !! + || +
|-
! - !! + || -
|-
! + !! - || -
|-
! - !! - || +
|}
Pour les entiers en signe-valeur absolue, le calcul est très simple, vu que la valeur absolue et le signe sont séparés. Il suffit de calculer le bit de signe à part, et multiplier les valeurs absolues. En traduisant le tableau d'avant en binaire, avec la convention + = 0 et - = 1, on trouve la table de vérité d'une porte XOR. Pour résumer, il suffit de multiplier les valeurs absolues et de faire un vulgaire XOR entre les bits de signe.
[[File:Multiplication en signe-magnitude.png|centre|vignette|upright=2|Multiplication en signe-magnitude]]
Pour les entiers en complément à deux, cette solution n'est pas utilisée. Prendre les valeurs absolues demande d'utiliser deux incrémenteurs et deux inverseurs, sans compter qu'il faut en rajouter un de plus pour inverser le résultat. Le cout en circuits serait un peu gros, sans compter qu'on peut faire autrement.
===Les multiplieurs itératifs signés en complément à deux===
Pour la représentation en complément à deux, les multiplieurs non-signés vus plus haut fonctionnent parfaitement quand les deux opérandes ont le même signe, mais pas quand un des deux opérandes est négatif.
Avec un multiplicande négatif, le produit partiel est censé être négatif. Les multiplieurs vus plus haut peuvent gérer la situation so on utilise une extension de signe sur les produits partiels. Pour cela, il faut faire en sorte que le décalage du résultat soit un décalage arithmétique. Cette technique marche très bien que on utilise un multiplieur qui travaille de droite à gauche, avec des décalages à droite.
Pour traiter les multiplicateurs négatifs, le produit partiel correspondant au bit de poids fort doit être soustrait. L'explication du pourquoi est assez dure à comprendre, aussi je vous épargne les détails, mais c'est lié au fait que ce bit a une valeur négative. L'additionneur doit donc être remplacé par un additionneur-soustracteur.
[[File:Multiplieur pour entiers signés.png|centre|vignette|upright=2|Multiplieur itératif pour entiers signés.]]
===Les multiplieurs de Booth===
Il existe une autre façon, nettement plus élégante, inventée par un chercheur en cristallographie du nom de Booth : l''''algorithme de Booth'''. Le principe de cet algorithme est que des suites de bits à 1 consécutives dans l'écriture binaire d'un nombre entier peuvent donner lieu à des simplifications. Si vous vous rappelez, les nombres de la forme 01111…111 sont des nombres qui valent 2n − 1. Donc, X × (2^n − 1) = (X × 2^n) − X. Cela se calcule avec un décalage (multiplication par 2^n) et une soustraction. Ce principe peut s'appliquer aux suites de 1 consécutifs dans un nombre entier, avec quelques modifications. Prenons un nombre composé d'une suite de 1 qui commence au n-ième bit, et qui termine au X-ième bit : celle-ci forme un nombre qui vaut 2^n − 2^n−x. Par exemple, 0011 1100 = 0011 1111 − 0000 0011, ce qui donne (2^7 − 1) − (2^2 − 1). Au lieu de faire des séries d'additions de produits partiels et de décalages, on peut remplacer le tout par des décalages et des soustractions.
C'est le principe qui se cache derrière l’algorithme de Booth : il regarde le bit du multiplicateur à traiter et celui qui précède, pour déterminer s'il faut soustraire, additionner, ou ne rien faire. Si les deux bits valent zéro, alors pas besoin de soustraire : le produit partiel vaut zéro. Si les deux bits valent 1, alors c'est que l'on est au beau milieu d'une suite de 1 consécutifs, et qu'il n'y a pas besoin de soustraire. Par contre, si ces deux bits valent 01 ou 10, alors on est au bord d'une suite de 1 consécutifs, et l'on doit soustraire ou additionner. Si les deux bits valent 10 alors c'est qu'on est au début d'une suite de 1 consécutifs : on doit soustraire le multiplicande multiplié par 2^n-x. Si les deux bits valent 01, alors on est à la fin d'une suite de bits, et on doit additionner le multiplicande multiplié par 2^n. On peut remarquer que si le registre utilisé pour le résultat décale vers la droite, il n'y a pas besoin de faire la multiplication par la puissance de deux : se contenter d’additionner ou de soustraire le multiplicande suffit.
Reste qu'il y a un problème pour le bit de poids faible : quel est le bit précédent ? Pour cela, le multiplicateur est stocké dans un registre qui contient un bit de plus qu'il n'en faut. On remarque que pour obtenir un bon résultat, ce bit précédent doit mis à 0. Le multiplicateur est placé dans les bits de poids fort, tandis que le bit de poids faible est mis à zéro. Cet algorithme gère les signes convenablement. Le cas où le multiplicande est négatif est géré par le fait que le registre du résultat subit un décalage arithmétique vers la droite à chaque cycle. La gestion du multiplicateur négatif est plus complexe à comprendre mathématiquement, mais je peux vous certifier que cet algorithme gère convenablement ce cas.
==Les division signée et non-signée==
La division en binaire se fait de la même manière qu'en décimal : avec une série de soustractions. L'opération implique un dividende, qui est divisé par un diviseur pour obtenir un quotient et un reste.
Implémenter la division sous la forme de circuit est quelque peu compliqué. La difficulté est simplement que chaque étape de la division dépend de la précédente ! Cela réduit les possibilités d'optimisation. Il est très difficile d'utiliser des soustracteurs multiopérande non-itératifs pour créer un circuit diviseur. Pas de problème pour la multiplication, où utiliser un paquet d'additionneurs en parallèle marche bien. La division ne permet pas de faire de genre de choses facilement. C'est possible, mais le cout en circuits est prohibitif.
Les techniques que nous allons voir en premier lieu calculent le quotient bit par bit, elles font une soustraction à la fois. Il est possible de calculer le quotient non pas bit par bit, mais par groupe de deux, trois, quatre bits, voire plus encore. Mais les circuits deviennent alors très compliqués. Dans tous les cas, cela revient à utiliser des diviseurs itératifs, sur le même modèle que les multiplicateurs itératifs, sauf que l’addition est remplacée par une soustraction. Nous commencer par les trois techniques les plus simples pour cela : l'implémentation naïve, la division avec restauration, et sans restauration.
===L'implémentation itérative naïve===
[[File:Algebra1 05 fig021.svg|vignette|Division en binaire.]]
En binaire, l'opération de division est la même qu'en décimal, si on omet que la table de soustraction est beaucoup plus simple. La seule différence est qu'en binaire, à chaque étape, on doit soit soustraire zéro, soit soustraire le diviseur, rien d'autre. Mais pour le reste, tout se passe de la même manière qu'en décimal. À chaque étape, on prend le '''reste partiel''', le résultat de la soustraction précédente, et on abaisse le bit adéquat, exactement comme en décimal.
Sur le principe, général, un diviseur ressemble à ce qui est indiqué dans le schéma ci-dessous. On trouve en tout quatre registres : un pour le dividende, un pour le diviseur, un pour le quotient, et un registre accumulateur dans lequel se trouve le "reste partiel" (ce qui reste une fois qu'on a soustrait le diviseur dans chaque étape).
À chaque étape de la division, on effectue une soustraction, ce qui demande un circuit soustracteur. On soustrait soit le diviseur, soit zéro. Le choix entre les deux est réalisé par un multiplexeur, ou encore mieux : par un circuit de mise à zéro sélectif.
Abaisser le bit suivant demande un peu plus de réflexion. Le bit abaissé appartient au dividende, et on abaisse les bits en progressant du bit de poids fort vers le bit de poids faible. Pour faire cela, le dividende est placé dans un registre à décalage, qui se décale d'un rang vers la gauche à chaque itération. Le bit sortant du registre n'est autre que le bit abaissé. Il suffit alors de le concaténer au reste partiel, avec une petite ruse de câblage, et d'envoyer le tout en opérande du soustracteur. Rien de bien compliqué, il faut juste envoyer le bit abaissé sur l'entrée de poids faible du soustracteur, et de câbler le reste pareil à côté, ce qui décale le tout d'un rang automatiquement, sans qu'on ait besoin de circuit décaleur pour le reste partiel.
Reste ensuite à déterminer le quotient, ce qui est fait par un circuit spécialisé relié au diviseur et au dividende. Au passage, déterminer le bit du quotient permet au passage de savoir si on doit soustraire le diviseur ou non (soustraire zéro). Ce circuit n'est pas relié qu'au registre pour le quotient, mais aussi au multiplexeur mentionné précédemment. Toute la difficulté tient dans la détermination du quotient. En soi, elle est très simple : il suffit de comparer le dividende et le diviseur. Si le dividende est supérieur au diviseur, alors on peut soustraire. S'il est inférieur, on ne soustrait pas, et on passe à l'étape suivante. Si les deux sont égaux, on soustrait.
[[File:Circuit diviseur, principe général.png|centre|vignette|upright=2|Circuit diviseur, principe général]]
L’optimisation de ce circuit la plus intéressante est la mise à l'échelle les opérandes. L'idée est juste de commencer la division au bon moment, en ne faisant pas certaines étapes dont on sait qu'elles vont fournir un zéro sur le quotient. L'idée marche sur les dividendes dont les n bits de poids fort sont à zéro. L'idée est de zapper ces n bits, en décalant le dividende de n rangs au début de la division, vu qu'on sait que ces bits donneront des zéros pour le quotient et pas de reste partiel. Il faut aussi décaler le quotient de n rangs, en insérant des 0 à chaque rang décalé.
===L'implémentation itérative sans redondance du soustracteur===
Un défaut du circuit précédent est qu'il y a une duplication de circuit cachée. En effet, le circuit de détermination du quotient est un comparateur. Mais un comparateur peut s'implémenter par un circuit soustracteur ! Pour vérifier si un opérande est supérieur, égale ou inférieur à une seconde opérande, il suffit de les soustraire entre elles et de regarder le signe du résultat. On a donc deux circuits soustracteurs cachés dans ce circuit : un pour déterminer le quotient, un autre pour faire la soustraction. Mais il y a moyen de ruser pour éliminer cette redondance.
De plus, retirer cette duplication ne rend pas le circuit plus lent. En n'utilisant qu'un seul soustracteur, on fera la comparaison et la soustraction dans le même soustracteur, l'une après l'autre. Mais avec le circuit ci-dessous, c'est la même chose : on effectue la comparaison pour sélectionner le bon opérande, avant de faire la soustraction. Dans les deux cas, c'est globalement la même chose.
Si on retire la redondance mentionnée dans la section précédente, le circuit reste globalement le même, à un détail près. Chaque étape demande de comparer reste partiel et diviseur pour déterminer le bit du quotient et l'opérande à soustraire, puis faire la soustraction. La comparaison se fait avec une soustraction, et le bit de signe du résultat est utilisé pour déterminer le signe de l'opérande. Chaque étape est donc découpée en deux sous-étapes consécutives : la comparaison et la soustraction. La manière la plus simple pour cela est de faire en sorte que le circuit fasse chaque étape en deux cycles d'horloges : un dédié à la comparaison, un autre dédié à la soustraction proprement dit.
Le circuit doit donc fonctionner en deux temps, et la meilleure manière pour cela est de lui faire faire une étape de la division en deux cycles d'horloges. Certains circuits vont fonctionner lors du premier cycle, d'autres lors du second. Lors du premier cycle, le bit du quotient est déterminé, le multiplexeur est configuré pour pointer vers le diviseur, et le registre du quotient est décalé. Les autres circuits ne fonctionnent pas. Le résultat de la soustraction n'est pas pris en compte, il n'est pas enregistré dans le registre du reste partiel. Lors du second cycle, c'est l'inverse : le multiplexeur est configuré par le bit calculé à l'étape précédente, le résultat de la soustraction est enregistré dans le registre accumulateur, et le registre du dividende est décalé.
[[File:Circuit diviseur naif amélioré en stoppant modification de l'accumulateur lors d'une comparaison.png|centre|vignette|upright=2|Circuit diviseur naif amélioré en stoppant modification de l'accumulateur lors d'une comparaison]]
On pourrait croire que le circuit de division obtenu est plus lent, vu qu'il a besoin de deux cycles d'horloge pour faire son travail. Mais la réalité est que ce n'est pas forcément le cas. En réalité, on peut très bien doubler la fréquence de l'horloge uniquement dans le circuit de division, qui fonctionne deux fois plus vite que les circuits alentours, y compris ceux auquel il est relié. Par exemple, si le circuit de division est intégré dans un processeur, le processeur ira à une certaine fréquence, mais le circuit de division ira deux fois plus vite. Mine de rien, cette solution a été utilisée dans de nombreux designs commerciaux, et notamment sur le processeur HP PA7100.
====La division avec restauration====
[[File:Division avec restauration.png|vignette|upright=1|Division avec restauration.]]
Un point important pour que l’algorithme précédent fonctionne est que le résultat fournit par le soustracteur ne soit pas pris en compte lors de l'étape de comparaison. Plus haut, la solution retenue était de ne pas l'enregistrer dans le registre du reste partiel. Il s'agit là de la solution la plus simple, mais il existe une solution alternative plus complexe, qui autorise l'enregistrement du reste partiel faussé dans le registre accumulateur, mais effectue une correction pour restaurer le reste partiel tel qu'il était avant la comparaison. C'est le principe de la '''division avec restauration''' que nous allons voir dans ce qui suit.
Développons la division avec restauration par un exemple illustré ci-contre. Nous allons cherche à diviser 1000 1100 1111 (2255 en décimal) par 0111 (7 en décimal). Pour commencer, nous allons commencer par sélectionner le bit de poids fort du dividende (le nombre qu'on veut diviser par le diviseur), et soustraire le diviseur à ce bit, pour voir le signe du résultat. Si le résultat de cette soustraction est négatif, alors le diviseur est plus grand que ce qu'on a sélectionné dans notre dividende. On place alors un zéro dans le quotient. On restaure alors le reste partiel antérieur, en ajoutant le diviseur retranché à tort. Ensuite, on abaisse le bit juste à côté du bit qu'on vient de tester, et on recommence. À chaque étape, on restaure le reste partiel si le résultat de la soustraction est négatif, on ne fait rien s'il est positif ou nul.
L'algorithme de division se déroule assez simplement. Tout d'abord, on initialise les registres, avec le registre du reste partiel qui est initialisé avec le dividende. Ensuite, on soustrait le diviseur de ce "reste" et on stocke le résultat dans le registre qui stocke le reste. Deux cas de figure se présentent alors : le reste partiel est négatif ou positif. Dans les deux cas, on réussit trouver le signe du reste partiel en regardant simplement le bit de signe du résultat. Reste à savoir quoi faire.
* Le résultat est négatif : cela signifie que le reste est plus petit que le diviseur et qu'on n’aurait pas dû soustraire. Vu que notre soustraction a été effectuée par erreur, on doit remettre le reste tel qu'il était. Ce qui est fait en effectuant une addition. Il faut aussi mettre le bit de poids faible du quotient à zéro et le décaler d'un rang vers la gauche.
* Le résultat est positif : dans ce cas, on met le bit de poids faible du quotient à 1 avant de le décaler, sans compter qu'il faut décaler le reste partiel pour mettre le diviseur à la bonne place (sous le reste partiel) lors des soustractions.
Et on continue ainsi de suite jusqu'à ce que le reste partiel soit inférieur au diviseur. L'algorithme utilise en tout, pour des nombres de N bits, 2N+1 additions/soustractions maximum.
Le seul changement est la restauration du reste partiel. Restaurer le dividende initial demande d'ajouter le diviseur qu'on vient de soustraire. L'algorithme ressemble au précédent, sauf que l'on a plus besoin du multiplexeur, le diviseur est toujours utilisé comme opérande du soustracteur. Sauf que le soustracteur est remplacé par un additionneur-soustracteur. Le circuit de détermination du bit du quotient commande non seulement l'additionneur/soustracteur. Il est beaucoup plus simple que le comparateur d'avant.
[[File:Circuit de division.png|centre|vignette|upright=2|Circuit de division.]]
====La division sans restauration====
La méthode précédente a toutefois un léger défaut : on a besoin de remettre le reste partiel comme il faut lorsqu'on a soustrait le diviseur décalé alors qu'on aurait pas du et que le résultat obtenu est négatif. La '''division sans restauration''' se passe de cette restauration du reste partiel et continue de calculer avec ce reste faux,. Par contre, elle effectue une phase de correction lors du cycle suivant. De plus, il faut corriger le quotient obtenu pour obtenir le quotient adéquat, pareil pour le reste.
Mettons que l'on souhaite soustraire le diviseur du reste partiel, mais que le résultat soit négatif. Au lieu de restaurer le reste partiel initial, on continue, en effectuant une correction au cycle suivant. Il y a donc deux cycles d'horloge à analyser. Au premier, on a le reste partiel R, dont on soustrait le diviseur D décalé de n rangs :
: <math>R - D</math>
Si le résultat est positif, on continue la division normalement, le cycle suivant implique une soustraction normale, il n'y a rien à faire. Mais si le résultat est négatif, une division normale restaure R, puis poursuit la soustraction. Lors du second cycle, le reste partiel est décalé d'un rang vers la gauche, ce qui donne :
: <math>2R - D</math>
Maintenant, regardons ce qui se passe avec une division sans restauration. On fait la soustraction, on a R - D, qui est négatif. On décale vers la gauche, et on soustrait de nouveau D au second cycle :
: <math>2(R - D) - D = 2R - 3D</math>
Le résultat est incorrect, il faut le corriger pour obtenir le bon résultat. Pour cela, on calcule l'erreur, la différence entre les deux équations précédentes :
: <math>[ 2R - D ] - [ 2R - 3D ] = 2D</math>
La correction demande donc juste de faire une addition du diviseur au cycle suivant.
Un autre point à prendre en compte est l'interprétation des bits du quotient. Avec la division avec restauration, le bit du quotient s’interprète comme suit : 0 signifie que l'on a pas soustrait le diviseur décalé par le poids du bit, 1 signifie qu'on a soustrait. Avec la division sans restauration, l'interprétation est différente : 0 signifie que l'on a additionné le diviseur décalé par le poids du bit, 1 signifie qu'on a soustrait. La différence signifie qu'il faut convertir le quotient de l'un vers l'autre pour obtenir le bon quotient. Pour cela, il faut inverser les bits du quotient, multiplier le résultat par deux et ajouter 1.
: Inverser les bits du quotient peut se faire à la volée, lors du calcul, alors que les deux opérations finales se font à la toute fin du calcul, lors du dernier cycle.
Enfin, il faut tenir compte d'un cas particulier : le cas où le reste final est invalide. Cela arrive si on arrive à la fin du calcul, au dernier cycle, et que l'on effectue une soustraction mais que l'on aurait pas dû soustraire. Dans ce cas, on se retrouve avec un reste négatif. Dans ce cas, on est censé poursuivre le calcul encore un cycle pour corriger le résultat, en additionnant le diviseur. Le circuit diviseur doit détecter la situation et effectuer un cycle supplémentaire.
Pour résumer, la division sans restauration :
* Continue le calcul en cas de reste partiel incorrect, sauf qu'au cycle suivant, on additionne le diviseur au lieu de soustraire ;
* Inverser les bits du quotient, multiplier le résultat par deux et ajouter 1.
* Corrige le reste avec l'addition du diviseur si celui-ci devient négatif au dernier cycle.
===Les diviseurs améliorés===
On peut améliorer toutes les méthodes précédentes en ne traitant pas notre dividende bit par bit, mais en le manipulant par groupe de deux, trois, quatre bits, voire plus encore. Mais les circuits deviennent alors très compliqués. Sur certains processeurs, le résultat de la division par un groupe 2,3,4,... bits est accéléré par une petite mémoire qui précalcule certains résultats utiles. Bien sûr, il faut faire attention quand on remplit cette mémoire, sous peine d'obtenir des résultats erronés. Et si vous croyez que les constructeurs de processeurs n'ont jamais fait cette erreur, sachez qu'Intel en a fait les frais sur le Pentium 1. L'unité en charge des divisions flottantes utilisait un algorithme similaire à celui vu au-dessus (les mantisses des nombres flottants étaient divisées ainsi), et la mémoire qui permettait de calculer les bits du quotient contenait quelques valeurs fausses. Résultat : certaines divisions donnaient des résultats incorrects ! C'est de là que vient le fameux "Pentium FDIV bug".
Il est possible de modifier les circuits diviseurs pour remplacer l'additionneur-soustracteur par un équivalent qui fait les calculs en ''carry save''. Les calculs sont alors drastiquement accélérés. Mais le circuit devient alors beaucoup plus complexe. Le calcul du quotient, qui demande un comparateur, est difficile du fait de l'usage de la représentation ''carry save''.
De nos jours, les diviseurs utilisent une version améliorée de la division sans restauration, appelé l'algorithme de '''division SRT'''. C'est cette méthode qui est utilisée dans les processeurs pour la division entière ou la division flottante.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les circuits pour l'addition multiopérande
| prevText=Les circuits pour l'addition multiopérande
| next=Les circuits de calcul logique et bit à bit
| nextText=Les circuits de calcul logique et bit à bit
}}
</noinclude>
cjsxoxcsd1xrhr4yvmo5coee9miwhpt
Fonctionnement d'un ordinateur/Les modes d'adressage
0
82238
763684
743253
2026-04-14T19:53:13Z
Mewtow
31375
/* Les modes d'adressage indirect à décalage pour les enregistrements */
763684
wikitext
text/x-wiki
Une instruction n'est pas encodée n'importe comment, la suite de bits associée a une certaine structure. Quelques bits de l’instruction indiquent quelle est l'opération à effectuer : est-ce une instruction d'addition, de soustraction, un branchement inconditionnel, un appel de fonction, une lecture en mémoire, etc. Cette portion de mémoire s'appelle l''''opcode'''.
Il arrive que certaines instructions soient composées d'un opcode, sans rien d'autre : elles ont alors une représentation en binaire qui est unique. Mais la majorité instructions ajoutent des bits pour préciser la localisation des données à manipuler. Une instruction peut alors fournir au processeur ce qu'on appelle une '''référence''', à savoir quelque chose qui permet de localiser une donnée dans la mémoire. Elles indiquent où se situent les opérandes d'un calcul, où stocker son résultat, où se situe la donnée à lire ou écrire, à quel l'endroit brancher pour les branchements.
Reste à savoir quelle est la nature de la référence : est-ce une adresse, un nombre, un nom de registre, de quoi calculer l'adresse ? Chaque manière d’interpréter la partie variable s'appellent un '''mode d'adressage'''. Un mode d'adressage indique au processeur que telle référence est une adresse, un registre, autre chose. Comme nous allons le voir, certaines instructions supportent certains modes d'adressage et pas d'autres. Généralement, les instructions d'accès mémoire possèdent plus de modes d'adressage que les autres, encore que cela dépende du processeur (chose que nous détaillerons dans le chapitre suivant).
Nous verrons dans le chapitre suivant comment sont encodées les instructions à plusieurs opérandes, ce qui dépend fortement du jeu d'instruction utilisé. Mais dans ce chapitre, nous allons nous limiter au cas où une instruction ne manipule qu'une seule opérande. De plus, nous allons nous limiter au cas où l'opérande est chargée dans un registre. La raison est que nous allons nous concentrer sur la description des modes d'adressage proprement dit. L'instruction encode donc un opcode et une référence, pas plus.
==Les modes d'adressages pour les données==
Pour comprendre un peu mieux ce qu'est un mode d'adressage, nous allons voir les modes d'adressage les plus simples qui soient. Ils sont supportés par la majorité des processeurs existants, à quelques détails près que nous élaborerons dans le chapitre suivant. Il s'agit des '''modes d'adressage directs''', qui permettent de localiser directement une donnée dans la mémoire ou dans les registres. Ils précisent dans quel registre, à quelle adresse mémoire se trouve une donnée.
===L'adressage implicite===
Avec l''''adressage implicite''', il n'y a pas besoin de fournir une référence vers l'opérande ! La raison à cela est que l'instruction n'a pas besoin qu'on lui donne la localisation des données d'entrée et « sait » où sont les données. Comme exemple, on pourrait citer une instruction qui met tous les bits du registre d'état à zéro.
===L'adressage inhérent (à registre)===
Avec le mode d''''adressage inhérent''', la partie variable va identifier un registre qui contient la donnée voulue. Ce mode d'adressage demande d'attribuer un '''numéro de registre''' à chaque registre, parfois appelé abusivement un '''nom de registre'''. Pour rappel, ce dernier est un numéro attribué à chaque registre, utilisé pour préciser à quel registre le processeur doit accéder. On parle aussi d'adressage à registre, pour simplifier.
[[File:Adressage inhérent.png|centre|vignette|upright=2|Adressage inhérent]]
===L'adressage immédiat===
Avec l''''adressage immédiat''', la partie variable est une constante : un nombre entier, un caractère, un nombre flottant, etc. Avec ce mode d'adressage, la donnée est placée dans la partie variable et est chargée en même temps que l'instruction.
[[File:Adressage immédiat.png|centre|vignette|upright=2|Adressage immédiat]]
Les constantes en adressage immédiat sont souvent codées sur 8 ou 16 bits. Aller au-delà serait inutile vu que la quasi-totalité des constantes manipulées par des opérations arithmétiques sont très petites et tiennent dans un ou deux octets. La plupart du temps, les constantes sont des entiers signés, c'est à dire qui peuvent être positifs, nuls ou négatifs. Au vu de la différence de taille entre la constante et les registres, les constantes subissent une opération d'extension de signe avant d'être utilisées.
Pour rappel, l'extension de signe convertit un entier en un entier plus grand, codé sur plus de bits, tout en préservant son signe et sa valeur. L'extension de signe des nombres positifs consiste à remplir les bits de poids fort avec des 0 jusqu’à arriver à la taille voulue : c'est la même chose qu'en décimal, où rajouter des zéros à gauche d'un nombre positif ne changera pas sa valeur. Pour les nombres négatifs, il faut remplir les bits à gauche du nombre à convertir avec des 1, jusqu'à obtenir le bon nombre de bits : par exemple, 1000 0000 (-128 codé sur 8 bits) donnera 1111 1111 1000 000 après extension de signe sur 16 bits. L'extension de signe d'un nombre codé en complément à 2 se résume donc en une phrase : il faut recopier le bit de poids fort de notre nombre à convertir à gauche de celui-ci jusqu’à atteindre le nombre de bits voulu.
===L'adressage absolu===
Passons maintenant à l''''adressage absolu''', aussi appelé adressage direct. Avec lui, la partie variable est l'adresse de la donnée à laquelle accéder. Cela permet de lire une donnée directement depuis la mémoire RAM/ROM. Le terme "adressage par adresse" est aussi utilisé. Un défaut de ce mode d'adressage est que l'adresse en question a une taille assez importante, elle augmente drastiquement la taille de l'instruction. Les instructions sont donc soit très longues, sans optimisations.
[[File:Adressage direct.png|centre|vignette|upright=2|Adressage direct]]
Pour raccourcir les instructions, il est possible de ne pas mettre des adresses complètes, mais de retirer les bits de poids forts. L'adressage absolu ne peut alors lire qu'une partie de la mémoire RAM. Il est aussi possible de ne pas encoder les bits de poids faible pour des questions d'alignement mémoire. Les processeurs RISC modernes gèrent parfois le mode d'adressage absolu, ils encodent des adresses sur 16-20 bits pour des processeurs 32 bits. Un exemple plus ancien est le cas de l’ordinateur Data General Nova. Son processeur était un processeur 16 bits, capable d'adresser 64 kibioctets. Il gérait plusieurs modes d'adressages, dont un mode d'adressage absolu avec des adresses codées sur 8 bits. En conséquence, il était impossible d’accéder à plus de 256 octets avec l'adressage absolu, il fallait utiliser d'autres modes d'adressage pour cela. Il s'agit d'un cas extrême.
Une solution un peu différente des précédentes utilise des adresses de taille variable, et donc des instructions de taille variable. Un exemple est celui du mode '''''zero page''''' des processeurs Motorola, notamment des Motorola 6800 et des MOS Technology 6502. Sur ces processeurs, il y avait deux types d'adressages absolus. Le premier mode utilisait des adresses complètes de 16 bits, capables d'adresser toute la mémoire, tout l'espace d'adressage. Le second mode utilisait des adresses de 8 bits, et ne permettait que d'adresser les premiers 256 octets de la mémoire. L'instruction était alors plus courte : avec un opcode de 8bits et des adresses de 8 bits, elle rentrait dans 16 bits, contre 24 avec des adresses de 16 bits. Un autre avantage était que l'accès à ces 256 octets était plus rapide d'un cycle d'horloge, ce qui fait qu'ils étaient monopolisés par le système d'exploitation et les programmes utilisateurs, mais ce n'est pas lié au mode d'adressage absolu proprement dit.
==Les modes d'adressage indirects pour les pointeurs==
Les modes d'adressages précédents sont appelés les modes d'adressage directs car ils fournissent directement une référence vers la donnée, en précisant dans quel registre ou adresse mémoire se trouve la donnée. Les modes d'adressage qui vont suivre ne sont pas dans ce cas, ils permettent de localiser une donnée de manière indirecte, en passant par un intermédiaire. D'où leurs noms de '''modes d'adressage indirects'''.
L'intermédiaire en question est ce qui s'appelle un '''pointeur'''. Il s'agit de fonctionnalités de certains langages de programmation dits bas-niveau (proches du matériel), dont le C. Les pointeurs sont des variables dont le contenu est une adresse mémoire. En clair, les modes d'adressage indirects ne disent pas où se trouve la donnée, mais où se trouve l'adresse de la donnée, un pointeur vers celle-ci.
===L'utilité des pointeurs : les structures de données===
Les pointeurs ont une définition très simple, mais beaucoup d'étudiants la trouve très abstraite et ne voient pas à quoi ces pointeurs peuvent servir. Pour résumer rapidement, les pointeurs sont utilisées pour manipuler/créér des '''structures de données''', à savoir des regroupements structurées de données plus simples, peu importe le langage de programmation utilisé. Manipuler des tableaux, des listes chainées, des arbres, ou tout autre structure de donnée un peu complexe, se fait à grand coup de pointeurs. C'est explicite dans des langages comme le C, mais implicite dans les langages haut-niveau. C'est surtout le cas dans les structures de données où les données sont dispersées dans la mémoire, comme les listes chaînées, les arbres, et toute structure éparse. Localiser les données en question dans la mémoire demande d'utiliser des pointeurs qui pointent vers ces données, qui donnent leur adresse.
[[File:Pointeurs.svg|centre|vignette|upright=1.5|Illustration du concept de pointeur.]]
Les structures de données les plus simples sont appelées "structures" ou '''enregistrements'''. Elles regroupent plusieurs données simples, comme des entiers, des adresses, des flottants, des caractères, etc. Par exemple, on peut regrouper deux entiers et un flottant dans une structure, qui regroupe les deux. Les données de la structure sont placées les unes à la suite des autres dans la RAM, à partir d'une adresse de début. Localiser une donnée dans la structure demande simplement de connaitre à combien de byte se situe la donnée par rapport à l'adresse de début. Une simple addition permet de calculer cette adresse, et des modes d'adressage permettent de faire ce calcul implicitement.
Un autre type de structure de donnée très utilisée est les '''tableaux''', des structures de données où plusieurs données de même types sont placées les unes à la suite des autres en mémoire. Par exemple, on peut placer 105 entiers les uns à la suite des autres en mémoire. Toute donnée dans le tableau se voit attribuer un '''indice''', un nombre entier qui indique la position de la donnée dans le tableau. Attention : les indices commencent à zéro, et non à 1, ce qui fait que la première donnée du tableau porte l'indice 0 ! L'indice dit si on veut la première donnée (indice 0), la deuxième (indice 1), la troisième (indice 2), etc.
[[File:Tableau à une dimension.png|centre|vignette|upright=2|Tableau]]
Le tableau commence à une adresse appelée l''''adresse de base''', qui est mémorisée dans un pointeur. Localiser un entier dans le tableau demande de faire des calculs avec le pointeur et l'indice. Intuitivement, on se dit qu'il suffit d'additionner le pointeur avec l'indice. Mais ce serait oublier qu'il faut tenir compte de la taille de la donnée. Le calcul de l'adresse d'une donnée dans le tableau se fait en multipliant l'indice par la taille de la donnée, puis en additionnant le pointeur. De nombreux modes d'adressage permettent de faire ce calcul directement, comme nous allons le voir.
===L'adressage indirect à registre pour les pointeurs===
Les modes d'adressage indirects sont des variantes des modes d'adressages directs. Par exemple, le mode d'adressage inhérent indique le registre qui contient la donnée, sa version indirecte indique le registre qui contient le pointeur, qui pointe vers une donnée en RAM/ROM. Idem avec le mode d'adressage absolu : sa version directe fournit l'adresse de la donnée, sa version indirecte fournit l'adresse du pointeur.
Par contre, il n'est pas possible de prendre tous les modes d'adressage précédents, et d'en faire des modes d'adressage indirects. L'adressage implicite reste de l'adressage implicite, peu importe qu'il adresse une donnée ou un pointeur. Quand à l'adressage immédiat, il n'a pas d'équivalent indirect, même si on peut interpréter l'adressage absolu comme tel. Pour résumer, un pointeur peut être soit dans un registre, soit en mémoire RAM, ce qui donne deux classes de modes d'adressages indirect : à registre et mémoire. Nous allons d'abord voir l'adressage indirect à registre, ainsi que ses nombreuses variantes.
Avec l''''adressage indirect à registre''', le pointeur est stockée dans un registre. Le registre en question contient donc un l'adresse de la donnée à lire/écrire, celle qui pointe vers la donnée à lire/écrire. Lors de l'exécution de l'instruction, le pointeur dans le registre est envoyé sur le bus d'adresse, et la donnée est récupérée sur le bus de données.
Ici, la partie variable de l'instruction identifie un registre contenant l'adresse de la donnée voulue. La différence avec le mode d'adressage inhérent vient de ce qu'on fait de ce nom de registre : avec le mode d'adressage inhérent, le registre indiqué dans l'instruction contiendra la donnée à manipuler, alors qu'avec le mode d'adressage indirect à registre, le registre contiendra l'adresse de la donnée.
[[File:Adressage indirects à registre.png|centre|vignette|upright=2|Adressage indirects à registre]]
L'adressage indirect à registre gère les pointeurs nativement, mais pas plus. Il faut encore faire des calculs d'adresse pour gérer les tableaux ou les enregistrements, et ces calculs sont réalisés par des instructions de calcul normales. Le mode d'adressage indirect à registre ne gére pas de calculs d'adresse en lui-même. Et les modes d'adressages qui vont suivre intègrent ce mode de calcul directement dans le mode d'adressage ! Avec eux, le processeur fait le calcul d'adresse de lui-même, sans recourir à des instructions spécialisées. Sans ces modes d'adressage, utiliser des tableaux demande d'utiliser du code automodifiant ou d'autres méthodes qui relèvent de la sorcellerie.
Pour faciliter ces parcours de tableaux, il existe des variantes de l'adressage précédent, qui incrémentent ou décrémentent automatiquement le pointeur à chaque lecture/écriture. Il s'agit des modes d''''adressages indirect avec auto-incrément''' (''register indirect autoincrement'') et '''indirect avec auto-décrément''' (''register indirect autodecrement''). Avec eux, le contenu du registre est incrémenté/décrémenté d'une valeur fixe automatiquement. Cela permet de passer directement à l’élément suivant ou précédent dans un tableau.
[[File:Adressage indirect à registre post-incrémenté.png|centre|vignette|upright=2|Adressage indirect à registre post-incrémenté]]
En théorie, il y a une différence entre les deux modes d'adressages. Avec l'adressage indirect avec auto-incrément, l'incrémentation se fait APRES l'envoi de l'adresse, après la lecture/écriture. On effectue l'accès mémoire avec le pointeur, avant d'incrémenter le pointeurs. Par contre, pour l'adresse indirect avec auto-décrément, on décrémente le pointeur AVANT de faire l'accès mémoire. Les deux comportements semblent incohérents, mais ils sont en réalité très intuitifs quand on sait comment se fait le parcours d'un tableau.
Le parcours d'un tableau du début vers la fin commence à l'adresse de base du tableau, celle de son premier élément. Aussi, si on place l'adresse de base du tableau dans un pointeur, on accède à l'adresse, puis ensuite on incrémente le tout. Pour le parcours en sens inverse, on commence à l'adresse de fin du tableau, celle à laquelle on quitte le tableau. Ce n'est pas l'adresse du dernier élément, mais l'adresse qui se situe immédiatement après. Pour obtenir l'adresse du dernier élément, on doit soustraire la taille de l'élément à l'adresse initiale. En clair, on décrémente l'adresse avant d'y accéder.
[[File:Adresses lors du parcours d'un tableau.png|centre|vignette|upright=2|Adresses lors du parcours d'un tableau]]
: Pour ceux qui savent déjà ce qu'est une exception matérielle : les deux modes d'adressage précédents posent des problèmes avec les exceptions matérielles. Le problème vient du fait que l'accès mémoire peut générer une exception matérielle, comme un problème de mémoire virtuelle ou autres. Dans ce cas, l'exception matérielle est gérée par une routine d'interruption, puis la routine se termine et l'instruction cause est ré-exécutée. Mais la ré-exécution doit tenir compte du fait que le pointeur initial a été incrémenté/décrémentée, et qu'il faut donc le faire revenir à sa valeur initiale. Quelques machines ont eu des problèmes d'implémentation de ce genre, notamment le DEC VAX et le Motorola 68000.
===Les modes d'adressage indirects indicés pour les tableaux===
Le mode d''''adressage base + indice''' est utilisé lors de l'accès à un tableau, quand on veut lire/écrire un élément de ce tableau. L'adressage base + indice, fournit à la fois l'adresse de base du tableau et l'indice de l’élément voulu. Les deux sont dans un registre, ce qui fait que ce mode d'adressage précise deux numéros/noms de registre. En clair, indice et pointeur sont localisés via adressage inhérent (à registre). Le calcul de l'adresse est effectué automatiquement par le processeur.
[[File:Base + Index.png|centre|vignette|upright=2|Base + Index]]
Il existe une variante qui permet de vérifier qu'on ne « déborde » pas du tableau, qu'on ne calcule pas une adresse en dehors du tableau, à cause d'un indice erroné, par exemple. Accéder à l’élément 25 d'un tableau de seulement 5 éléments n'a pas de sens et est souvent signe d'une erreur. Pour cela, l'instruction peut prendre deux opérandes supplémentaires (qui peuvent être constants ou placés dans deux registres). L'instruction BOUND sur le jeu d'instruction x86 en est un exemple. Si cette variante n'est pas supportée, on doit faire ces vérifications à la main.
Le mode d''''adressage absolu indexé''' (''indexed absolute'', ou encore ''base+offset'') est une variante de l'adressage précédent, qui est spécialisée pour les tableaux dont l'adresse de base est fixée une fois pour toute, elle est connue à la compilation. Les tableaux de ce genre sont assez rares : ils correspondent aux tableaux de taille fixe, déclarée dans la mémoire statique. L'adresse de base du tableau est alors précisée via une adresse mémoire et non un nom de registre. En clair, l'adresse de base est précisée par adressage absolu, alors que l'indice est précisé par adressage inhérent. À partir de ces deux données, l'adresse de l’élément du tableau est calculée, envoyée sur le bus d'adresse, et l’élément est récupéré.
[[File:Indexed Absolute.png|centre|vignette|upright=2|Indexed Absolute]]
Les deux modes d'adressage précédents sont appelés des '''modes d'adressage indicés''', car ils gèrent automatiquement l'indice. Ils existent en deux variantes, assez similaires. La première variante ne tient pas compte de la taille de la donnée. L'adresse de base est additionnée avec l'indice, rien de plus. Le programme doit donc incrémenter/décrémenter l'indice en tenant compte de la taille de la donnée. Par exemple, pour un tableau d'entiers de 4 octets chacun, l'indice doit être incrémenté/décrémenté par pas de 4. Pour éviter ce genre de choses, la seconde variante se charge automatiquement de gérer la taille de la donnée. Le programme doit donc incrémenter/décrémenter les indices normalement, par pas de 1, l'indice est automatiquement multiplié par la taille de la donnée. Cette dernière est généralement encodée dans l'instruction, qui gère des tailles de données basiques 1, 2, 4, 8 octets, guère plus.
Pour les deux modes d'adressage précédent, l'indice est généralement mémorisé dans un registre général, éventuellement un registre entier. Mais il a existé des processeurs qui utilisaient des '''registres d'indice''' spécialisés dans les indices de tableaux. Les processeurs en question sont des processeurs assez anciens, la technique n'est plus utilisée de nos jours.
===Les modes d'adressage indirect à décalage pour les enregistrements===
Après avoir vu les modes d'adressage pour les tableaux, nous allons voir des modes d'adressage spécialisés dans les enregistrements, aussi appelées structures en langage C. Elles regroupent plusieurs données, généralement une petite dizaine d'entiers/flottants/adresses. Mais le processeur ne peut pas manipuler ces enregistrements : il est obligé de manipuler les données élémentaires qui le constituent une par une. Pour cela, il doit calculer leur adresse, et les modes d'adressage qui vont suivre permettent de le faire automatiquement.
Une donnée a une place prédéterminée dans un enregistrement : elle est donc a une distance fixe du début de celui-ci. En clair, l'adresse d'un élément d'un enregistrement se calcule en ajoutant une constante à l'adresse de départ de l'enregistrement. Et c'est ce que fait le mode d''''adressage base + décalage'''. Il spécifie un registre et une constante. Le registre contient l'adresse du début de l'enregistrement, un pointeur vers l'enregistrement.
[[File:Base + offset.png|centre|vignette|upright=2|Base + offset]]
D'autres processeurs vont encore plus loin : ils sont capables de gérer des tableaux d'enregistrements ! Ce genre de prouesse est possible grâce au mode d''''adressage base + indice + décalage'''. Il calcule l'adresse du début de la structure avec le mode d'adressage base + indice avant d'ajouter une constante pour repérer la donnée dans la structure. Et le tout, en un seul mode d'adressage. Les processeurs x86 disposent d'un tel mode d'adressage.
==Les modes d'adressage pour les branchements==
Les modes d'adressage des branchements permettent de donner l'adresse de destination du branchement, l'adresse vers laquelle le processeur reprend son exécution si le branchement est pris. Les instructions de branchement peuvent avoir plusieurs modes d'adressages : implicite, direct, relatif ou indirect. Suivant le mode d'adressage, l'adresse de destination est
* soit dans l'instruction elle-même (adressage direct) ;
* soit dans un registre du processeur (branchement indirect) ;
* soit calculée à l’exécution (relatif) ;
* soit précisée de manière implicite.
Avec un '''branchement direct''', l'opérande est simplement l'adresse de l'instruction à laquelle on souhaite reprendre. Il s'agit d'une sorte d'équivalent à l'adressage immédiat/absolu, mais pour les branchements.
[[File:Branchement direct.png|centre|vignette|upright=2|Branchement direct.]]
Les '''branchements relatifs''' permettent de localiser la destination d'un branchement par rapport à l'instruction en cours. Cela permet de dire « le branchement est 50 instructions plus loin ». Avec eux, l'opérande est un nombre qu'il faut ajouter au registre d'adresse d'instruction pour tomber sur l'adresse voulue. On appelle ce nombre un décalage (offset).
[[File:Branchement relatif.png|centre|vignette|upright=2|Branchement relatif]]
Avec les '''branchements indirects''', l'adresse vers laquelle on souhaite brancher peut varier au cours de l’exécution du programme. Il s'agit d'une sorte d'équivalent à l'adressage indirect à registre, mais pour les branchements. Ces branchements sont souvent camouflés dans des fonctionnalités un peu plus complexes des langages de programmation (pointeurs sur fonction, chargement dynamique de bibliothèque, structure de contrôle <code>switch</code>, et ainsi de suite). Avec ces branchements, l'adresse vers laquelle on veut brancher est stockée dans un registre.
[[File:Branchement indirect.png|centre|vignette|upright=2|Branchement indirect]]
Les branchements implicites se limitent aux instructions de retour de fonction, qu'on abordera dans quelques chapitres. L'instruction SKIP est équivalente à un branchement relatif dont le décalage est de 2. Il n'est pas précisé dans l'instruction, mais est implicite.
==Les modes d'adressage pour les conditions/tests==
Pour rappel, les instructions à prédicats et les branchements s’exécutent si une certaine condition est remplie. Pour rappel, on peut faire face à deux cas. Dans le premier, le branchement et l'instruction de test sont fusionnés en une seule instruction. Dans le second, la condition en question est calculée par une instruction de test séparée du branchement. Dans les deux cas, on doit préciser quelle est la condition qu'on veut vérifier. Cela peut se faire de différentes manières, mais la principale est de numéroter les différentes conditions et d'incorporer celles-ci dans l'instruction de test ou le branchement.
Un second problème survient quand on a une instruction de test séparée du branchement. Le résultat de l'instruction de test est mémorisé soit dans un registre de prédicat (un registre de 1 bit qui mémorise le résultat d'une instruction de test), soit dans le registre d'état. Les instructions à prédicats et les branchements doivent alors préciser où se trouve le résultat de la condition adéquate, ce qui demande d'utiliser un mode d'adressage spécialisé.
Pour résumer peut faire face à trois possibilités :
* soit le branchement et le test sont fusionnés et l'adressage est implicite ;
* soit l'instruction de branchement doit préciser le registre à prédicat adéquat ;
* soit l'instruction de branchement doit préciser le bon bit dans le registre d'état.
===L'adressage des registres à prédicats===
La première possibilité est celle où les instructions de test écrivent leur résultat dans un registre à prédicat, qui est ensuite lu par le branchement. De tels processeurs ont généralement plusieurs registres à prédicats, chacun étant identifié par un nom de registre spécialisé. Les noms de registres pour les registres à prédicats sont séparés des noms des registres généraux/entiers/autres. Par exemple, on peut avoir des noms de registre à prédicats codés sur 4 bits (16 registres à prédicats), alors que les noms pour les autres registres sont codés sur 8 bits (256 registres généraux).
La distinction entre les deux se fait sur deux points : leur place dans l'instruction, et le fait que seuls certaines instructions utilisent les registres à prédicats. Typiquement, les noms de registre à prédicats sont utilisés uniquement par les instructions de test et les branchements. Ils sont utilisés comme registre de destination pour les instructions de test, et comme registre source (à lire) pour les branchements et instructions à prédicats. De plus, ils sont placés à des endroits très précis dans l'instruction, ce qui fait que le décodeur sait identifier facilement les noms de registres à prédicats des noms des autres registres.
===L'adressage du registre d'état===
La seconde possibilité est rencontrée sur les processeurs avec un registre d'état. Sur ces derniers, le registre d'état ne contient pas directement le résultat de la condition, mais celle-ci doit être calculée par le branchement ou l'instruction à prédicat. Et il faut alors préciser quels sont le ou les bits nécessaires pour connaitre le résultat de la condition. En conséquence, cela ne sert à rien de numéroter les bits du registre d'état comme on le ferais avec les registres à prédicats. A la place, l'instruction précise la condition à tester, que ce soit l'instruction de test ou le branchement. Et cela peut être fait de manière implicite ou explicite.
La première possibilité est d'indiquer explicitement la condition à tester dans l'instruction. Pour cela, les différentes conditions possibles sont numérotées, et ce numéro est incorporé dans l'instruction de branchement. L'instruction de branchement contient donc un opcode, une adresse de destination ou une référence vers celle-ci, puis un numéro qui indique quelle condition tester.
Un exemple assez intéressant est l'ARM1, le tout premier processeur de marque ARM. Sur l'ARM1, le registre d'état est mis à jour par une opération de comparaison, qui est en fait une soustraction déguisée. L'opération de comparaison soustrait deux opérandes A et B, met à jour le registre d'état en fonction du résultat, mais n'enregistre pas ce résultat dans un registre et s'en débarrasse.
Le registre d'état est un registre contenant 4 bits appelés N, Z, C et V : Z indique que le résultat de la soustraction vaut 0, N indique qu'il est négatif, C indique que le calcul a donné un débordement d'entier non-signé, et V indique qu'un débordement d'entier signé. Avec ces 4 bits, on peut obtenir 16 conditions possibles, certaines indiquant que les deux nombres sont égaux, différents, que l'un est supérieur à l'autre, inférieur, supérieur ou égal, etc. L'instruction précise laquelle de ces 16 conditions est nécessaire : l'instruction s’exécute si la condition est remplie, ne s’exécute pas sinon. Voici les 16 conditions possibles :
{|class="wikitable"
|-
! Code fournit par l’instruction
! Test sur le registre d'état
! Interprétation
|-
! 0000
| Z = 1
| Les deux nombres A et B sont égaux
|-
! 0001
| Z = 0
| Les deux nombres A et B sont différents
|-
! 0010
| C = 1
| Le calcul arithmétique précédent a généré un débordement non-signé
|-
! 0011
| C = 0
| Le calcul arithmétique précédent n'a pas généré un débordement non-signé
|-
! 0100
| N = 1
| Le résultat est négatif
|-
! 0101
| N = 0
| Le résultat est positif
|-
! 0110
| V = 1
| Le calcul arithmétique précédent a généré un débordement signé
|-
! 0111
| V = 0
| Le calcul arithmétique précédent n'a pas généré de débordement signé
|-
! 1000
| C = 1 et Z = 0
| A > B si A et B sont non-signés
|-
! 1001
| C = 0 ou Z = 1
| A <= B si A et B sont non-signés
|-
! 1010
| N = V
| A >= B si on calcule A - B
|-
! 1011
| N != V
| A < B si on calcule A - B
|-
! 1100
| Z = 0 et ( N = V )
| A > B si on calcule A - B
|-
! 1101
| Z = 1 ou ( N = 1 et V = 0 ) ou ( N = 0 et V = 1 )
| A <= B si on calcule A - B
|-
! 1110
| colspan="2" | L'instruction s’exécute toujours (pas de prédication).
|-
! 1111
| colspan="2" | L'instruction ne s’exécute jamais (NOP).
|}
La seconde possibilité est celle de l'adressage implicite du registre d'état. C'est le cas sur les processeurs x86, où il y a plusieurs instructions de branchements, chacune calculant une condition à partir des bits du registre d'état. Le registre d'état est similaire à celui de l'ARM1 vu plus haut. Le registre d'état des CPU x86 contient 5 bits : ZF indique que le résultat de la soustraction vaut 0, SF indique son signe, CF est le bit de retenue et de débordement non-signé, OF le bit de débordement signé, et PF le bit qui donne la parité du résultat. Il existe plusieurs branchements, certains testant un seul bit du registre d'état, d'autres une combinaison de plusieurs bits.
{|class="wikitable"
|-
! Instruction de branchement
! Bit du registre d'état testé
! Condition testée si on compare deux nombres A et B avec une instruction de test
|-
! JS (Jump if Sign)
| N = 1
| Le résultat est négatif
|-
! JNS (Jump if not Sign)
| N = 0
| Le résultat est positif
|-
! JO (Jump if Overflow)
| SF = 1 ou
| Le calcul arithmétique précédent a généré un débordement signé
|-
! JNO (Jump if Not Overflow)
| SF = 0
| Le calcul arithmétique précédent n'a pas généré de débordement signé
|-
! JNE (Jump if Not equal)
| Z = 1
| Les deux nombres A et B sont égaux
|-
! JE (Jump if Equal)
| Z = 0
| Les deux nombres A et B sont différents
|-
! JB (Jump if below)
| C = 1
| A < B, avec A et B non-signés
|-
! JAE (Jump if Above or Equal)
| C = 0
| A >= B, avec A et B non-signés
|-
! (JBE) Jump if below or equal
| C = 1 ou Z = 0
| A >= B si A et B sont non-signés
|-
! JA (Jump if above)
| C = 0 et Z = 0
| A > B si A et B sont non-signés
|-
! JL (Jump if less)
| SF != OF
| si A < BA et B sont signés
|-
! JGE (Jump if Greater or Equal)
| SF = OF
| si A >= BA et B sont signés
|-
! JLE (Jump if less or equal)
| SF != OF OU ZF = 1
| si A <= BA et B sont signés
|-
! JGE (Jump if Greater)
| SF = OF OU ZF = 0
| si A > B et B sont signés
|}
==Les modes d'adressage obsolètes : données et pointeurs==
Dans cette section, nous allons voir quelques modes d'adressage autrefois utilisés sur les ordinateurs historiques, d'avant les années 90. Ils ne sont plus utilisés aujourd'hui, aucun processeur ne les supporte. Cependant, ils reviendront plus tard dans ce cours, aussi je préfère en parler maintenant. De plus, certains ont un lien avec ce qui a été dit précédemment. Nous allons tout d'abord voir un mode d'adressage pour les pointeurs.
===Les modes d'adressage indirect mémoire===
Les modes d'adressage pour les pointeurs mémorisent les pointeurs dans des registres, mais il existe quelques modes d'adressage qui mémorisent les pointeurs en mémoire RAM, à une adresse bien précise. Avec de tels modes d'adressages, le processeur accède à une adresse mémoire pour récupérer le pointeur, et l'utiliser pour un second accès. L'accès est donc indirect, par l'intermédiaire du pointeur, d'où leur nom de '''modes d'adressage indirects mémoire'''. Ils étaient utilisés autrefois sur quelques vieux ordinateurs se débrouillaient sans registres pour les données/adresses.
Du moment qu'un mode d'adressage fournit une adresse mémoire, il peut être rendu indirect. Par exemple, on peut imaginer un mode d'adressage indirect Base + indice : la somme base + indice calcule l'adresse du pointeur et non l'adresse de la donnée. Un tel mode d'adressage serait utile pour gérer des tableaux de pointeurs. Tous les modes d'adressage précédents peuvent être modifiés de manière à ce que la donnée lue/écrite soit traitée comme un pointeur. Il y a donc un grand nombre de modes d'adressages indirects mémoire !
Le plus simple d'entre eux est le '''mode d'adressage absolu indirect'''. L'instruction incorpore une adresse mémoire, comme dans l'adressage absolu. Sauf qu'il s'agit d'un adressage indirect : l'adresse n'est pas l'adresse de la donnée voulue, mais l'adresse du pointeur qui pointe vers la donnée. Un exemple est le cas des instructions LOAD et STORE des ordinateurs Data General Nova. Les deux instructions existaient en deux versions, distinguées par un bit d'indirection. Si ce bit est à 0 dans l'opcode, alors l'instruction utilise le mode d'adressage absolu normal : l'adresse intégrée dans l'instruction est celle de la donnée. Mais s'il est à 1, alors l'adresse intégrée dans l'instruction est celle du pointeur.
Il a existé des '''modes d'adressage absolus indirects avec auto-incrément/auto-décrément''', où le pointeur est incrémenté ou décrémenté automatiquement lors de l'exécution de l'instruction. Les deux exemples les plus connus sont le PDP-8 et le Data General Nova, les autres exemples sont très rares. Sur le PDP-8, les adresses 8 à 15 avaient un comportement spécial. Quand on y accédait via adressage mémoire indirect, leur contenu était automatiquement incrémenté. Le Data General Nova avait la même chose, mais pour ses adresses 16 à 31 : les adresses 16 à 24 étaient incrémentées, celles de 25 à 31 étaient décrémentées.
D'autres architectures supportaient des '''modes d'adressages indirects récursifs'''. l'idée était simple : le mode d'adressage identifie un mot mémoire, qui peut être soit une donnée soit un pointeur. Le pointeur peut lui aussi pointer vers une donnée ou un pointeur, qui lui-même... Une véritable chaine de pointeurs pouvait être supportée avec une seule instruction. Pour cela, chaque mot mémoire avait un ''bit d'indirection'' qui disait si son contenu était un pointeur ou une donnée. Des exemples d'ordinateurs supportant un tel mode d'adressage sont le DEC PDP-10, les IBM 1620, le Data General Nova, l'HP 2100 series, and le NAR 2. Le PDP-10 gérait même l'usage de registres d'indice à chaque étape d'accès à un pointeur.
===Une curiosité historique : l'instruction ''Index next'' de l'Apollo Guidance Computer===
Les tout premiers ordinateurs ne supportaient aucun mode d’adressage indirect. L'utilisation de tableaux ou de structures de données était un véritable calvaire, qui se résolvait à grand coup de code automodifiant. Les instructions d'accès mémoire incorporaient une adresse, qui était incrémentée/décrémentée par code auto-modifiant. Les branchements indirects étaient eux aussi gérés de la même manière : l'adresse de destination été incorporée dans l'instruction via adressage absolu, mais était changée via code automodifiant. Et quelques rares processeurs ont incorporé des optimisations pour simplifier l'usage du code automodifiant, voire pour s'en passer.
Un exemple est celui de l'instruction '''''Index next instruction''''', que nous appellerons INI, qui a été utilisée sur des architectures comme l'Apollo Guidance Computer et quelques autres. Elle additionne une certaine valeur à l'instruction suivante. Elle est utilisée pour émuler un adressage absolu indicé : on utilise l'INI pour ajouter l'indice à l'instruction LOAD suivante. La valeur à ajouter est précisée via mode d'adressage absolu est lue depuis la mémoire. Par exemple, si l’instruction suivante est une instruction LOAD adresse 50, l'INI permet d'y ajouter la valeur 5, ce qui donne LOAD adresse 55.
L'instruction est aussi utilisée pour modifier des branchements : si l'instruction suivante est l'instruction JUMP à adresse 100, on peut la transformer en JUMP à adresse 150. Elle peut en théorie changer l'opcode d'une instruction, ce qui permet en théorie de faire des calculs différents suivant le résultat d'une condition. Mais ces cas d'utilisation étaient assez rares, ils étaient peu fréquents.
Un point important est que l'addition a lieu à l'intérieur du processeur, pas en mémoire RAM/ROM. Le mode d'adressage ne fait pas de code auto-modifiant, l'instruction modifiée reste la même qu'avant en mémoire RAM. Elle est altérée une fois chargée par le processeur, avant son exécution.
===L'adressage relatif pour les données===
L'adressage relatif est utilisé pour les branchements, pour calculer l'adresse de destination. Mais il peut en théorie être utilisé pour les données. En clair, l'adresse d'une donnée est calculée en ajoutant au ''program counter'' un décalage, un ''offset''. Il s'agit d'une variante de l'adressage base + décalage, sauf que l'adresse de base est le ''program counter''. Il était très utilisé sur les anciens ordinateurs, qui encodaient leurs instructions sur un faible nombre de bits et ne pouvaient pas encoder d'adresses complètes.
Un exemple est celui du PDP-8, encore lui. Il avait des instructions de 12 bits, et les adresses mémoire faisaient la même taille. L'opcode était codé sur 3 bits, l'instruction incorporait une adresse codée sur 7 bits, il restait deux bits pour le mode d’adressage. Vous remarquerez que les adresses font 12 bits, mais que les instructions incorporent seulement les 7 bits de poids faible. Il faut donc trouver les 5 bits de poids fort manquants. Pour cela, trois modes d'adressage sont possibles
* avec l'adressage absolu, les 5 bits de poids fort sont mis à 0 ;
* avec l'adressage PC-relatif, les 5 bits de poids fort étaient les 5 bits de poids fort du ''program counter'' ;
* avec l'adresse indirect mémoire, l'adresse 7 bit poijnte vers un pointeur en mémoire, qui fait 12 bits.
Les deux bits du mode d'adressage permettent d'indiquer quelle option est choisie. Le premier bit indiquait si le mode d'adressage utilisé était le mode d'adressage indirect mémoire ou non. Il était à 1 pour le mode d'adressage indirect mémoire, à 0 sinon. Le second bit indiquait s'il fallait utiliser l'adressage absolu (0) ou relatif au ''program counter''.
{|class="wikitable"
|-
! Opcode !! colspan="2" | Mode d'adressage !! Adresse mémoire
|-
| class="f_rouge" | Opcode || class="f_bleu" | Bit d'indirection || class="f_bleu" | Bit d'adressage absolu/relatif || class="f_vert" | Adresse mémoire
|-
| 3 bits || 1 bit || 1 bit || 7 bits
|}
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Le modèle mémoire : alignement et boutisme
| prevText=Le modèle mémoire : alignement et boutisme
| next=L'encodage des instructions
| nextText=L'encodage des instructions
}}
</noinclude>
4exrd7b2511ws3lulv3ongsdig3ktd7
Fonctionnement d'un ordinateur/L'accélération matérielle de la virtualisation
0
82429
763697
762950
2026-04-14T23:06:30Z
Mewtow
31375
/* Le fonctionnement du mode virtuel 8086 de base */
763697
wikitext
text/x-wiki
La virtualisation est l'ensemble des techniques qui permettent de faire tourner plusieurs systèmes d'exploitation en même temps. Le terme est polysémique, mais c'est la définition que nous allons utiliser pour ce qui nous intéresse. La virtualisation demande d'utiliser un logiciel dit '''hyperviseur''', qui permet de faire tourner plusieurs OS en même temps. Les hyperviseurs sont en quelque sorte situés sous le système d'exploitation. On peut les voir comme une sorte de sous-système d'exploitation, de système d'exploitation pour les systèmes d'exploitation. A ce propos, les OS virtualisés sont appelés des ''OS invités'', alors que l'hyperviseur est parfois appelé l'''OS hôte''.
[[File:Diagramme ArchiHyperviseur.png|centre|vignette|upright=2|Différence entre système d'exploitation et hyperviseur.]]
Les processeurs modernes intègrent des techniques pour accélérer la virtualisation. Les techniques en question sont assez variées, allant d'un niveau de privilège en plus des modes noyau/utilisateur à des modifications de la mémoire virtuelle, en passant à des modifications liées aux interruptions matérielles. Mais pour comprendre tout cela, il va falloir faire quelques explications sur la virtualisation elle-même.
==La virtualisation : généralités==
Pour faire tourner plusieurs OS en même temps, l'hyperviseur recourt à de nombreux stratagèmes. Il doit partager le processeur, la RAM et les entrées-sorties entre plusieurs OS. Le partage de la RAM demande concrètement des modifications assez légères de la mémoire virtuelle, qu'on verra en temps voulu.
Le partage du processeur est assez simple : les OS s'exécutent à tour de rôle sur le processeur, chacun pendant un temps défini, fixe. Une fois leur temps d'exécution passé, ils laissent la main à l'OS suivant. C'est l’hyperviseur qui s'occupe de tout cela, grâce à une interruption commandée à un ''timer''. Ce système de partage est une forme de '''multiplexage'''. A ce propos, il s'agit de la même solution que les OS utilisent pour faire tourner plusieurs programmes en même temps sur un processeur/cœur unique.
La gestion des entrées-sorties demande d'utiliser des techniques d''''émulation''', plus complexes à expliquer. Un hyperviseur peut parfaitement simuler du matériel qui n'est pas installé sur l'ordinateur. Par exemple, il peut faire croire à un OS qu'une carte réseau obsolète, datant d'il y a 20 ans, est installée sur l'ordinateur, alors que ce n'est pas le cas. Les commandes envoyées par l'OS à cette carte réseau fictive sont en réalité traitées par une vraie carte réseau par l’hyperviseur. Pour cela, l’hyperviseur intercepte les commandes envoyées aux entrées-sorties, et les traduit en commandes compatibles avec les entrées-sorties réellement installées sur l'ordinateur.
===Les machines virtuelles===
L'exemple avec la carte réseau est un cas particulier, l'hyperviseur faisant beaucoup de choses dans le genre. L'hyperviseur peut faire croire à l'ordinateur qu'il a plus ou moins de RAM que ce qui est réellement installé, par exemple. L'hyperviseur implémente ce qu'on appelle des '''machines virtuelles'''. Il s'agit d'une sorte de faux matériel, simulé par un logiciel. Un logiciel qui s’exécute dans une machine virtuelle aura l'impression de s’exécuter sur un matériel et/ou un O.S différent du matériel sur lequel il est en train de s’exécuter.
: Dans ce qui suit, nous parlerons de V.M (virtual machine), pour parler des machines virtuelles.
[[File:VM-monitor-french.png|centre|vignette|upright=2|Machines virtuelles avec la virtualisation.]]
Avec la virtualisation, plusieurs machines virtuelles sont gérées par l'hyperviseur, chacune étant réservée à un système d'exploitation. D'ailleurs, hyperviseurs sont parfois appelés des ''Virtual Machine Manager''. Nous utiliserons d'ailleurs l'abréviation VMM dans les schémas qui suivent. Il existe deux types d'hyperviseurs, qui sont nommés type 1 et type 2. Le premier type s'exécute directement sur le matériel, alors que le second est un logiciel qui s’exécute sur un OS normal. Pour ce qui nous concerne, la distinction n'est pas très importante.
[[File:Ansatz der Systemvirtualisierung zur Schaffung virtueller Betriebsumgebungen.png|centre|vignette|upright=2.5|Comparaison des différentes techniques de virtualisation : sans virtualisation à gauche, virtualisation de type 1 au milieu, de type 2 à droite.]]
La virtualisation est une des utilisations possibles, mais il y en a d'autres. La plus intéressante est celle des émulateurs. Ces derniers sont des logiciels qui permettent de simuler le fonctionnement d'anciens ordinateurs ou consoles de jeux. L'émulateur crée une machine virtuelle qui est réservée à un programme, à savoir le jeu à émuler.
Il y a une différence de taille entre un émulateur et un hyperviseur. L'émulation émule une machine virtuelle totalement différente, alors que la virtualisation doit émuler les entrées-sorties mais pas le processeur. Avec un hyperviseur, le système d'exploitation s'exécute sur le processeur lui-même. Le code de l'OS est compatible avec le processeur de la machine, dans le sens où il est compilé pour le jeu d'instruction du processeur de la machine réelle. Les instructions de l'OS s'exécutent directement.
Par contre, un émulateur exécute un jeu qui est programmé pour une machine dont le processeur est totalement différent. Le jeu d'instruction de la machine virtuelle et celui du vrai processeur n'est pas le même. L'émulation implique donc de traduire les instructions à exécuter dans la V.M par des instructions exécutables par le processeur. Ce n'est pas le cas avec la virtualisation, le jeu d'instruction étant le même.
===La méthode ''trap and emulate'' basique===
Pour être considéré comme un logiciel de virtualisation, un logiciel doit remplir trois critères :
* L'équivalence : l'O.S virtualisé et les applications qui s’exécutent doivent se comporter comme s'ils étaient exécutés sur le matériel de base, sans virtualisation.
* Le contrôle des ressources : tout accès au matériel par l'O.S virtualisé doit être intercepté par la machine virtuelle et intégralement pris en charge par l'hyperviseur.
* L'efficacité : La grande partie des instructions machines doit s’exécuter directement sur le processeur, afin de garder des performances correctes. Ce critère n'est pas respecté par les émulateurs matériels, qui doivent simuler le jeu d'instruction du processeur émulé.
Remplir ces trois critères est possible sous certaines conditions, établies par les théorèmes de Popek et Goldberg. Ces théorèmes se basent sur des hypothèses précises. De fait, la portée de ces théorèmes est limitée, notamment pour le critère de performance. Ils partent notamment du principe que l'ordinateur utilise la segmentation pour la mémoire virtuelle, et non la pagination. Il part aussi du principe que les interruptions ont un cout assez faible, qu'elles sont assez rares. Mais laissons ces détails de côté, le cœur de ces théorèmes repose sur une hypothèse simple : la présence de différents types d'instructions machines.
Pour rappel, il faut distinguer les instructions privilégiées de celles qui ne le sont pas. Les instructions privilégiées ne peuvent s'exécuter que en mode noyau, les programmes en mode utilisateur ne peuvent pas les exécuter. Parmi les instructions privilégiées on peut distinguer un sous-groupe appelé les '''instructions systèmes'''. Le premier type regroupe les '''instructions d'accès aux entrées-sorties''', aussi appelées instructions sensibles à la configuration. Le second type est celui des '''instructions de configuration du processeur''', qui agissent sur les registres de contrôle du processeur, aussi appelées instructions sensibles au comportement. Elles servent notamment à gérer la mémoire virtuelle, mais pas que.
La théorie de Popek et Goldberg dit qu'il est possible de virtualiser un O.S à une condition : que les instructions systèmes soient toutes des instructions privilégiées, c’est-à-dire exécutables seulement en mode noyau. Virtualiser un O.S demande simplement de le démarrer en mode utilisateur. Quand l'O.S fait un accès au matériel, il le fait via une instruction privilégiée. Vu que l'OS est en mode utilisateur, cela déclenche une exception matérielle, qui émule l'instruction privilégiée.
L'hyperviseur n'est ni plus ni moins qu'un ensemble de routines d'interruptions, chaque routine simulant le fonctionnement du matériel émulé. Par exemple, un accès au disque dur sera émulé par une routine d'interruption, qui utilisera les appels systèmes fournit par l'OS pour accéder au disque dur réellement présent dans l'ordinateur. Cette méthode est souvent appelée la méthode ''trap and emulate''.
[[File:Virtualisation avec la méthode trap-and-emulate.png|centre|vignette|upright=2.0|Virtualisation avec la méthode trap-and-emulate]]
La méthode ''trap and emulate'' ne fonctionne que si certaines contraintes sont respectées. Un premier problème est que beaucoup de jeux d'instructions anciens ne respectent pas la règle "les instructions systèmes sont toutes privilégiées". Par exemple, ce n'est pas le cas sur les processeurs x86 32 bits. Sur ces CPU, les instructions qui manipulent les drapeaux d'interruption ne sont pas toutes des instructions privilégiées, idem pour les instructions qui manipulent les registres de segmentation, celles liées aux ''call gates'', etc. A cause de cela, il est impossible d'utiliser la méthode du ''trap and emulate''. La seule solution qui ne requiert pas de techniques matérielles est de traduire à la volée les instructions systèmes problématiques en appels systèmes équivalents, grâce à des techniques de '''réécriture de code'''.
Enfin, certaines instructions dites '''sensibles au contexte''' ont un comportement différent entre le mode noyau et le mode utilisateur. En présence de telles instructions, la méthode ''trap and emulate'' ne fonctionne tout simplement pas. Grâce à ces instructions, le système d’exploitation ou un programme applicatif peut savoir s'il s'exécute en mode utilisateur ou noyau, ou hyperviseur, ou autre.
La virtualisation impose l'usage de la mémoire virtuelle, sans quoi plusieurs OS ne peuvent pas se partager la même mémoire physique. De plus, il ne faut pas que la mémoire physique, non-virtuelle, puisse être adressée directement. Et cette contrainte est violée, par exemple sur les architectures MIPS qui exposent des portions de la mémoire physique dans certaines zones fixées à l'avance de la mémoire virtuelle. L'OS est compilé pour utiliser ces zones de mémoire pour accéder aux entrées-sorties mappées en mémoire, entre autres. En théorie, on peut passer outre le problème en marquant ces zones de mémoire comme inaccessibles, toute lecture/écriture à ces adresses déclenche alors une exception traitée par l'hyperviseur. Mais le cout en performance est alors trop important.
Quelques hyperviseurs ont été conçus pour les architectures MIPS, dont le projet de recherche DISCO, mais ils ne fonctionnaient qu'avec des systèmes d'exploitation recompilés, de manière à passer outre ce problème. Les OS étaient recompilés afin de ne pas utiliser les zones mémoire problématiques. De plus, les OS étaient modifiés pour améliorer les performances en virtualisation. Les OS disposaient notamment d'appels systèmes spéciaux, appelés des ''hypercalls'', qui exécutaient des routines de l'hyperviseur directement. Les appels systèmes faisant appel à des instructions systèmes étaient ainsi remplacés par des appels système appelant directement l'hyperviseur. Le fait de modifier l'OS pour qu'il communique avec un hyperviseur, dont il a connaissance de l'existence, s'appelle la '''para-virtualisation'''.
[[File:Virtualization - Para vs Full.png|centre|vignette|upright=2.5|Virtualization - Para vs Full]]
==La virtualisation du processeur==
La virtualisation demande de partager le matériel entre plusieurs machines virtuelles. Précisément, il faut partager : le processeur, la mémoire RAM, les entrées-sorties. Les trois sont gérés différemment. Par exemple, la virtualisation des entrées-sorties est gérée par l’hyperviseur, parfois aidé par le ''chipset'' de la carte mère. Virtualiser des entrées-sorties demande d'émuler du matériel inexistant, mais aussi de dupliquer des entrées-sorties de manière à ce le matériel existe dans chaque VM. Partager la mémoire RAM entre plusieurs VM est assez simple avec la mémoire virtuelle, bien que cela demande quelques adaptations. Maintenant, voyons ce qu'il en est pour le processeur.
===Le niveau de privilège hyperviseur===
Sur certains CPU modernes, il existe un niveau de privilège appelé le '''niveau de privilège hyperviseur''' qui est utilisé pour les techniques de virtualisation. Le niveau de privilège hyperviseur est réservé à l’hyperviseur et il a des droits d'accès spécifiques. Il n'est cependant pas toujours activé. Par exemple, si aucun hyperviseur n'est installé sur la machine, le processeur dispose seulement des niveaux de privilège noyau et utilisateur, le mode noyau n'ayant alors aucune limitation précise. Mais quand le niveau de privilège hyperviseur est activé, une partie des manipulations est bloquée en mode noyau et n'est possible qu'en mode hyperviseur.
Le fonctionnement se base sur la différence entre instruction privilégiée et instruction système. Les instructions privilégiées peuvent s'exécuter en niveau noyau, alors que les instructions systèmes ne peuvent s'exécuter qu'en niveau hyperviseur. L'idée est que quand le noyau d'un OS exécute une instruction système, une exception matérielle est levée. L'exception bascule en mode hyperviseur et laisse la main à une routine de l'hyperviseur. L'hyperviseur fait alors des manipulations précise pour que l'instruction système donne le même résultat que si elle avait été exécutée par l'ordinateur simulé par la machine virtuelle.
[[File:Virtualisation avec un mode hyperviseur.png|centre|vignette|upright=2|Virtualisation avec un mode hyperviseur.]]
Il est ainsi possible d'émuler des entrées-sorties avec un cout en performance assez léger. Précisément, ce mode hyperviseur améliore les performances de la méthode du ''trap-and-emulate''. La méthode ''trap-and-emulate'' basique exécute une exception matérielle pour toute instruction privilégiée, qu'elle soit une instruction système ou non. Mais avec le niveau de privilège hyperviseur, seules les instructions systèmes déclenchent une exception, pas les instructions privilégiées non-système. Les performances sont donc un peu meilleures, pour un résultat identique. Après tout, les entrées-sorties et la configuration du processeur suffisent à émuler une machine virtuelle, les autres instructions noyau ne le sont pas.
Sur les processeurs ARM, il est possible de configurer quelles instructions sont détournées vers le mode hyperviseur et celles qui restent en mode noyau. En clair, on peut configurer quelles sont les instructions systèmes et celles qui sont simplement privilégiées. Et il en est de même pour les interruptions : on peut configurer si elles exécutent la routine de l'OS normal en mode noyau, ou si elles déclenchent une exception matérielle qui redirige vers une routine de l’hyperviseur. En l'absence d'hyperviseur, toutes les interruptions redirigent vers la routine de l'OS normale, vers le mode noyau.
Il faut noter que le mode hyperviseur n'est compatible qu'avec les hyperviseurs de type 1, à savoir ceux qui s'exécutent directement sur le matériel. Par contre, elle n'est pas compatible avec les hyperviseurs de type 2, qui sont des logiciels qui s'exécutent comme tout autre logiciel, au-dessus d'un système d'exploitation sous-jacent.
===L'Intel VT-X et l'AMD-V===
Les processeurs ARM de version v8 et plus incorporent un mode hyperviseur, mais pas les processeurs x86. À la place, ils incorporent des technologies alternatives nommées Intel VT-X ou l'AMD-V. Les deux ajoutent de nouvelles instructions pour gérer l'entrée et la sortie d'un mode réservé à l’hyperviseur. Mais ce mode réservé à l'hyperviseur n'est pas un niveau de privilège comme l'est le mode hyperviseur.
L'Intel VT-X et l'AMD-V dupliquent le processeur en deux modes de fonctionnement : un mode racine pour l'hyperviseur, un mode non-racine pour l'OS et les applications. Fait important : les niveaux de privilège sont dupliqués eux aussi ! Par exemple, il y a un mode noyau racine et un mode noyau non-racine, idem pour le mode utilisateur, idem pour le mode système (pour le BIOS/UEFI). De même, les modes réel, protégé, v8086 ou autres, sont eux aussi dupliqués en un exemplaire racine et un exemplaire non-racine.
L'avantage est que les systèmes d'exploitation virtualisés s'exécutent bel et bien en mode noyau natif, l'hyperviseur a à sa disposition un mode noyau séparé. D'ailleurs, les deux modes ont des registres d'interruption différents. Le mode racine et le mode non-racine ont chacun leurs espaces d'adressage séparés de 64 bit, avec leur propre table des pages. Et cela demande des adaptations au niveau de la TLB.
La transition entre mode racine et non-racine se fait lorsque le processeur exécute une instruction système ou lors de certaines interruptions. Au minimum, toute exécution d'une instruction système fait commuter le processeur mode racine et lance l'exécution des routines de l’hyperviseur adéquates. Les interruptions matérielles et exceptions font aussi passer le CPU en mode racine, afin que l’hyperviseur puisse gérer le matériel. De plus, afin de gérer le partage de la mémoire entre OS, certains défauts de page déclenchent l'entrée en mode racine. Les ''hypercalls'' de la para-virtualisation sont supportés grâce à aux instructions ''vmcall'' et ''vmresume'' qui permettent respectivement d'appeler une routine de l’hyperviseur ou d'en sortir.
La transition demande de sauvegarder/restaurer les registres du processeur, comme avec les interruptions. Mais cette sauvegarde est réalisée automatiquement par le processeur, elle n'est pas faite par les routines de l'hyperviseur. L’implémentation de cette sauvegarde/restauration se fait surtout via le microcode du processeur, car elle demande beaucoup d'étapes. Elle est en conséquence très lente.
Le processeur sauvegarde l'état de chaque machine virtuelle en mémoire RAM, dans une structure de données appelée la ''Virtual Machine Control Structure'' (VMCS). Elle mémorise surtout les registres du processeur à l'instant t. Lorsque le processeur démarre l'exécution d'une VM sur le processeur, cette VMCS est recopiée dans les registres pour rétablir la VM à l'endroit où elle s'était arrêtée. Lorsque la VM est interrompue et doit laisser sa place à l'hyperviseur, les registres et l'état du processeur sont sauvegardés dans la VMCS adéquate.
==La virtualisation de la mémoire : mémoire virtuelle et MMU==
Avec la virtualisation, les différentes machines virtuelles, les différents OS doivent se partager la mémoire physique, en plus d'être isolés les uns des autres. L'idée est d'utiliser la mémoire virtuelle pour cela. L'espace d'adressage physique vu par chaque OS est en réalité un espace d'adressage fictif, qui ne correspond pas à la mémoire physique. Les adresses physiques manipulées par l'OS sont en réalité des adresses intermédiaires entre les adresses physiques liées à la RAM, et les adresses virtuelles vues par les processus. Pour les distinguer, nous parlerons d'adresses physiques de l'hôte pour parler des adresses de la RAM, et des adresses physiques invitées pour parler des adresses manipulées par les OS virtualisés.
Sans accélération matérielle, la traduction des adresses physiques invitées en adresses hôte est réalisée par une seconde table des pages, appelée la ''shadow page table'', ce qui donnerait '''table des pages cachée''' en français. La table des pages cachée est prise en charge par l'hyperviseur. Toute modification de la table des pages cachée est réalisée par l'hyperviseur, les OS ne savent même pas qu'elle existe.
[[File:Shadowpagetables.png|centre|vignette|upright=2|Table des pages cachée.]]
===La MMU et la virtualisation : les tables des pages emboitées===
Une autre solution demande un support matériel des tables des pages emboitées, à savoir qu'il y a un arbre de table des pages, chaque consultation de la première table des pages renvoie vers une seconde, qui renvoie vers une troisième, et ainsi de suite jusqu'à tomber sur la table des pages finale qui renvoie l'adresse physique réelle.
L'idée est l'utiliser une seule table des pages, mais d'ajouter un ou deux niveaux supplémentaires. Pour l'exemple, prenons le cas des processeurs x86. Sans virtualisation, l'OS utilise une table des pages de 4 niveaux. Avec, la table des pages a un niveau en plus, qui sont ajoutés à la fin de la dernière table des pages normale. Les niveaux ajoutés s'occupent de la traduction des adresses physiques invitées en adresses physiques hôte. On parle alors de '''table des pages étendues''' pour désigner ce nouveau format de table des pages conçu pour la virtualisation.
Il faut que le processeur soit modifié de manière à parcourir automatiquement les niveaux ajoutés, ce qui demande quelques modifications de la TLB et du ''page table walker''. Les modifications en question ne font que modifier le format normal de la table des pages, et sont donc assez triviales. Elles ont été implémentées sur les processeurs AMD et Intel. AMD a introduit les tables des pages étendues sur ses processeurs Opteron, destinés aux serveurs, avec sa technologie ''Rapid Virtualization Indexing''. Intel, quant à lui, a introduit la technologie sur les processeurs i3, i5 et i7, sous le nom ''Extended Page Tables''. Les processeurs ARM ne sont pas en reste avec la technologie ''Stage-2 page-tables'', qui est utilisée en mode hyperviseur.
===La virtualisation de l'IO-MMU===
Si la MMU du processeur est modifiée pour gérer des tables des pages étendues, il en est de même pour les IO-MMU des périphériques et contrôleurs DMA.Les périphériques doivent idéalement intégrer une IO-MMU pour faciliter la virtualisation. La raison est globalement la même que pour le partage de la mémoire. Les pilotes de périphériques utilisent des adresses qui sont des adresses physiques sans virtualisation, mais qui deviennent des adresses virtuelles avec. Quand le pilote de périphérique configure un contrôleur DMA, pour transférer des données de la RAM vers un périphérique, il utilisera des adresses virtuelles qu'il croit physique pour adresser les données en RAM.
Pour éviter tout problème, le contrôleur DMA doit traduire les adresses qu'il reçoit en adresses physiques. Pour cela, il y a besoin d'une IO-MMU intégrée au contrôleur DMA, qui est configurée par l'hyperviseur. Toute IO-MMU a sa propre table des pages et l'hyperviseur configure les table des pages pour chaque périphérique. Ainsi, le pilote de périphérique manipule des adresses virtuelles, qui sont traduites en adresses physiques directement par le matériel lui-même, sans intervention logicielle.
Pour gérer la virtualisation, on fait la même chose qu'avec une table des pages emboitée habituelle : on l'étend en ajoutant des niveaux. L'IO-MMU peut fonctionner dans un mode normal, sans virtualisation, où les adresses virtuelles reçues du ''driver'' sont traduite avec une table des pages normale, non-emboitée. Mais elle a aussi un mode virtualisation qui utilise des tables de pages étendues.
==La virtualisation des entrées-sorties==
Virtualiser les entrées-sorties est simple sur le principe. Un OS communique avec le matériel soit via des ports IO, soit avec des entrées-sorties mappées en mémoire. Le périphérique répond avec des interruptions ou via des transferts DMA. Virtualiser les périphériques demande alors d'émuler les ports IO, les entrées-sorties mappées en mémoire, le DMA et les interruptions.
===La virtualisation logicielle des interruptions===
Émuler les ports IO est assez simple, vu que l'OS lit ou écrit dedans grâce à des instructions IO spécialisées. Vu que ce sont des instructions système, la méthode ''trap and emulate'' suffit. Pour les entrées-sorties mappées en mémoire, l'hyperviseur a juste à marquer les adresses mémoires concernées comme étant réservées/non-allouées/autre. Tout accès à ces adresses lèvera une exception matérielle d'accès mémoire interdit, que l’hyperviseur intercepte et gère via ''trap and emulate''.
L'émulation du DMA est triviale, vu que l'hyperviseur a accès direct à celui-ci, sans compter que l'usage d'une IO-MMU résout beaucoup de problèmes. La gestion des interruptions matérielles, les fameuses IRQ, est quant à elle plus complexe. Les interruptions matérielles ne sont pas à prendre en compte pour toutes les machines virtuelles. Par exemple, si une machine virtuelle n'a pas de carte graphique, pas besoin qu'elle prenne en compte les interruptions provenant de la carte graphique. La gestion des interruptions matérielles n'est pas la même si l'ordinateur grée des cartes virtuelles ou s'il se débrouille avec une carte physique unique.
Lors d'une interruption matérielle, le processeur exécute la routine adéquate de l'hyperviseur. Celle-ci enregistre qu'il y a eu une IRQ et fait quelques traitements préliminaires. Ensuite, elle laisse la main au système d'exploitation concerné, qui exécute alors sa routine d'interruption. Une fois la routine de l'OS terminée, l'OS dit au contrôleur d'interruption qu'il a terminé son travail. Mais cela demande d'interagir avec le contrôleur d'interruption, ce qui déclenche une exception qui appelle l'hyperviseur. L'hyperviseur signale au contrôleur d'interruption que l'interruption matérielle a été traitée. Il rend alors définitivement la main au système d'exploitation. Le processus complet demande donc plusieurs changements entre mode hyperviseur et OS, ce qui est assez couteux en performances.
Vu que le matériel simulé varie d'une machine virtuelle à l'autre, chaque machine virtuelle a son propre vecteur d'interruption. Par exemple, si une machine virtuelle n'a pas de carte graphique son vecteur d'interruption ne pointera pas vers les routines d'interruption d'un quelconque GPU. L'hyperviseur gère les différents vecteurs d'interruption de chaque VM et traduit les interruptions reçues en interruptions destinées aux VM/OS.
Si la méthode ''trap and emulate'' fonctionne, ses performances ne sont cependant pas forcément au rendez-vous. Tous les matériels ne se prêtent pas tous bien à la virtualisation, surtout les périphériques anciens. Pour éliminer une partie de ces problèmes, il existe différentes techniques, accélérées en matériel ou non. Elles permettent aux machines virtuelles de communiquer directement avec les périphériques, sans passer par l'hyperviseur.
===La virtualisation des périphériques avec l'affectation directe===
Virtualiser les entrées-sorties avec de bonnes performances est plus complexe. En pratique, cela demande une intervention du matériel. Le ''chipset'' de la carte mère, les différents contrôleurs d'interruption et bien d'autres circuits doivent être modifiés. Diverses techniques permettent de faciliter le partage des entrées-sorties entre machines virtuelles.
La première est l''''affectation directe''', qui alloue un périphérique à une machine virtuelle et pas aux autres. Par exemple, il est possible d'assigner la carte graphique à une machine virtuelle tournant sur Windows, mais les autres machines virtuelles ne verront même pas la carte graphique. Même l'hyperviseur n'a pas accès directement à ce matériel. L'affectation directe est très utile sur les serveurs, qui disposent souvent de plusieurs cartes réseaux et peuvent en assigner une à chaque machine virtuelle. Mais dans la plupart des cas, elle ne marche pas. De plus, sur les périphériques sans IO-MMU, elle ouvre la porte à des attaques DMA, où une machine virtuelle accède à la mémoire physique de la machine en configurant le contrôleur DMA de son périphérique assigné.
L'affectation directe est certes limitée, mais elle se marie bien avec certaines de virtualisation matérielles, intégrées dans de nombreux périphériques. Il existe des périphériques qui sont capables de se virtualiser tout seuls, à savoir qu'ils peuvent se dédoubler en plusieurs '''périphériques virtuels'''. Par exemple, prenons une carte réseau avec cette propriété. Il n'y a qu'une seule carte réseau dans l'ordinateur, mais elle peut donner l'illusion qu'il y en a 8-16 d'installés dans l'ordinateur. Il faut alors faire la différence entre la carte réseau physique et les 8-16 cartes réseau virtuelles. L'idée est d'utiliser l'affectation directe, chaque machine virtuelle/OS ayant une carte réseau virtuelle d'affectée, avec affectation directe.
[[File:Virtualisation matérielle des périphériques.png|centre|vignette|upright=2|Virtualisation matérielle des périphériques]]
Pour les périphériques PCI-Express, le fait de se dupliquer en plusieurs périphériques virtuels est permis par la technologie '''''Single-root input/output virtualization''''', abrévié en SRIOV. Elle est beaucoup, utilisée sur les cartes réseaux, pour plusieurs raisons. Déjà, ce sont des périphériques beaucoup utilisés sur les serveurs, qui utilisent beaucoup la virtualisation. Dupliquer des cartes réseaux et utiliser l'affectation directe rend la configuration des serveurs bien plus simple. De plus, la plupart des cartes réseaux sont sous-utilisées, même par les serveurs. Une carte réseau est souvent utilisée à environ 10% de ses capacités par une VM unique, ce qui fait qu'utiliser 10 cartes réseaux virtuelles permet d'utiliser les capacités de la carte réseau à 100%.
Il est possible de faire une analogie entre les processeurs multithreadés et les périphériques virtuels. Un processeur multithreadé est dupliqué en plusieurs processeurs virtuels, un périphérique virtualisé est dupliqué en plusieurs périphériques virtuels. L'implémentation des deux techniques est similaire sur le principe, mais les détails varient selon qu'on parle d'une carte réseau, d'une carte graphique, d'une carte son, etc. Pour gérer plusieurs périphériques virtuels, le périphérique physique contient plusieurs copies de ses registres de commande/données, plusieurs files de commandes, etc. De plus, le périphérique physique contient divers circuits d'arbitrage, qui gèrent comment le matériel est utilisé. Ils donnent accès à tour de rôle à chaque VM aux ressources non-dupliquées.
[[File:Implémentation d'une carte réseau gérant plusieurs cartes réseaux virtuelles.png|centre|vignette|upright=2|Implémentation d'une carte réseau gérant plusieurs cartes réseaux virtuelles]]
Dans le cas le plus simple, le matériel traite les commandes provenant des différentes VM dans l'ordre d'arrivée, une par une, il n'y a pas d'arbitrage pour éviter qu'une VM monopolise le matériel. Plus évolué, le matériel peut faire de l'affectation au tour par tour, en traitant chaque VM dans l'ordre durant un certain temps. Le matériel peut aussi utiliser des algorithmes d'ordonnancement/répartition plus complexes. Par exemple, les cartes graphiques modernes utilisent des algorithmes de répartition/ordonnancement accélérés en matériel, implémentés dans le GPU lui-même.
===La virtualisation des interruptions===
La gestion des interruptions matérielles peut aussi être accélérée en matériel, en complément des techniques de périphériques virtuels vues plus haut. Par exemple, il est possible de gérer des ''exitless interrupts'', qui ne passent pas du tout par l'hyperviseur. Mais cela demande d'utiliser l'affectation directe, en complément de l'usage de périphériques virtuels.
Tout périphérique virtuel émet des interruptions distinctes des autres périphérique virtuel. Pour distinguer les interruptions provenant de cartes virtuelles de celles provenant de cartes physiques, on les désigne sous le terme d''''interruptions virtuelles'''. Une interruption virtuelle est destinée à une seule machine virtuelle : celle à laquelle est assignée la carte virtuelle. Les autres machines virtuelles ne reçoivent pas ces interruptions. Les interruptions virtuelles ne sont pas traitées par l'hyperviseur, seulement par l'OS de la machine virtuelle assignée.
Une subtilité a lieu sur les processeurs à plusieurs cœurs. Il est possible d'assigner un cœur à chaque machine virtuelle, possiblement plusieurs. Par exemple, un processeur octo-coeur peut exécuter 8 machines virtuelles simultanément. Avec l'affectation directe ou à tour de rôle, l'interruption matérielle est donc destinée à une machine virtuelle, donc à un cœur. L'IRQ doit donc être redirigée vers un cœur bien précis et ne pas être envoyée aux autres. Les contrôleurs d'interruption modernes déterminent à quelles machines virtuelles sont destinées telle ou telle interruption, et peuvent leur envoyer directement, sans passer par l'hyperviseur. Grâce à cela, l'affectation directe à tour de rôle ont de bonnes performances.
En plus de ce support des interruptions virtuelles, le contrôleur d'interruption peut aussi être virtualisé, à savoir être dupliqué en plusieurs '''contrôleurs d'interruption virtuels'''. Sur les systèmes à processeur x86, le contrôleur d'interruption virtualisé est l'APIC (''Advanced Programmable Interrupt Controller''). Diverses technologies de vAPIC, aussi dites d'APIC virtualisé, permettent à chaque machine virtuelle d'avoir une copie virtuelle de l'APIC. Pour ce faire, tous les registres de l'APIC sont dupliqués en autant d'exemplaires que d'APIC virtuels supportés. Il existe un équivalent sur les processeurs ARM, où le contrôleur d'interruption est nommé le ''Generic Interrupt Controller'' (GIC) et peut aussi être virtualisé.
La virtualisation de l'APIC permet d'éviter d'avoir à passer par l'hyperviseur pour gérer les interruptions. Par exemple, quand un OS veut prévenir qu'il a fini de traiter une interruption, il doit communiquer avec le contrôleur d'interruption. Sans virtualisation du contrôleur d'interruption, cela demande de passer par l'intermédiaire de l'hyperviseur. Mais s'il est virtualisé, l'OS peut communiquer directement avec le contrôleur d'interruption virtuel qui lui est associé, sans que l'hyperviseur n'ait à faire quoique ce soit. De plus, la virtualisation du contrôleur d'interruption permet de gérer des interruptions inter-processeurs dites postées, qui ne font pas appel à l'hyperviseur, ainsi que des interruptions virtuelles émises par les IO-MMU.
Sur les plateformes ARM, les ''timers'' sont aussi virtualisés.
==Annexe : le mode virtuel 8086 des premiers CPU Intel==
Les premiers processeurs x86 étaient rudimentaires. Le 8086 utilisait une forme très simple de segmentation, sans aucune forme de protection mémoire, ni même de mémoire virtuelle, dont le but était d'adresser plus de 64 kibioctets de mémoire avec un processeur 16 bits. Sur le processeur 286, la segmentation s'améliora et ajouta un support complet de la mémoire virtuelle et de la protection 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. Pour bien faire la différence, la segmentation du 8086 fut appelée le mode réel, et la nouvelle forme de segmentation fut appelée le mode protégé.
Les programmes conçus pour le mode réel ne pouvaient pas s'exécuter en mode protégé. En clair, tous les programmes conçus pour le 8086 devaient fonctionner en mode réel, qui était supporté sur le 286 et les processeurs suivant. Pour corriger les problèmes observés sur le 286, le 386 a ajouté un '''mode 8086 virtuel''', une technique de virtualisation qui permet à des programmes de s'exécuter en mode réel dans une machine virtuelle dédiée appelée la '''VM V86'''. Notez que nous utiliserons l'abréviation V86 pour parler du mode virtuel 8086, ainsi que de tout ce qui est lié à ce mode.
===La virtualisation du DOS et la mémoire étendue===
Utiliser le mode V86 demande d'avoir un programme 8086 à lancer, mais aussi d'utiliser un '''hyperviseur V86'''. L’hyperviseur V86 est un véritable hyperviseur, qui s’exécute en mode noyau, exécute des routines d'interruption, gère les exceptions matérielles, etc. Il réside en mémoire dans une zone non-adressable en mode réel, mais accessible en mode protégé, celui-ci permettant d'adresser plus de RAM. L'hyperviseur est forcément exécuté en mode protégé.
Le système d'exploitation DOS s'exécutait en mode réel, ce qui fait qu'il pouvait être émulé par le mode V86. Il était ainsi possible de lancer une ou plusieurs sessions DOS à partir d'un système d'exploitation multitâche comme Windows. Windows. Beaucoup de personnes nées avant les années 2000 ont sans doute profité de cette possibilité pour lancer des applications DOS sous Windows. Les applications étaient en réalité lancées dans une machine virtuelle grâce au mode V86. Windows implémentait un hyperviseur V86 de type 2, à savoir que c'était un logiciel qui s'exécutait sur un OS sous-jacent, ici
[[File:Hyperviseur.svg|centre|vignette|upright=2|Hyperviseur]]
Les applications DOS dans une VM V86 ne peuvent pas adresser plus d'un mébioctet de mémoire. L'ordinateur peut cependant avoir plus de mémoire RAM, notamment pour gérer l'hyperviseur V86. Diverses techniques permettaient aux applications DOS d'utiliser la mémoire au-delà du mébioctet, appelée la '''mémoire étendue'''. Les logiciels DOS accédaient à la mémoire étendue en passant par un intermédiaire logiciel, qui lui-même communiquait avec l'hyperviseur V86. L'intermédiaire est appelé le ''Extended Memory Manager'' (EMM), et il est concrètement implémenté par un driver sur DOS (HIMEM.SYS).
Les applications DOS ne pouvaient pas adresser la mémoire étendue, mais pouvaient échanger des données avec l'EMM. Les logiciels peuvent ainsi déplacer des données dans la mémoire étendue pour les garder au chaud, puis les rapatrier dans la mémoire conventionnelle quand ils en avaient besoin. L'intermédiaire ''Extended Memory Manager'' s'occupe d’échanger des données entre mémoire conventionnelle et mémoire étendue. Pour cela, il switche entre mode réel et protégé à la demande, quand il doit lire ou écrire en mémoire étendue.
Il ne faut pas confondre mémoire étendue et ''expanded memory''. Pour rappel, l'''expanded memory'' est un système de commutation de banque, qui autorise un va-et-vient entre une carte d'extension et une page de 64 kibioctets mappé en mémoire haute. Elle fonctionne sans mode protégé, sans virtualisation, sans mode V86. La mémoire étendue ne gère pas de commutation de banque et demande que la RAM en plus soit installée dans l'ordinateur, pas sur une carte d'extension.
Par contre, il est possible d'émuler l'''expanded memory'' sans carte d'extension, en utilisant la mémoire étendue. Quelques ''chipsets'' de carte mère intégraient des techniques cela. Une émulation logicielle était aussi possible. L'émulation logicielle se basait sur une réécriture de l'interruption 67h utilisée pour adresser la technologie ''expanded memory''. L'hyperviseur V86 pouvait s'en charger, il avait juste à réécrire son allocateur de mémoire pour gérer cette interruption et quelques autres détails.
===Le fonctionnement du mode virtuel 8086 de base===
En mode V86, la segmentation du mode protégé est désactivée, seule la segmentation du mode réel est utilisée. Il y a quelques subtilités liées à la ligne A20 du bus d'adresse, déjà abordées auparavant dans ce cours. Sur les CPU 286 et ultérieurs, le processeur peut adresser 1 mébioctet (2^20 adresses), plus 64 kibioctets qui ne sont pas adressables sur le 8086. Le tout permet donc d'adresser les adresses allant de 0 à 0x010FFEFH. Et ces adresses sont utilisées pour les programmes en mode réel. Les adresses au-delà de l'adresse 0x010FFEFH sont typiquement le lieu de résidence de l’hyperviseur en RAM.
Par contre, la pagination peut être activée par l’hyperviseur, afin d’exécuter plusieurs logiciels en mode réel simultanément. La mémoire virtuelle par pagination peut aussi être utile si l'ordinateur a peu de mémoire RAM, pas assez pour faire tourner le logiciel : l'espace d'adressage vu par le logiciel est un espace virtuel de grande taille, ce qui permet de lancer le logiciel, au prix de performances dégradées. Enfin, la gestion des entrées-sorties mappées en mémoire est aussi simplifiée. De plus, cela permettait d'adresser plus de mémoire RAM grâce aux adresses plus longues du mode protégé.
Le processeur est configuré en mode V86, le bit VM du registre d'état spécifique est mis à 1. Le processeur utilise ce bit lorsqu'une instruction utilise les registres de segments, afin de savoir comment calculer les adresses, le calcul n'étant pas le même en mode réel et en mode protégé. Quelques instructions machines dépendent aussi de la valeur de ce bit, mais cela est traité au décodage de l'instruction, et plus précisément dans le microcode.
La plupart des instructions sont en double dans le microcode : il y a une version utilisant la segmentation en mode réel, une autre utilisant la pagination segmentée du mode protégé. Pour être plus précis, ces instructions sont coupées en deux dans le microcode : un microcode qui lit une opérande et dépend de la mémoire virtuelle, un microcode qui exécute le reste de l'instruction. La première partie n'est pas la même suivant qu'on est en mode réel ou protégé. Mais la seconde partie est la même dans les deux modes et est partagée entre les deux. En clair, les instructions ont deux points d'entrées : un pour le mode réel, un autre pour le mode protégé. Les deux lisent la table des segments/pages, font des tests de protection mémoire, puis branchent vers le microcode partagé.
Pour faire la différence, un bit du processeur indique si le CPU est en mode réel ou protégé, et ce bit est utilisé pour adresser le microcode. Il est appelé le bit PE et vaut 1 en mode protégé, 0 en mode réel. Le mode virtuel 8086 combine de bit PE avec le bit VM. Le bit utilisé pour adresser le microcode est calculé avec l'équation logique suivante : <math>PE & \override{VM}</math>. Ainsi, le bit qui adresse le microcode est mis à 0 quand le bit VM est à 1, ce qui adresse le microcode du mode réel.
Il faut noter que dans le mode 8086 virtuel, les programmes peuvent utiliser les registres ajoutés sur le 386 et ultérieurs. Par exemple, le 8086 n'a que 4 registres de segment, alors que le 286 en a 6. Les programmes en mode 8086 virtuel peuvent utiliser les deux registres de segment supplémentaires. Il en est de même pour d'autres registres ajoutés par le 286, comme des registres de contrôle, des registres de debug, et quelques autres. Il en est de même pour les instructions ajoutées par le 286, le 386 et ultérieur, qui sont exécutables en mode virtuel 8086. Et elles sont nombreuses.
La compatibilité n'était pas parfaite, il y avait quelques petites différences entre ce mode V86 et le mode réel du 8086, idem avec le mode réel du 286. Mais la grande majorité des applications n'avait aucun problème. Les problèmes étaient concentrés sur quelques instructions précises, notamment celles avec un préfixe LOCK.
===Les ''Virtual-8086 mode extensions''===
A partir du processeur Pentium, les processeurs x86 ont introduit des optimisations du mode V86, afin de rendre la virtualisation plus rapide. L'ensemble de ces optimisations est regroupé sous le terme de '''''Virtual-8086 mode extensions''''', abrévié en VME. Les optimisations du VMA étaient, pour certaines, utiles au-delà de la virtualisation et étaient activables indépendamment du reste du VME.
Le VME introduisait des optimisations quant au traitement des interruptions, à savoir la gestion des interruptions virtuelles. De plus, le VME modifie la gestion de l'''interrupt flag'' du registre d'état. Pour rappel, ce bit permet d'activer ou de désactiver les interruptions masquables. Modifier le bit ''interrupt flag'' permettait de désactiver les interruptions masquables ou au contraire de les activer.
Il se trouve que ce bit était accesible par les programmes exécutés en mode réel, qui pouvaient en faire ce qu'ils voulaient. Le mode réel n'étant pas prévu pour la multi-programmation, ce n'était pas un problème. Mais en mode V86, toute modification de ce bit se répercute sur les autres VM en mode V86. Pour éviter les problèmes, le VME a ajouté de quoi virtualiser cet ''interrupt flag'', avec une copie par machine virtuelle V86. Chaque programme modifiait sa propre copie de l'''interrupt flag'' sans altérer celle des autres programmes exécutés en mode V86, et surtout sans déclencher une exception matérielle gérée par l'hyperviseur.
Bien qu'elles aient été introduites sur les processeurs Pentium, elles n'ont réellement été rendues publiques qu'après la sortie des processeurs de microarchitecture P6. Avant d'être rendue publique, la documentation du VME était une annexe de la documentation officielle, la fameuse annexe H. Elle était mentionnée dans la documentation officielle, mais était indisponible au grand public, seules quelques entreprises sous NDA y avait accès.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les coprocesseurs : FPU et IO
| prevText=Les coprocesseurs : FPU et IO
| next=Les ISA optimisés pour la compilation/interprétation
| nextText=Les ISA optimisés pour la compilation/interprétation
}}
</noinclude>
641kyhn0di268rfw6a2pjykiszjexu5
763698
763697
2026-04-14T23:07:49Z
Mewtow
31375
/* Le fonctionnement du mode virtuel 8086 de base */
763698
wikitext
text/x-wiki
La virtualisation est l'ensemble des techniques qui permettent de faire tourner plusieurs systèmes d'exploitation en même temps. Le terme est polysémique, mais c'est la définition que nous allons utiliser pour ce qui nous intéresse. La virtualisation demande d'utiliser un logiciel dit '''hyperviseur''', qui permet de faire tourner plusieurs OS en même temps. Les hyperviseurs sont en quelque sorte situés sous le système d'exploitation. On peut les voir comme une sorte de sous-système d'exploitation, de système d'exploitation pour les systèmes d'exploitation. A ce propos, les OS virtualisés sont appelés des ''OS invités'', alors que l'hyperviseur est parfois appelé l'''OS hôte''.
[[File:Diagramme ArchiHyperviseur.png|centre|vignette|upright=2|Différence entre système d'exploitation et hyperviseur.]]
Les processeurs modernes intègrent des techniques pour accélérer la virtualisation. Les techniques en question sont assez variées, allant d'un niveau de privilège en plus des modes noyau/utilisateur à des modifications de la mémoire virtuelle, en passant à des modifications liées aux interruptions matérielles. Mais pour comprendre tout cela, il va falloir faire quelques explications sur la virtualisation elle-même.
==La virtualisation : généralités==
Pour faire tourner plusieurs OS en même temps, l'hyperviseur recourt à de nombreux stratagèmes. Il doit partager le processeur, la RAM et les entrées-sorties entre plusieurs OS. Le partage de la RAM demande concrètement des modifications assez légères de la mémoire virtuelle, qu'on verra en temps voulu.
Le partage du processeur est assez simple : les OS s'exécutent à tour de rôle sur le processeur, chacun pendant un temps défini, fixe. Une fois leur temps d'exécution passé, ils laissent la main à l'OS suivant. C'est l’hyperviseur qui s'occupe de tout cela, grâce à une interruption commandée à un ''timer''. Ce système de partage est une forme de '''multiplexage'''. A ce propos, il s'agit de la même solution que les OS utilisent pour faire tourner plusieurs programmes en même temps sur un processeur/cœur unique.
La gestion des entrées-sorties demande d'utiliser des techniques d''''émulation''', plus complexes à expliquer. Un hyperviseur peut parfaitement simuler du matériel qui n'est pas installé sur l'ordinateur. Par exemple, il peut faire croire à un OS qu'une carte réseau obsolète, datant d'il y a 20 ans, est installée sur l'ordinateur, alors que ce n'est pas le cas. Les commandes envoyées par l'OS à cette carte réseau fictive sont en réalité traitées par une vraie carte réseau par l’hyperviseur. Pour cela, l’hyperviseur intercepte les commandes envoyées aux entrées-sorties, et les traduit en commandes compatibles avec les entrées-sorties réellement installées sur l'ordinateur.
===Les machines virtuelles===
L'exemple avec la carte réseau est un cas particulier, l'hyperviseur faisant beaucoup de choses dans le genre. L'hyperviseur peut faire croire à l'ordinateur qu'il a plus ou moins de RAM que ce qui est réellement installé, par exemple. L'hyperviseur implémente ce qu'on appelle des '''machines virtuelles'''. Il s'agit d'une sorte de faux matériel, simulé par un logiciel. Un logiciel qui s’exécute dans une machine virtuelle aura l'impression de s’exécuter sur un matériel et/ou un O.S différent du matériel sur lequel il est en train de s’exécuter.
: Dans ce qui suit, nous parlerons de V.M (virtual machine), pour parler des machines virtuelles.
[[File:VM-monitor-french.png|centre|vignette|upright=2|Machines virtuelles avec la virtualisation.]]
Avec la virtualisation, plusieurs machines virtuelles sont gérées par l'hyperviseur, chacune étant réservée à un système d'exploitation. D'ailleurs, hyperviseurs sont parfois appelés des ''Virtual Machine Manager''. Nous utiliserons d'ailleurs l'abréviation VMM dans les schémas qui suivent. Il existe deux types d'hyperviseurs, qui sont nommés type 1 et type 2. Le premier type s'exécute directement sur le matériel, alors que le second est un logiciel qui s’exécute sur un OS normal. Pour ce qui nous concerne, la distinction n'est pas très importante.
[[File:Ansatz der Systemvirtualisierung zur Schaffung virtueller Betriebsumgebungen.png|centre|vignette|upright=2.5|Comparaison des différentes techniques de virtualisation : sans virtualisation à gauche, virtualisation de type 1 au milieu, de type 2 à droite.]]
La virtualisation est une des utilisations possibles, mais il y en a d'autres. La plus intéressante est celle des émulateurs. Ces derniers sont des logiciels qui permettent de simuler le fonctionnement d'anciens ordinateurs ou consoles de jeux. L'émulateur crée une machine virtuelle qui est réservée à un programme, à savoir le jeu à émuler.
Il y a une différence de taille entre un émulateur et un hyperviseur. L'émulation émule une machine virtuelle totalement différente, alors que la virtualisation doit émuler les entrées-sorties mais pas le processeur. Avec un hyperviseur, le système d'exploitation s'exécute sur le processeur lui-même. Le code de l'OS est compatible avec le processeur de la machine, dans le sens où il est compilé pour le jeu d'instruction du processeur de la machine réelle. Les instructions de l'OS s'exécutent directement.
Par contre, un émulateur exécute un jeu qui est programmé pour une machine dont le processeur est totalement différent. Le jeu d'instruction de la machine virtuelle et celui du vrai processeur n'est pas le même. L'émulation implique donc de traduire les instructions à exécuter dans la V.M par des instructions exécutables par le processeur. Ce n'est pas le cas avec la virtualisation, le jeu d'instruction étant le même.
===La méthode ''trap and emulate'' basique===
Pour être considéré comme un logiciel de virtualisation, un logiciel doit remplir trois critères :
* L'équivalence : l'O.S virtualisé et les applications qui s’exécutent doivent se comporter comme s'ils étaient exécutés sur le matériel de base, sans virtualisation.
* Le contrôle des ressources : tout accès au matériel par l'O.S virtualisé doit être intercepté par la machine virtuelle et intégralement pris en charge par l'hyperviseur.
* L'efficacité : La grande partie des instructions machines doit s’exécuter directement sur le processeur, afin de garder des performances correctes. Ce critère n'est pas respecté par les émulateurs matériels, qui doivent simuler le jeu d'instruction du processeur émulé.
Remplir ces trois critères est possible sous certaines conditions, établies par les théorèmes de Popek et Goldberg. Ces théorèmes se basent sur des hypothèses précises. De fait, la portée de ces théorèmes est limitée, notamment pour le critère de performance. Ils partent notamment du principe que l'ordinateur utilise la segmentation pour la mémoire virtuelle, et non la pagination. Il part aussi du principe que les interruptions ont un cout assez faible, qu'elles sont assez rares. Mais laissons ces détails de côté, le cœur de ces théorèmes repose sur une hypothèse simple : la présence de différents types d'instructions machines.
Pour rappel, il faut distinguer les instructions privilégiées de celles qui ne le sont pas. Les instructions privilégiées ne peuvent s'exécuter que en mode noyau, les programmes en mode utilisateur ne peuvent pas les exécuter. Parmi les instructions privilégiées on peut distinguer un sous-groupe appelé les '''instructions systèmes'''. Le premier type regroupe les '''instructions d'accès aux entrées-sorties''', aussi appelées instructions sensibles à la configuration. Le second type est celui des '''instructions de configuration du processeur''', qui agissent sur les registres de contrôle du processeur, aussi appelées instructions sensibles au comportement. Elles servent notamment à gérer la mémoire virtuelle, mais pas que.
La théorie de Popek et Goldberg dit qu'il est possible de virtualiser un O.S à une condition : que les instructions systèmes soient toutes des instructions privilégiées, c’est-à-dire exécutables seulement en mode noyau. Virtualiser un O.S demande simplement de le démarrer en mode utilisateur. Quand l'O.S fait un accès au matériel, il le fait via une instruction privilégiée. Vu que l'OS est en mode utilisateur, cela déclenche une exception matérielle, qui émule l'instruction privilégiée.
L'hyperviseur n'est ni plus ni moins qu'un ensemble de routines d'interruptions, chaque routine simulant le fonctionnement du matériel émulé. Par exemple, un accès au disque dur sera émulé par une routine d'interruption, qui utilisera les appels systèmes fournit par l'OS pour accéder au disque dur réellement présent dans l'ordinateur. Cette méthode est souvent appelée la méthode ''trap and emulate''.
[[File:Virtualisation avec la méthode trap-and-emulate.png|centre|vignette|upright=2.0|Virtualisation avec la méthode trap-and-emulate]]
La méthode ''trap and emulate'' ne fonctionne que si certaines contraintes sont respectées. Un premier problème est que beaucoup de jeux d'instructions anciens ne respectent pas la règle "les instructions systèmes sont toutes privilégiées". Par exemple, ce n'est pas le cas sur les processeurs x86 32 bits. Sur ces CPU, les instructions qui manipulent les drapeaux d'interruption ne sont pas toutes des instructions privilégiées, idem pour les instructions qui manipulent les registres de segmentation, celles liées aux ''call gates'', etc. A cause de cela, il est impossible d'utiliser la méthode du ''trap and emulate''. La seule solution qui ne requiert pas de techniques matérielles est de traduire à la volée les instructions systèmes problématiques en appels systèmes équivalents, grâce à des techniques de '''réécriture de code'''.
Enfin, certaines instructions dites '''sensibles au contexte''' ont un comportement différent entre le mode noyau et le mode utilisateur. En présence de telles instructions, la méthode ''trap and emulate'' ne fonctionne tout simplement pas. Grâce à ces instructions, le système d’exploitation ou un programme applicatif peut savoir s'il s'exécute en mode utilisateur ou noyau, ou hyperviseur, ou autre.
La virtualisation impose l'usage de la mémoire virtuelle, sans quoi plusieurs OS ne peuvent pas se partager la même mémoire physique. De plus, il ne faut pas que la mémoire physique, non-virtuelle, puisse être adressée directement. Et cette contrainte est violée, par exemple sur les architectures MIPS qui exposent des portions de la mémoire physique dans certaines zones fixées à l'avance de la mémoire virtuelle. L'OS est compilé pour utiliser ces zones de mémoire pour accéder aux entrées-sorties mappées en mémoire, entre autres. En théorie, on peut passer outre le problème en marquant ces zones de mémoire comme inaccessibles, toute lecture/écriture à ces adresses déclenche alors une exception traitée par l'hyperviseur. Mais le cout en performance est alors trop important.
Quelques hyperviseurs ont été conçus pour les architectures MIPS, dont le projet de recherche DISCO, mais ils ne fonctionnaient qu'avec des systèmes d'exploitation recompilés, de manière à passer outre ce problème. Les OS étaient recompilés afin de ne pas utiliser les zones mémoire problématiques. De plus, les OS étaient modifiés pour améliorer les performances en virtualisation. Les OS disposaient notamment d'appels systèmes spéciaux, appelés des ''hypercalls'', qui exécutaient des routines de l'hyperviseur directement. Les appels systèmes faisant appel à des instructions systèmes étaient ainsi remplacés par des appels système appelant directement l'hyperviseur. Le fait de modifier l'OS pour qu'il communique avec un hyperviseur, dont il a connaissance de l'existence, s'appelle la '''para-virtualisation'''.
[[File:Virtualization - Para vs Full.png|centre|vignette|upright=2.5|Virtualization - Para vs Full]]
==La virtualisation du processeur==
La virtualisation demande de partager le matériel entre plusieurs machines virtuelles. Précisément, il faut partager : le processeur, la mémoire RAM, les entrées-sorties. Les trois sont gérés différemment. Par exemple, la virtualisation des entrées-sorties est gérée par l’hyperviseur, parfois aidé par le ''chipset'' de la carte mère. Virtualiser des entrées-sorties demande d'émuler du matériel inexistant, mais aussi de dupliquer des entrées-sorties de manière à ce le matériel existe dans chaque VM. Partager la mémoire RAM entre plusieurs VM est assez simple avec la mémoire virtuelle, bien que cela demande quelques adaptations. Maintenant, voyons ce qu'il en est pour le processeur.
===Le niveau de privilège hyperviseur===
Sur certains CPU modernes, il existe un niveau de privilège appelé le '''niveau de privilège hyperviseur''' qui est utilisé pour les techniques de virtualisation. Le niveau de privilège hyperviseur est réservé à l’hyperviseur et il a des droits d'accès spécifiques. Il n'est cependant pas toujours activé. Par exemple, si aucun hyperviseur n'est installé sur la machine, le processeur dispose seulement des niveaux de privilège noyau et utilisateur, le mode noyau n'ayant alors aucune limitation précise. Mais quand le niveau de privilège hyperviseur est activé, une partie des manipulations est bloquée en mode noyau et n'est possible qu'en mode hyperviseur.
Le fonctionnement se base sur la différence entre instruction privilégiée et instruction système. Les instructions privilégiées peuvent s'exécuter en niveau noyau, alors que les instructions systèmes ne peuvent s'exécuter qu'en niveau hyperviseur. L'idée est que quand le noyau d'un OS exécute une instruction système, une exception matérielle est levée. L'exception bascule en mode hyperviseur et laisse la main à une routine de l'hyperviseur. L'hyperviseur fait alors des manipulations précise pour que l'instruction système donne le même résultat que si elle avait été exécutée par l'ordinateur simulé par la machine virtuelle.
[[File:Virtualisation avec un mode hyperviseur.png|centre|vignette|upright=2|Virtualisation avec un mode hyperviseur.]]
Il est ainsi possible d'émuler des entrées-sorties avec un cout en performance assez léger. Précisément, ce mode hyperviseur améliore les performances de la méthode du ''trap-and-emulate''. La méthode ''trap-and-emulate'' basique exécute une exception matérielle pour toute instruction privilégiée, qu'elle soit une instruction système ou non. Mais avec le niveau de privilège hyperviseur, seules les instructions systèmes déclenchent une exception, pas les instructions privilégiées non-système. Les performances sont donc un peu meilleures, pour un résultat identique. Après tout, les entrées-sorties et la configuration du processeur suffisent à émuler une machine virtuelle, les autres instructions noyau ne le sont pas.
Sur les processeurs ARM, il est possible de configurer quelles instructions sont détournées vers le mode hyperviseur et celles qui restent en mode noyau. En clair, on peut configurer quelles sont les instructions systèmes et celles qui sont simplement privilégiées. Et il en est de même pour les interruptions : on peut configurer si elles exécutent la routine de l'OS normal en mode noyau, ou si elles déclenchent une exception matérielle qui redirige vers une routine de l’hyperviseur. En l'absence d'hyperviseur, toutes les interruptions redirigent vers la routine de l'OS normale, vers le mode noyau.
Il faut noter que le mode hyperviseur n'est compatible qu'avec les hyperviseurs de type 1, à savoir ceux qui s'exécutent directement sur le matériel. Par contre, elle n'est pas compatible avec les hyperviseurs de type 2, qui sont des logiciels qui s'exécutent comme tout autre logiciel, au-dessus d'un système d'exploitation sous-jacent.
===L'Intel VT-X et l'AMD-V===
Les processeurs ARM de version v8 et plus incorporent un mode hyperviseur, mais pas les processeurs x86. À la place, ils incorporent des technologies alternatives nommées Intel VT-X ou l'AMD-V. Les deux ajoutent de nouvelles instructions pour gérer l'entrée et la sortie d'un mode réservé à l’hyperviseur. Mais ce mode réservé à l'hyperviseur n'est pas un niveau de privilège comme l'est le mode hyperviseur.
L'Intel VT-X et l'AMD-V dupliquent le processeur en deux modes de fonctionnement : un mode racine pour l'hyperviseur, un mode non-racine pour l'OS et les applications. Fait important : les niveaux de privilège sont dupliqués eux aussi ! Par exemple, il y a un mode noyau racine et un mode noyau non-racine, idem pour le mode utilisateur, idem pour le mode système (pour le BIOS/UEFI). De même, les modes réel, protégé, v8086 ou autres, sont eux aussi dupliqués en un exemplaire racine et un exemplaire non-racine.
L'avantage est que les systèmes d'exploitation virtualisés s'exécutent bel et bien en mode noyau natif, l'hyperviseur a à sa disposition un mode noyau séparé. D'ailleurs, les deux modes ont des registres d'interruption différents. Le mode racine et le mode non-racine ont chacun leurs espaces d'adressage séparés de 64 bit, avec leur propre table des pages. Et cela demande des adaptations au niveau de la TLB.
La transition entre mode racine et non-racine se fait lorsque le processeur exécute une instruction système ou lors de certaines interruptions. Au minimum, toute exécution d'une instruction système fait commuter le processeur mode racine et lance l'exécution des routines de l’hyperviseur adéquates. Les interruptions matérielles et exceptions font aussi passer le CPU en mode racine, afin que l’hyperviseur puisse gérer le matériel. De plus, afin de gérer le partage de la mémoire entre OS, certains défauts de page déclenchent l'entrée en mode racine. Les ''hypercalls'' de la para-virtualisation sont supportés grâce à aux instructions ''vmcall'' et ''vmresume'' qui permettent respectivement d'appeler une routine de l’hyperviseur ou d'en sortir.
La transition demande de sauvegarder/restaurer les registres du processeur, comme avec les interruptions. Mais cette sauvegarde est réalisée automatiquement par le processeur, elle n'est pas faite par les routines de l'hyperviseur. L’implémentation de cette sauvegarde/restauration se fait surtout via le microcode du processeur, car elle demande beaucoup d'étapes. Elle est en conséquence très lente.
Le processeur sauvegarde l'état de chaque machine virtuelle en mémoire RAM, dans une structure de données appelée la ''Virtual Machine Control Structure'' (VMCS). Elle mémorise surtout les registres du processeur à l'instant t. Lorsque le processeur démarre l'exécution d'une VM sur le processeur, cette VMCS est recopiée dans les registres pour rétablir la VM à l'endroit où elle s'était arrêtée. Lorsque la VM est interrompue et doit laisser sa place à l'hyperviseur, les registres et l'état du processeur sont sauvegardés dans la VMCS adéquate.
==La virtualisation de la mémoire : mémoire virtuelle et MMU==
Avec la virtualisation, les différentes machines virtuelles, les différents OS doivent se partager la mémoire physique, en plus d'être isolés les uns des autres. L'idée est d'utiliser la mémoire virtuelle pour cela. L'espace d'adressage physique vu par chaque OS est en réalité un espace d'adressage fictif, qui ne correspond pas à la mémoire physique. Les adresses physiques manipulées par l'OS sont en réalité des adresses intermédiaires entre les adresses physiques liées à la RAM, et les adresses virtuelles vues par les processus. Pour les distinguer, nous parlerons d'adresses physiques de l'hôte pour parler des adresses de la RAM, et des adresses physiques invitées pour parler des adresses manipulées par les OS virtualisés.
Sans accélération matérielle, la traduction des adresses physiques invitées en adresses hôte est réalisée par une seconde table des pages, appelée la ''shadow page table'', ce qui donnerait '''table des pages cachée''' en français. La table des pages cachée est prise en charge par l'hyperviseur. Toute modification de la table des pages cachée est réalisée par l'hyperviseur, les OS ne savent même pas qu'elle existe.
[[File:Shadowpagetables.png|centre|vignette|upright=2|Table des pages cachée.]]
===La MMU et la virtualisation : les tables des pages emboitées===
Une autre solution demande un support matériel des tables des pages emboitées, à savoir qu'il y a un arbre de table des pages, chaque consultation de la première table des pages renvoie vers une seconde, qui renvoie vers une troisième, et ainsi de suite jusqu'à tomber sur la table des pages finale qui renvoie l'adresse physique réelle.
L'idée est l'utiliser une seule table des pages, mais d'ajouter un ou deux niveaux supplémentaires. Pour l'exemple, prenons le cas des processeurs x86. Sans virtualisation, l'OS utilise une table des pages de 4 niveaux. Avec, la table des pages a un niveau en plus, qui sont ajoutés à la fin de la dernière table des pages normale. Les niveaux ajoutés s'occupent de la traduction des adresses physiques invitées en adresses physiques hôte. On parle alors de '''table des pages étendues''' pour désigner ce nouveau format de table des pages conçu pour la virtualisation.
Il faut que le processeur soit modifié de manière à parcourir automatiquement les niveaux ajoutés, ce qui demande quelques modifications de la TLB et du ''page table walker''. Les modifications en question ne font que modifier le format normal de la table des pages, et sont donc assez triviales. Elles ont été implémentées sur les processeurs AMD et Intel. AMD a introduit les tables des pages étendues sur ses processeurs Opteron, destinés aux serveurs, avec sa technologie ''Rapid Virtualization Indexing''. Intel, quant à lui, a introduit la technologie sur les processeurs i3, i5 et i7, sous le nom ''Extended Page Tables''. Les processeurs ARM ne sont pas en reste avec la technologie ''Stage-2 page-tables'', qui est utilisée en mode hyperviseur.
===La virtualisation de l'IO-MMU===
Si la MMU du processeur est modifiée pour gérer des tables des pages étendues, il en est de même pour les IO-MMU des périphériques et contrôleurs DMA.Les périphériques doivent idéalement intégrer une IO-MMU pour faciliter la virtualisation. La raison est globalement la même que pour le partage de la mémoire. Les pilotes de périphériques utilisent des adresses qui sont des adresses physiques sans virtualisation, mais qui deviennent des adresses virtuelles avec. Quand le pilote de périphérique configure un contrôleur DMA, pour transférer des données de la RAM vers un périphérique, il utilisera des adresses virtuelles qu'il croit physique pour adresser les données en RAM.
Pour éviter tout problème, le contrôleur DMA doit traduire les adresses qu'il reçoit en adresses physiques. Pour cela, il y a besoin d'une IO-MMU intégrée au contrôleur DMA, qui est configurée par l'hyperviseur. Toute IO-MMU a sa propre table des pages et l'hyperviseur configure les table des pages pour chaque périphérique. Ainsi, le pilote de périphérique manipule des adresses virtuelles, qui sont traduites en adresses physiques directement par le matériel lui-même, sans intervention logicielle.
Pour gérer la virtualisation, on fait la même chose qu'avec une table des pages emboitée habituelle : on l'étend en ajoutant des niveaux. L'IO-MMU peut fonctionner dans un mode normal, sans virtualisation, où les adresses virtuelles reçues du ''driver'' sont traduite avec une table des pages normale, non-emboitée. Mais elle a aussi un mode virtualisation qui utilise des tables de pages étendues.
==La virtualisation des entrées-sorties==
Virtualiser les entrées-sorties est simple sur le principe. Un OS communique avec le matériel soit via des ports IO, soit avec des entrées-sorties mappées en mémoire. Le périphérique répond avec des interruptions ou via des transferts DMA. Virtualiser les périphériques demande alors d'émuler les ports IO, les entrées-sorties mappées en mémoire, le DMA et les interruptions.
===La virtualisation logicielle des interruptions===
Émuler les ports IO est assez simple, vu que l'OS lit ou écrit dedans grâce à des instructions IO spécialisées. Vu que ce sont des instructions système, la méthode ''trap and emulate'' suffit. Pour les entrées-sorties mappées en mémoire, l'hyperviseur a juste à marquer les adresses mémoires concernées comme étant réservées/non-allouées/autre. Tout accès à ces adresses lèvera une exception matérielle d'accès mémoire interdit, que l’hyperviseur intercepte et gère via ''trap and emulate''.
L'émulation du DMA est triviale, vu que l'hyperviseur a accès direct à celui-ci, sans compter que l'usage d'une IO-MMU résout beaucoup de problèmes. La gestion des interruptions matérielles, les fameuses IRQ, est quant à elle plus complexe. Les interruptions matérielles ne sont pas à prendre en compte pour toutes les machines virtuelles. Par exemple, si une machine virtuelle n'a pas de carte graphique, pas besoin qu'elle prenne en compte les interruptions provenant de la carte graphique. La gestion des interruptions matérielles n'est pas la même si l'ordinateur grée des cartes virtuelles ou s'il se débrouille avec une carte physique unique.
Lors d'une interruption matérielle, le processeur exécute la routine adéquate de l'hyperviseur. Celle-ci enregistre qu'il y a eu une IRQ et fait quelques traitements préliminaires. Ensuite, elle laisse la main au système d'exploitation concerné, qui exécute alors sa routine d'interruption. Une fois la routine de l'OS terminée, l'OS dit au contrôleur d'interruption qu'il a terminé son travail. Mais cela demande d'interagir avec le contrôleur d'interruption, ce qui déclenche une exception qui appelle l'hyperviseur. L'hyperviseur signale au contrôleur d'interruption que l'interruption matérielle a été traitée. Il rend alors définitivement la main au système d'exploitation. Le processus complet demande donc plusieurs changements entre mode hyperviseur et OS, ce qui est assez couteux en performances.
Vu que le matériel simulé varie d'une machine virtuelle à l'autre, chaque machine virtuelle a son propre vecteur d'interruption. Par exemple, si une machine virtuelle n'a pas de carte graphique son vecteur d'interruption ne pointera pas vers les routines d'interruption d'un quelconque GPU. L'hyperviseur gère les différents vecteurs d'interruption de chaque VM et traduit les interruptions reçues en interruptions destinées aux VM/OS.
Si la méthode ''trap and emulate'' fonctionne, ses performances ne sont cependant pas forcément au rendez-vous. Tous les matériels ne se prêtent pas tous bien à la virtualisation, surtout les périphériques anciens. Pour éliminer une partie de ces problèmes, il existe différentes techniques, accélérées en matériel ou non. Elles permettent aux machines virtuelles de communiquer directement avec les périphériques, sans passer par l'hyperviseur.
===La virtualisation des périphériques avec l'affectation directe===
Virtualiser les entrées-sorties avec de bonnes performances est plus complexe. En pratique, cela demande une intervention du matériel. Le ''chipset'' de la carte mère, les différents contrôleurs d'interruption et bien d'autres circuits doivent être modifiés. Diverses techniques permettent de faciliter le partage des entrées-sorties entre machines virtuelles.
La première est l''''affectation directe''', qui alloue un périphérique à une machine virtuelle et pas aux autres. Par exemple, il est possible d'assigner la carte graphique à une machine virtuelle tournant sur Windows, mais les autres machines virtuelles ne verront même pas la carte graphique. Même l'hyperviseur n'a pas accès directement à ce matériel. L'affectation directe est très utile sur les serveurs, qui disposent souvent de plusieurs cartes réseaux et peuvent en assigner une à chaque machine virtuelle. Mais dans la plupart des cas, elle ne marche pas. De plus, sur les périphériques sans IO-MMU, elle ouvre la porte à des attaques DMA, où une machine virtuelle accède à la mémoire physique de la machine en configurant le contrôleur DMA de son périphérique assigné.
L'affectation directe est certes limitée, mais elle se marie bien avec certaines de virtualisation matérielles, intégrées dans de nombreux périphériques. Il existe des périphériques qui sont capables de se virtualiser tout seuls, à savoir qu'ils peuvent se dédoubler en plusieurs '''périphériques virtuels'''. Par exemple, prenons une carte réseau avec cette propriété. Il n'y a qu'une seule carte réseau dans l'ordinateur, mais elle peut donner l'illusion qu'il y en a 8-16 d'installés dans l'ordinateur. Il faut alors faire la différence entre la carte réseau physique et les 8-16 cartes réseau virtuelles. L'idée est d'utiliser l'affectation directe, chaque machine virtuelle/OS ayant une carte réseau virtuelle d'affectée, avec affectation directe.
[[File:Virtualisation matérielle des périphériques.png|centre|vignette|upright=2|Virtualisation matérielle des périphériques]]
Pour les périphériques PCI-Express, le fait de se dupliquer en plusieurs périphériques virtuels est permis par la technologie '''''Single-root input/output virtualization''''', abrévié en SRIOV. Elle est beaucoup, utilisée sur les cartes réseaux, pour plusieurs raisons. Déjà, ce sont des périphériques beaucoup utilisés sur les serveurs, qui utilisent beaucoup la virtualisation. Dupliquer des cartes réseaux et utiliser l'affectation directe rend la configuration des serveurs bien plus simple. De plus, la plupart des cartes réseaux sont sous-utilisées, même par les serveurs. Une carte réseau est souvent utilisée à environ 10% de ses capacités par une VM unique, ce qui fait qu'utiliser 10 cartes réseaux virtuelles permet d'utiliser les capacités de la carte réseau à 100%.
Il est possible de faire une analogie entre les processeurs multithreadés et les périphériques virtuels. Un processeur multithreadé est dupliqué en plusieurs processeurs virtuels, un périphérique virtualisé est dupliqué en plusieurs périphériques virtuels. L'implémentation des deux techniques est similaire sur le principe, mais les détails varient selon qu'on parle d'une carte réseau, d'une carte graphique, d'une carte son, etc. Pour gérer plusieurs périphériques virtuels, le périphérique physique contient plusieurs copies de ses registres de commande/données, plusieurs files de commandes, etc. De plus, le périphérique physique contient divers circuits d'arbitrage, qui gèrent comment le matériel est utilisé. Ils donnent accès à tour de rôle à chaque VM aux ressources non-dupliquées.
[[File:Implémentation d'une carte réseau gérant plusieurs cartes réseaux virtuelles.png|centre|vignette|upright=2|Implémentation d'une carte réseau gérant plusieurs cartes réseaux virtuelles]]
Dans le cas le plus simple, le matériel traite les commandes provenant des différentes VM dans l'ordre d'arrivée, une par une, il n'y a pas d'arbitrage pour éviter qu'une VM monopolise le matériel. Plus évolué, le matériel peut faire de l'affectation au tour par tour, en traitant chaque VM dans l'ordre durant un certain temps. Le matériel peut aussi utiliser des algorithmes d'ordonnancement/répartition plus complexes. Par exemple, les cartes graphiques modernes utilisent des algorithmes de répartition/ordonnancement accélérés en matériel, implémentés dans le GPU lui-même.
===La virtualisation des interruptions===
La gestion des interruptions matérielles peut aussi être accélérée en matériel, en complément des techniques de périphériques virtuels vues plus haut. Par exemple, il est possible de gérer des ''exitless interrupts'', qui ne passent pas du tout par l'hyperviseur. Mais cela demande d'utiliser l'affectation directe, en complément de l'usage de périphériques virtuels.
Tout périphérique virtuel émet des interruptions distinctes des autres périphérique virtuel. Pour distinguer les interruptions provenant de cartes virtuelles de celles provenant de cartes physiques, on les désigne sous le terme d''''interruptions virtuelles'''. Une interruption virtuelle est destinée à une seule machine virtuelle : celle à laquelle est assignée la carte virtuelle. Les autres machines virtuelles ne reçoivent pas ces interruptions. Les interruptions virtuelles ne sont pas traitées par l'hyperviseur, seulement par l'OS de la machine virtuelle assignée.
Une subtilité a lieu sur les processeurs à plusieurs cœurs. Il est possible d'assigner un cœur à chaque machine virtuelle, possiblement plusieurs. Par exemple, un processeur octo-coeur peut exécuter 8 machines virtuelles simultanément. Avec l'affectation directe ou à tour de rôle, l'interruption matérielle est donc destinée à une machine virtuelle, donc à un cœur. L'IRQ doit donc être redirigée vers un cœur bien précis et ne pas être envoyée aux autres. Les contrôleurs d'interruption modernes déterminent à quelles machines virtuelles sont destinées telle ou telle interruption, et peuvent leur envoyer directement, sans passer par l'hyperviseur. Grâce à cela, l'affectation directe à tour de rôle ont de bonnes performances.
En plus de ce support des interruptions virtuelles, le contrôleur d'interruption peut aussi être virtualisé, à savoir être dupliqué en plusieurs '''contrôleurs d'interruption virtuels'''. Sur les systèmes à processeur x86, le contrôleur d'interruption virtualisé est l'APIC (''Advanced Programmable Interrupt Controller''). Diverses technologies de vAPIC, aussi dites d'APIC virtualisé, permettent à chaque machine virtuelle d'avoir une copie virtuelle de l'APIC. Pour ce faire, tous les registres de l'APIC sont dupliqués en autant d'exemplaires que d'APIC virtuels supportés. Il existe un équivalent sur les processeurs ARM, où le contrôleur d'interruption est nommé le ''Generic Interrupt Controller'' (GIC) et peut aussi être virtualisé.
La virtualisation de l'APIC permet d'éviter d'avoir à passer par l'hyperviseur pour gérer les interruptions. Par exemple, quand un OS veut prévenir qu'il a fini de traiter une interruption, il doit communiquer avec le contrôleur d'interruption. Sans virtualisation du contrôleur d'interruption, cela demande de passer par l'intermédiaire de l'hyperviseur. Mais s'il est virtualisé, l'OS peut communiquer directement avec le contrôleur d'interruption virtuel qui lui est associé, sans que l'hyperviseur n'ait à faire quoique ce soit. De plus, la virtualisation du contrôleur d'interruption permet de gérer des interruptions inter-processeurs dites postées, qui ne font pas appel à l'hyperviseur, ainsi que des interruptions virtuelles émises par les IO-MMU.
Sur les plateformes ARM, les ''timers'' sont aussi virtualisés.
==Annexe : le mode virtuel 8086 des premiers CPU Intel==
Les premiers processeurs x86 étaient rudimentaires. Le 8086 utilisait une forme très simple de segmentation, sans aucune forme de protection mémoire, ni même de mémoire virtuelle, dont le but était d'adresser plus de 64 kibioctets de mémoire avec un processeur 16 bits. Sur le processeur 286, la segmentation s'améliora et ajouta un support complet de la mémoire virtuelle et de la protection 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. Pour bien faire la différence, la segmentation du 8086 fut appelée le mode réel, et la nouvelle forme de segmentation fut appelée le mode protégé.
Les programmes conçus pour le mode réel ne pouvaient pas s'exécuter en mode protégé. En clair, tous les programmes conçus pour le 8086 devaient fonctionner en mode réel, qui était supporté sur le 286 et les processeurs suivant. Pour corriger les problèmes observés sur le 286, le 386 a ajouté un '''mode 8086 virtuel''', une technique de virtualisation qui permet à des programmes de s'exécuter en mode réel dans une machine virtuelle dédiée appelée la '''VM V86'''. Notez que nous utiliserons l'abréviation V86 pour parler du mode virtuel 8086, ainsi que de tout ce qui est lié à ce mode.
===La virtualisation du DOS et la mémoire étendue===
Utiliser le mode V86 demande d'avoir un programme 8086 à lancer, mais aussi d'utiliser un '''hyperviseur V86'''. L’hyperviseur V86 est un véritable hyperviseur, qui s’exécute en mode noyau, exécute des routines d'interruption, gère les exceptions matérielles, etc. Il réside en mémoire dans une zone non-adressable en mode réel, mais accessible en mode protégé, celui-ci permettant d'adresser plus de RAM. L'hyperviseur est forcément exécuté en mode protégé.
Le système d'exploitation DOS s'exécutait en mode réel, ce qui fait qu'il pouvait être émulé par le mode V86. Il était ainsi possible de lancer une ou plusieurs sessions DOS à partir d'un système d'exploitation multitâche comme Windows. Windows. Beaucoup de personnes nées avant les années 2000 ont sans doute profité de cette possibilité pour lancer des applications DOS sous Windows. Les applications étaient en réalité lancées dans une machine virtuelle grâce au mode V86. Windows implémentait un hyperviseur V86 de type 2, à savoir que c'était un logiciel qui s'exécutait sur un OS sous-jacent, ici
[[File:Hyperviseur.svg|centre|vignette|upright=2|Hyperviseur]]
Les applications DOS dans une VM V86 ne peuvent pas adresser plus d'un mébioctet de mémoire. L'ordinateur peut cependant avoir plus de mémoire RAM, notamment pour gérer l'hyperviseur V86. Diverses techniques permettaient aux applications DOS d'utiliser la mémoire au-delà du mébioctet, appelée la '''mémoire étendue'''. Les logiciels DOS accédaient à la mémoire étendue en passant par un intermédiaire logiciel, qui lui-même communiquait avec l'hyperviseur V86. L'intermédiaire est appelé le ''Extended Memory Manager'' (EMM), et il est concrètement implémenté par un driver sur DOS (HIMEM.SYS).
Les applications DOS ne pouvaient pas adresser la mémoire étendue, mais pouvaient échanger des données avec l'EMM. Les logiciels peuvent ainsi déplacer des données dans la mémoire étendue pour les garder au chaud, puis les rapatrier dans la mémoire conventionnelle quand ils en avaient besoin. L'intermédiaire ''Extended Memory Manager'' s'occupe d’échanger des données entre mémoire conventionnelle et mémoire étendue. Pour cela, il switche entre mode réel et protégé à la demande, quand il doit lire ou écrire en mémoire étendue.
Il ne faut pas confondre mémoire étendue et ''expanded memory''. Pour rappel, l'''expanded memory'' est un système de commutation de banque, qui autorise un va-et-vient entre une carte d'extension et une page de 64 kibioctets mappé en mémoire haute. Elle fonctionne sans mode protégé, sans virtualisation, sans mode V86. La mémoire étendue ne gère pas de commutation de banque et demande que la RAM en plus soit installée dans l'ordinateur, pas sur une carte d'extension.
Par contre, il est possible d'émuler l'''expanded memory'' sans carte d'extension, en utilisant la mémoire étendue. Quelques ''chipsets'' de carte mère intégraient des techniques cela. Une émulation logicielle était aussi possible. L'émulation logicielle se basait sur une réécriture de l'interruption 67h utilisée pour adresser la technologie ''expanded memory''. L'hyperviseur V86 pouvait s'en charger, il avait juste à réécrire son allocateur de mémoire pour gérer cette interruption et quelques autres détails.
===Le fonctionnement du mode virtuel 8086 de base===
En mode V86, la segmentation du mode protégé est désactivée, seule la segmentation du mode réel est utilisée. Il y a quelques subtilités liées à la ligne A20 du bus d'adresse, déjà abordées auparavant dans ce cours. Sur les CPU 286 et ultérieurs, le processeur peut adresser 1 mébioctet (2^20 adresses), plus 64 kibioctets qui ne sont pas adressables sur le 8086. Le tout permet donc d'adresser les adresses allant de 0 à 0x010FFEFH. Et ces adresses sont utilisées pour les programmes en mode réel. Les adresses au-delà de l'adresse 0x010FFEFH sont typiquement le lieu de résidence de l’hyperviseur en RAM.
Par contre, la pagination peut être activée par l’hyperviseur, afin d’exécuter plusieurs logiciels en mode réel simultanément. La mémoire virtuelle par pagination peut aussi être utile si l'ordinateur a peu de mémoire RAM, pas assez pour faire tourner le logiciel : l'espace d'adressage vu par le logiciel est un espace virtuel de grande taille, ce qui permet de lancer le logiciel, au prix de performances dégradées. Enfin, la gestion des entrées-sorties mappées en mémoire est aussi simplifiée. De plus, cela permettait d'adresser plus de mémoire RAM grâce aux adresses plus longues du mode protégé.
Le processeur est configuré en mode V86, le bit VM du registre d'état spécifique est mis à 1. Le processeur utilise ce bit lorsqu'une instruction utilise les registres de segments, afin de savoir comment calculer les adresses, le calcul n'étant pas le même en mode réel et en mode protégé. Quelques instructions machines dépendent aussi de la valeur de ce bit, mais cela est traité au décodage de l'instruction, et plus précisément dans le microcode.
La plupart des instructions sont en double dans le microcode : il y a une version utilisant la segmentation en mode réel, une autre utilisant la pagination segmentée du mode protégé. Pour être plus précis, ces instructions sont coupées en deux dans le microcode : un microcode qui lit une opérande et dépend de la mémoire virtuelle, un microcode qui exécute le reste de l'instruction. La première partie n'est pas la même suivant qu'on est en mode réel ou protégé. Mais la seconde partie est la même dans les deux modes et est partagée entre les deux. En clair, les instructions ont deux points d'entrées : un pour le mode réel, un autre pour le mode protégé. Les deux lisent la table des segments/pages, font des tests de protection mémoire, puis branchent vers le microcode partagé.
Pour faire la différence, un bit du processeur indique si le CPU est en mode réel ou protégé, et ce bit est utilisé pour adresser le microcode. Il est appelé le bit P et vaut 1 en mode protégé, 0 en mode réel. Le mode virtuel 8086 combine de bit PE avec le bit VM. Le bit utilisé pour adresser le microcode est calculé avec l'équation logique suivante : <math>\text{P} . \overline{\text{VM}}</math>. Ainsi, le bit qui adresse le microcode est mis à 0 quand le bit VM est à 1, ce qui adresse le microcode du mode réel.
Il faut noter que dans le mode 8086 virtuel, les programmes peuvent utiliser les registres ajoutés sur le 386 et ultérieurs. Par exemple, le 8086 n'a que 4 registres de segment, alors que le 286 en a 6. Les programmes en mode 8086 virtuel peuvent utiliser les deux registres de segment supplémentaires. Il en est de même pour d'autres registres ajoutés par le 286, comme des registres de contrôle, des registres de debug, et quelques autres. Il en est de même pour les instructions ajoutées par le 286, le 386 et ultérieur, qui sont exécutables en mode virtuel 8086. Et elles sont nombreuses.
La compatibilité n'était pas parfaite, il y avait quelques petites différences entre ce mode V86 et le mode réel du 8086, idem avec le mode réel du 286. Mais la grande majorité des applications n'avait aucun problème. Les problèmes étaient concentrés sur quelques instructions précises, notamment celles avec un préfixe LOCK.
===Les ''Virtual-8086 mode extensions''===
A partir du processeur Pentium, les processeurs x86 ont introduit des optimisations du mode V86, afin de rendre la virtualisation plus rapide. L'ensemble de ces optimisations est regroupé sous le terme de '''''Virtual-8086 mode extensions''''', abrévié en VME. Les optimisations du VMA étaient, pour certaines, utiles au-delà de la virtualisation et étaient activables indépendamment du reste du VME.
Le VME introduisait des optimisations quant au traitement des interruptions, à savoir la gestion des interruptions virtuelles. De plus, le VME modifie la gestion de l'''interrupt flag'' du registre d'état. Pour rappel, ce bit permet d'activer ou de désactiver les interruptions masquables. Modifier le bit ''interrupt flag'' permettait de désactiver les interruptions masquables ou au contraire de les activer.
Il se trouve que ce bit était accesible par les programmes exécutés en mode réel, qui pouvaient en faire ce qu'ils voulaient. Le mode réel n'étant pas prévu pour la multi-programmation, ce n'était pas un problème. Mais en mode V86, toute modification de ce bit se répercute sur les autres VM en mode V86. Pour éviter les problèmes, le VME a ajouté de quoi virtualiser cet ''interrupt flag'', avec une copie par machine virtuelle V86. Chaque programme modifiait sa propre copie de l'''interrupt flag'' sans altérer celle des autres programmes exécutés en mode V86, et surtout sans déclencher une exception matérielle gérée par l'hyperviseur.
Bien qu'elles aient été introduites sur les processeurs Pentium, elles n'ont réellement été rendues publiques qu'après la sortie des processeurs de microarchitecture P6. Avant d'être rendue publique, la documentation du VME était une annexe de la documentation officielle, la fameuse annexe H. Elle était mentionnée dans la documentation officielle, mais était indisponible au grand public, seules quelques entreprises sous NDA y avait accès.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les coprocesseurs : FPU et IO
| prevText=Les coprocesseurs : FPU et IO
| next=Les ISA optimisés pour la compilation/interprétation
| nextText=Les ISA optimisés pour la compilation/interprétation
}}
</noinclude>
5uhi3qu8osx8oivzrxk6nzxswlcm1l4
763699
763698
2026-04-14T23:11:52Z
Mewtow
31375
/* Le fonctionnement du mode virtuel 8086 de base */
763699
wikitext
text/x-wiki
La virtualisation est l'ensemble des techniques qui permettent de faire tourner plusieurs systèmes d'exploitation en même temps. Le terme est polysémique, mais c'est la définition que nous allons utiliser pour ce qui nous intéresse. La virtualisation demande d'utiliser un logiciel dit '''hyperviseur''', qui permet de faire tourner plusieurs OS en même temps. Les hyperviseurs sont en quelque sorte situés sous le système d'exploitation. On peut les voir comme une sorte de sous-système d'exploitation, de système d'exploitation pour les systèmes d'exploitation. A ce propos, les OS virtualisés sont appelés des ''OS invités'', alors que l'hyperviseur est parfois appelé l'''OS hôte''.
[[File:Diagramme ArchiHyperviseur.png|centre|vignette|upright=2|Différence entre système d'exploitation et hyperviseur.]]
Les processeurs modernes intègrent des techniques pour accélérer la virtualisation. Les techniques en question sont assez variées, allant d'un niveau de privilège en plus des modes noyau/utilisateur à des modifications de la mémoire virtuelle, en passant à des modifications liées aux interruptions matérielles. Mais pour comprendre tout cela, il va falloir faire quelques explications sur la virtualisation elle-même.
==La virtualisation : généralités==
Pour faire tourner plusieurs OS en même temps, l'hyperviseur recourt à de nombreux stratagèmes. Il doit partager le processeur, la RAM et les entrées-sorties entre plusieurs OS. Le partage de la RAM demande concrètement des modifications assez légères de la mémoire virtuelle, qu'on verra en temps voulu.
Le partage du processeur est assez simple : les OS s'exécutent à tour de rôle sur le processeur, chacun pendant un temps défini, fixe. Une fois leur temps d'exécution passé, ils laissent la main à l'OS suivant. C'est l’hyperviseur qui s'occupe de tout cela, grâce à une interruption commandée à un ''timer''. Ce système de partage est une forme de '''multiplexage'''. A ce propos, il s'agit de la même solution que les OS utilisent pour faire tourner plusieurs programmes en même temps sur un processeur/cœur unique.
La gestion des entrées-sorties demande d'utiliser des techniques d''''émulation''', plus complexes à expliquer. Un hyperviseur peut parfaitement simuler du matériel qui n'est pas installé sur l'ordinateur. Par exemple, il peut faire croire à un OS qu'une carte réseau obsolète, datant d'il y a 20 ans, est installée sur l'ordinateur, alors que ce n'est pas le cas. Les commandes envoyées par l'OS à cette carte réseau fictive sont en réalité traitées par une vraie carte réseau par l’hyperviseur. Pour cela, l’hyperviseur intercepte les commandes envoyées aux entrées-sorties, et les traduit en commandes compatibles avec les entrées-sorties réellement installées sur l'ordinateur.
===Les machines virtuelles===
L'exemple avec la carte réseau est un cas particulier, l'hyperviseur faisant beaucoup de choses dans le genre. L'hyperviseur peut faire croire à l'ordinateur qu'il a plus ou moins de RAM que ce qui est réellement installé, par exemple. L'hyperviseur implémente ce qu'on appelle des '''machines virtuelles'''. Il s'agit d'une sorte de faux matériel, simulé par un logiciel. Un logiciel qui s’exécute dans une machine virtuelle aura l'impression de s’exécuter sur un matériel et/ou un O.S différent du matériel sur lequel il est en train de s’exécuter.
: Dans ce qui suit, nous parlerons de V.M (virtual machine), pour parler des machines virtuelles.
[[File:VM-monitor-french.png|centre|vignette|upright=2|Machines virtuelles avec la virtualisation.]]
Avec la virtualisation, plusieurs machines virtuelles sont gérées par l'hyperviseur, chacune étant réservée à un système d'exploitation. D'ailleurs, hyperviseurs sont parfois appelés des ''Virtual Machine Manager''. Nous utiliserons d'ailleurs l'abréviation VMM dans les schémas qui suivent. Il existe deux types d'hyperviseurs, qui sont nommés type 1 et type 2. Le premier type s'exécute directement sur le matériel, alors que le second est un logiciel qui s’exécute sur un OS normal. Pour ce qui nous concerne, la distinction n'est pas très importante.
[[File:Ansatz der Systemvirtualisierung zur Schaffung virtueller Betriebsumgebungen.png|centre|vignette|upright=2.5|Comparaison des différentes techniques de virtualisation : sans virtualisation à gauche, virtualisation de type 1 au milieu, de type 2 à droite.]]
La virtualisation est une des utilisations possibles, mais il y en a d'autres. La plus intéressante est celle des émulateurs. Ces derniers sont des logiciels qui permettent de simuler le fonctionnement d'anciens ordinateurs ou consoles de jeux. L'émulateur crée une machine virtuelle qui est réservée à un programme, à savoir le jeu à émuler.
Il y a une différence de taille entre un émulateur et un hyperviseur. L'émulation émule une machine virtuelle totalement différente, alors que la virtualisation doit émuler les entrées-sorties mais pas le processeur. Avec un hyperviseur, le système d'exploitation s'exécute sur le processeur lui-même. Le code de l'OS est compatible avec le processeur de la machine, dans le sens où il est compilé pour le jeu d'instruction du processeur de la machine réelle. Les instructions de l'OS s'exécutent directement.
Par contre, un émulateur exécute un jeu qui est programmé pour une machine dont le processeur est totalement différent. Le jeu d'instruction de la machine virtuelle et celui du vrai processeur n'est pas le même. L'émulation implique donc de traduire les instructions à exécuter dans la V.M par des instructions exécutables par le processeur. Ce n'est pas le cas avec la virtualisation, le jeu d'instruction étant le même.
===La méthode ''trap and emulate'' basique===
Pour être considéré comme un logiciel de virtualisation, un logiciel doit remplir trois critères :
* L'équivalence : l'O.S virtualisé et les applications qui s’exécutent doivent se comporter comme s'ils étaient exécutés sur le matériel de base, sans virtualisation.
* Le contrôle des ressources : tout accès au matériel par l'O.S virtualisé doit être intercepté par la machine virtuelle et intégralement pris en charge par l'hyperviseur.
* L'efficacité : La grande partie des instructions machines doit s’exécuter directement sur le processeur, afin de garder des performances correctes. Ce critère n'est pas respecté par les émulateurs matériels, qui doivent simuler le jeu d'instruction du processeur émulé.
Remplir ces trois critères est possible sous certaines conditions, établies par les théorèmes de Popek et Goldberg. Ces théorèmes se basent sur des hypothèses précises. De fait, la portée de ces théorèmes est limitée, notamment pour le critère de performance. Ils partent notamment du principe que l'ordinateur utilise la segmentation pour la mémoire virtuelle, et non la pagination. Il part aussi du principe que les interruptions ont un cout assez faible, qu'elles sont assez rares. Mais laissons ces détails de côté, le cœur de ces théorèmes repose sur une hypothèse simple : la présence de différents types d'instructions machines.
Pour rappel, il faut distinguer les instructions privilégiées de celles qui ne le sont pas. Les instructions privilégiées ne peuvent s'exécuter que en mode noyau, les programmes en mode utilisateur ne peuvent pas les exécuter. Parmi les instructions privilégiées on peut distinguer un sous-groupe appelé les '''instructions systèmes'''. Le premier type regroupe les '''instructions d'accès aux entrées-sorties''', aussi appelées instructions sensibles à la configuration. Le second type est celui des '''instructions de configuration du processeur''', qui agissent sur les registres de contrôle du processeur, aussi appelées instructions sensibles au comportement. Elles servent notamment à gérer la mémoire virtuelle, mais pas que.
La théorie de Popek et Goldberg dit qu'il est possible de virtualiser un O.S à une condition : que les instructions systèmes soient toutes des instructions privilégiées, c’est-à-dire exécutables seulement en mode noyau. Virtualiser un O.S demande simplement de le démarrer en mode utilisateur. Quand l'O.S fait un accès au matériel, il le fait via une instruction privilégiée. Vu que l'OS est en mode utilisateur, cela déclenche une exception matérielle, qui émule l'instruction privilégiée.
L'hyperviseur n'est ni plus ni moins qu'un ensemble de routines d'interruptions, chaque routine simulant le fonctionnement du matériel émulé. Par exemple, un accès au disque dur sera émulé par une routine d'interruption, qui utilisera les appels systèmes fournit par l'OS pour accéder au disque dur réellement présent dans l'ordinateur. Cette méthode est souvent appelée la méthode ''trap and emulate''.
[[File:Virtualisation avec la méthode trap-and-emulate.png|centre|vignette|upright=2.0|Virtualisation avec la méthode trap-and-emulate]]
La méthode ''trap and emulate'' ne fonctionne que si certaines contraintes sont respectées. Un premier problème est que beaucoup de jeux d'instructions anciens ne respectent pas la règle "les instructions systèmes sont toutes privilégiées". Par exemple, ce n'est pas le cas sur les processeurs x86 32 bits. Sur ces CPU, les instructions qui manipulent les drapeaux d'interruption ne sont pas toutes des instructions privilégiées, idem pour les instructions qui manipulent les registres de segmentation, celles liées aux ''call gates'', etc. A cause de cela, il est impossible d'utiliser la méthode du ''trap and emulate''. La seule solution qui ne requiert pas de techniques matérielles est de traduire à la volée les instructions systèmes problématiques en appels systèmes équivalents, grâce à des techniques de '''réécriture de code'''.
Enfin, certaines instructions dites '''sensibles au contexte''' ont un comportement différent entre le mode noyau et le mode utilisateur. En présence de telles instructions, la méthode ''trap and emulate'' ne fonctionne tout simplement pas. Grâce à ces instructions, le système d’exploitation ou un programme applicatif peut savoir s'il s'exécute en mode utilisateur ou noyau, ou hyperviseur, ou autre.
La virtualisation impose l'usage de la mémoire virtuelle, sans quoi plusieurs OS ne peuvent pas se partager la même mémoire physique. De plus, il ne faut pas que la mémoire physique, non-virtuelle, puisse être adressée directement. Et cette contrainte est violée, par exemple sur les architectures MIPS qui exposent des portions de la mémoire physique dans certaines zones fixées à l'avance de la mémoire virtuelle. L'OS est compilé pour utiliser ces zones de mémoire pour accéder aux entrées-sorties mappées en mémoire, entre autres. En théorie, on peut passer outre le problème en marquant ces zones de mémoire comme inaccessibles, toute lecture/écriture à ces adresses déclenche alors une exception traitée par l'hyperviseur. Mais le cout en performance est alors trop important.
Quelques hyperviseurs ont été conçus pour les architectures MIPS, dont le projet de recherche DISCO, mais ils ne fonctionnaient qu'avec des systèmes d'exploitation recompilés, de manière à passer outre ce problème. Les OS étaient recompilés afin de ne pas utiliser les zones mémoire problématiques. De plus, les OS étaient modifiés pour améliorer les performances en virtualisation. Les OS disposaient notamment d'appels systèmes spéciaux, appelés des ''hypercalls'', qui exécutaient des routines de l'hyperviseur directement. Les appels systèmes faisant appel à des instructions systèmes étaient ainsi remplacés par des appels système appelant directement l'hyperviseur. Le fait de modifier l'OS pour qu'il communique avec un hyperviseur, dont il a connaissance de l'existence, s'appelle la '''para-virtualisation'''.
[[File:Virtualization - Para vs Full.png|centre|vignette|upright=2.5|Virtualization - Para vs Full]]
==La virtualisation du processeur==
La virtualisation demande de partager le matériel entre plusieurs machines virtuelles. Précisément, il faut partager : le processeur, la mémoire RAM, les entrées-sorties. Les trois sont gérés différemment. Par exemple, la virtualisation des entrées-sorties est gérée par l’hyperviseur, parfois aidé par le ''chipset'' de la carte mère. Virtualiser des entrées-sorties demande d'émuler du matériel inexistant, mais aussi de dupliquer des entrées-sorties de manière à ce le matériel existe dans chaque VM. Partager la mémoire RAM entre plusieurs VM est assez simple avec la mémoire virtuelle, bien que cela demande quelques adaptations. Maintenant, voyons ce qu'il en est pour le processeur.
===Le niveau de privilège hyperviseur===
Sur certains CPU modernes, il existe un niveau de privilège appelé le '''niveau de privilège hyperviseur''' qui est utilisé pour les techniques de virtualisation. Le niveau de privilège hyperviseur est réservé à l’hyperviseur et il a des droits d'accès spécifiques. Il n'est cependant pas toujours activé. Par exemple, si aucun hyperviseur n'est installé sur la machine, le processeur dispose seulement des niveaux de privilège noyau et utilisateur, le mode noyau n'ayant alors aucune limitation précise. Mais quand le niveau de privilège hyperviseur est activé, une partie des manipulations est bloquée en mode noyau et n'est possible qu'en mode hyperviseur.
Le fonctionnement se base sur la différence entre instruction privilégiée et instruction système. Les instructions privilégiées peuvent s'exécuter en niveau noyau, alors que les instructions systèmes ne peuvent s'exécuter qu'en niveau hyperviseur. L'idée est que quand le noyau d'un OS exécute une instruction système, une exception matérielle est levée. L'exception bascule en mode hyperviseur et laisse la main à une routine de l'hyperviseur. L'hyperviseur fait alors des manipulations précise pour que l'instruction système donne le même résultat que si elle avait été exécutée par l'ordinateur simulé par la machine virtuelle.
[[File:Virtualisation avec un mode hyperviseur.png|centre|vignette|upright=2|Virtualisation avec un mode hyperviseur.]]
Il est ainsi possible d'émuler des entrées-sorties avec un cout en performance assez léger. Précisément, ce mode hyperviseur améliore les performances de la méthode du ''trap-and-emulate''. La méthode ''trap-and-emulate'' basique exécute une exception matérielle pour toute instruction privilégiée, qu'elle soit une instruction système ou non. Mais avec le niveau de privilège hyperviseur, seules les instructions systèmes déclenchent une exception, pas les instructions privilégiées non-système. Les performances sont donc un peu meilleures, pour un résultat identique. Après tout, les entrées-sorties et la configuration du processeur suffisent à émuler une machine virtuelle, les autres instructions noyau ne le sont pas.
Sur les processeurs ARM, il est possible de configurer quelles instructions sont détournées vers le mode hyperviseur et celles qui restent en mode noyau. En clair, on peut configurer quelles sont les instructions systèmes et celles qui sont simplement privilégiées. Et il en est de même pour les interruptions : on peut configurer si elles exécutent la routine de l'OS normal en mode noyau, ou si elles déclenchent une exception matérielle qui redirige vers une routine de l’hyperviseur. En l'absence d'hyperviseur, toutes les interruptions redirigent vers la routine de l'OS normale, vers le mode noyau.
Il faut noter que le mode hyperviseur n'est compatible qu'avec les hyperviseurs de type 1, à savoir ceux qui s'exécutent directement sur le matériel. Par contre, elle n'est pas compatible avec les hyperviseurs de type 2, qui sont des logiciels qui s'exécutent comme tout autre logiciel, au-dessus d'un système d'exploitation sous-jacent.
===L'Intel VT-X et l'AMD-V===
Les processeurs ARM de version v8 et plus incorporent un mode hyperviseur, mais pas les processeurs x86. À la place, ils incorporent des technologies alternatives nommées Intel VT-X ou l'AMD-V. Les deux ajoutent de nouvelles instructions pour gérer l'entrée et la sortie d'un mode réservé à l’hyperviseur. Mais ce mode réservé à l'hyperviseur n'est pas un niveau de privilège comme l'est le mode hyperviseur.
L'Intel VT-X et l'AMD-V dupliquent le processeur en deux modes de fonctionnement : un mode racine pour l'hyperviseur, un mode non-racine pour l'OS et les applications. Fait important : les niveaux de privilège sont dupliqués eux aussi ! Par exemple, il y a un mode noyau racine et un mode noyau non-racine, idem pour le mode utilisateur, idem pour le mode système (pour le BIOS/UEFI). De même, les modes réel, protégé, v8086 ou autres, sont eux aussi dupliqués en un exemplaire racine et un exemplaire non-racine.
L'avantage est que les systèmes d'exploitation virtualisés s'exécutent bel et bien en mode noyau natif, l'hyperviseur a à sa disposition un mode noyau séparé. D'ailleurs, les deux modes ont des registres d'interruption différents. Le mode racine et le mode non-racine ont chacun leurs espaces d'adressage séparés de 64 bit, avec leur propre table des pages. Et cela demande des adaptations au niveau de la TLB.
La transition entre mode racine et non-racine se fait lorsque le processeur exécute une instruction système ou lors de certaines interruptions. Au minimum, toute exécution d'une instruction système fait commuter le processeur mode racine et lance l'exécution des routines de l’hyperviseur adéquates. Les interruptions matérielles et exceptions font aussi passer le CPU en mode racine, afin que l’hyperviseur puisse gérer le matériel. De plus, afin de gérer le partage de la mémoire entre OS, certains défauts de page déclenchent l'entrée en mode racine. Les ''hypercalls'' de la para-virtualisation sont supportés grâce à aux instructions ''vmcall'' et ''vmresume'' qui permettent respectivement d'appeler une routine de l’hyperviseur ou d'en sortir.
La transition demande de sauvegarder/restaurer les registres du processeur, comme avec les interruptions. Mais cette sauvegarde est réalisée automatiquement par le processeur, elle n'est pas faite par les routines de l'hyperviseur. L’implémentation de cette sauvegarde/restauration se fait surtout via le microcode du processeur, car elle demande beaucoup d'étapes. Elle est en conséquence très lente.
Le processeur sauvegarde l'état de chaque machine virtuelle en mémoire RAM, dans une structure de données appelée la ''Virtual Machine Control Structure'' (VMCS). Elle mémorise surtout les registres du processeur à l'instant t. Lorsque le processeur démarre l'exécution d'une VM sur le processeur, cette VMCS est recopiée dans les registres pour rétablir la VM à l'endroit où elle s'était arrêtée. Lorsque la VM est interrompue et doit laisser sa place à l'hyperviseur, les registres et l'état du processeur sont sauvegardés dans la VMCS adéquate.
==La virtualisation de la mémoire : mémoire virtuelle et MMU==
Avec la virtualisation, les différentes machines virtuelles, les différents OS doivent se partager la mémoire physique, en plus d'être isolés les uns des autres. L'idée est d'utiliser la mémoire virtuelle pour cela. L'espace d'adressage physique vu par chaque OS est en réalité un espace d'adressage fictif, qui ne correspond pas à la mémoire physique. Les adresses physiques manipulées par l'OS sont en réalité des adresses intermédiaires entre les adresses physiques liées à la RAM, et les adresses virtuelles vues par les processus. Pour les distinguer, nous parlerons d'adresses physiques de l'hôte pour parler des adresses de la RAM, et des adresses physiques invitées pour parler des adresses manipulées par les OS virtualisés.
Sans accélération matérielle, la traduction des adresses physiques invitées en adresses hôte est réalisée par une seconde table des pages, appelée la ''shadow page table'', ce qui donnerait '''table des pages cachée''' en français. La table des pages cachée est prise en charge par l'hyperviseur. Toute modification de la table des pages cachée est réalisée par l'hyperviseur, les OS ne savent même pas qu'elle existe.
[[File:Shadowpagetables.png|centre|vignette|upright=2|Table des pages cachée.]]
===La MMU et la virtualisation : les tables des pages emboitées===
Une autre solution demande un support matériel des tables des pages emboitées, à savoir qu'il y a un arbre de table des pages, chaque consultation de la première table des pages renvoie vers une seconde, qui renvoie vers une troisième, et ainsi de suite jusqu'à tomber sur la table des pages finale qui renvoie l'adresse physique réelle.
L'idée est l'utiliser une seule table des pages, mais d'ajouter un ou deux niveaux supplémentaires. Pour l'exemple, prenons le cas des processeurs x86. Sans virtualisation, l'OS utilise une table des pages de 4 niveaux. Avec, la table des pages a un niveau en plus, qui sont ajoutés à la fin de la dernière table des pages normale. Les niveaux ajoutés s'occupent de la traduction des adresses physiques invitées en adresses physiques hôte. On parle alors de '''table des pages étendues''' pour désigner ce nouveau format de table des pages conçu pour la virtualisation.
Il faut que le processeur soit modifié de manière à parcourir automatiquement les niveaux ajoutés, ce qui demande quelques modifications de la TLB et du ''page table walker''. Les modifications en question ne font que modifier le format normal de la table des pages, et sont donc assez triviales. Elles ont été implémentées sur les processeurs AMD et Intel. AMD a introduit les tables des pages étendues sur ses processeurs Opteron, destinés aux serveurs, avec sa technologie ''Rapid Virtualization Indexing''. Intel, quant à lui, a introduit la technologie sur les processeurs i3, i5 et i7, sous le nom ''Extended Page Tables''. Les processeurs ARM ne sont pas en reste avec la technologie ''Stage-2 page-tables'', qui est utilisée en mode hyperviseur.
===La virtualisation de l'IO-MMU===
Si la MMU du processeur est modifiée pour gérer des tables des pages étendues, il en est de même pour les IO-MMU des périphériques et contrôleurs DMA.Les périphériques doivent idéalement intégrer une IO-MMU pour faciliter la virtualisation. La raison est globalement la même que pour le partage de la mémoire. Les pilotes de périphériques utilisent des adresses qui sont des adresses physiques sans virtualisation, mais qui deviennent des adresses virtuelles avec. Quand le pilote de périphérique configure un contrôleur DMA, pour transférer des données de la RAM vers un périphérique, il utilisera des adresses virtuelles qu'il croit physique pour adresser les données en RAM.
Pour éviter tout problème, le contrôleur DMA doit traduire les adresses qu'il reçoit en adresses physiques. Pour cela, il y a besoin d'une IO-MMU intégrée au contrôleur DMA, qui est configurée par l'hyperviseur. Toute IO-MMU a sa propre table des pages et l'hyperviseur configure les table des pages pour chaque périphérique. Ainsi, le pilote de périphérique manipule des adresses virtuelles, qui sont traduites en adresses physiques directement par le matériel lui-même, sans intervention logicielle.
Pour gérer la virtualisation, on fait la même chose qu'avec une table des pages emboitée habituelle : on l'étend en ajoutant des niveaux. L'IO-MMU peut fonctionner dans un mode normal, sans virtualisation, où les adresses virtuelles reçues du ''driver'' sont traduite avec une table des pages normale, non-emboitée. Mais elle a aussi un mode virtualisation qui utilise des tables de pages étendues.
==La virtualisation des entrées-sorties==
Virtualiser les entrées-sorties est simple sur le principe. Un OS communique avec le matériel soit via des ports IO, soit avec des entrées-sorties mappées en mémoire. Le périphérique répond avec des interruptions ou via des transferts DMA. Virtualiser les périphériques demande alors d'émuler les ports IO, les entrées-sorties mappées en mémoire, le DMA et les interruptions.
===La virtualisation logicielle des interruptions===
Émuler les ports IO est assez simple, vu que l'OS lit ou écrit dedans grâce à des instructions IO spécialisées. Vu que ce sont des instructions système, la méthode ''trap and emulate'' suffit. Pour les entrées-sorties mappées en mémoire, l'hyperviseur a juste à marquer les adresses mémoires concernées comme étant réservées/non-allouées/autre. Tout accès à ces adresses lèvera une exception matérielle d'accès mémoire interdit, que l’hyperviseur intercepte et gère via ''trap and emulate''.
L'émulation du DMA est triviale, vu que l'hyperviseur a accès direct à celui-ci, sans compter que l'usage d'une IO-MMU résout beaucoup de problèmes. La gestion des interruptions matérielles, les fameuses IRQ, est quant à elle plus complexe. Les interruptions matérielles ne sont pas à prendre en compte pour toutes les machines virtuelles. Par exemple, si une machine virtuelle n'a pas de carte graphique, pas besoin qu'elle prenne en compte les interruptions provenant de la carte graphique. La gestion des interruptions matérielles n'est pas la même si l'ordinateur grée des cartes virtuelles ou s'il se débrouille avec une carte physique unique.
Lors d'une interruption matérielle, le processeur exécute la routine adéquate de l'hyperviseur. Celle-ci enregistre qu'il y a eu une IRQ et fait quelques traitements préliminaires. Ensuite, elle laisse la main au système d'exploitation concerné, qui exécute alors sa routine d'interruption. Une fois la routine de l'OS terminée, l'OS dit au contrôleur d'interruption qu'il a terminé son travail. Mais cela demande d'interagir avec le contrôleur d'interruption, ce qui déclenche une exception qui appelle l'hyperviseur. L'hyperviseur signale au contrôleur d'interruption que l'interruption matérielle a été traitée. Il rend alors définitivement la main au système d'exploitation. Le processus complet demande donc plusieurs changements entre mode hyperviseur et OS, ce qui est assez couteux en performances.
Vu que le matériel simulé varie d'une machine virtuelle à l'autre, chaque machine virtuelle a son propre vecteur d'interruption. Par exemple, si une machine virtuelle n'a pas de carte graphique son vecteur d'interruption ne pointera pas vers les routines d'interruption d'un quelconque GPU. L'hyperviseur gère les différents vecteurs d'interruption de chaque VM et traduit les interruptions reçues en interruptions destinées aux VM/OS.
Si la méthode ''trap and emulate'' fonctionne, ses performances ne sont cependant pas forcément au rendez-vous. Tous les matériels ne se prêtent pas tous bien à la virtualisation, surtout les périphériques anciens. Pour éliminer une partie de ces problèmes, il existe différentes techniques, accélérées en matériel ou non. Elles permettent aux machines virtuelles de communiquer directement avec les périphériques, sans passer par l'hyperviseur.
===La virtualisation des périphériques avec l'affectation directe===
Virtualiser les entrées-sorties avec de bonnes performances est plus complexe. En pratique, cela demande une intervention du matériel. Le ''chipset'' de la carte mère, les différents contrôleurs d'interruption et bien d'autres circuits doivent être modifiés. Diverses techniques permettent de faciliter le partage des entrées-sorties entre machines virtuelles.
La première est l''''affectation directe''', qui alloue un périphérique à une machine virtuelle et pas aux autres. Par exemple, il est possible d'assigner la carte graphique à une machine virtuelle tournant sur Windows, mais les autres machines virtuelles ne verront même pas la carte graphique. Même l'hyperviseur n'a pas accès directement à ce matériel. L'affectation directe est très utile sur les serveurs, qui disposent souvent de plusieurs cartes réseaux et peuvent en assigner une à chaque machine virtuelle. Mais dans la plupart des cas, elle ne marche pas. De plus, sur les périphériques sans IO-MMU, elle ouvre la porte à des attaques DMA, où une machine virtuelle accède à la mémoire physique de la machine en configurant le contrôleur DMA de son périphérique assigné.
L'affectation directe est certes limitée, mais elle se marie bien avec certaines de virtualisation matérielles, intégrées dans de nombreux périphériques. Il existe des périphériques qui sont capables de se virtualiser tout seuls, à savoir qu'ils peuvent se dédoubler en plusieurs '''périphériques virtuels'''. Par exemple, prenons une carte réseau avec cette propriété. Il n'y a qu'une seule carte réseau dans l'ordinateur, mais elle peut donner l'illusion qu'il y en a 8-16 d'installés dans l'ordinateur. Il faut alors faire la différence entre la carte réseau physique et les 8-16 cartes réseau virtuelles. L'idée est d'utiliser l'affectation directe, chaque machine virtuelle/OS ayant une carte réseau virtuelle d'affectée, avec affectation directe.
[[File:Virtualisation matérielle des périphériques.png|centre|vignette|upright=2|Virtualisation matérielle des périphériques]]
Pour les périphériques PCI-Express, le fait de se dupliquer en plusieurs périphériques virtuels est permis par la technologie '''''Single-root input/output virtualization''''', abrévié en SRIOV. Elle est beaucoup, utilisée sur les cartes réseaux, pour plusieurs raisons. Déjà, ce sont des périphériques beaucoup utilisés sur les serveurs, qui utilisent beaucoup la virtualisation. Dupliquer des cartes réseaux et utiliser l'affectation directe rend la configuration des serveurs bien plus simple. De plus, la plupart des cartes réseaux sont sous-utilisées, même par les serveurs. Une carte réseau est souvent utilisée à environ 10% de ses capacités par une VM unique, ce qui fait qu'utiliser 10 cartes réseaux virtuelles permet d'utiliser les capacités de la carte réseau à 100%.
Il est possible de faire une analogie entre les processeurs multithreadés et les périphériques virtuels. Un processeur multithreadé est dupliqué en plusieurs processeurs virtuels, un périphérique virtualisé est dupliqué en plusieurs périphériques virtuels. L'implémentation des deux techniques est similaire sur le principe, mais les détails varient selon qu'on parle d'une carte réseau, d'une carte graphique, d'une carte son, etc. Pour gérer plusieurs périphériques virtuels, le périphérique physique contient plusieurs copies de ses registres de commande/données, plusieurs files de commandes, etc. De plus, le périphérique physique contient divers circuits d'arbitrage, qui gèrent comment le matériel est utilisé. Ils donnent accès à tour de rôle à chaque VM aux ressources non-dupliquées.
[[File:Implémentation d'une carte réseau gérant plusieurs cartes réseaux virtuelles.png|centre|vignette|upright=2|Implémentation d'une carte réseau gérant plusieurs cartes réseaux virtuelles]]
Dans le cas le plus simple, le matériel traite les commandes provenant des différentes VM dans l'ordre d'arrivée, une par une, il n'y a pas d'arbitrage pour éviter qu'une VM monopolise le matériel. Plus évolué, le matériel peut faire de l'affectation au tour par tour, en traitant chaque VM dans l'ordre durant un certain temps. Le matériel peut aussi utiliser des algorithmes d'ordonnancement/répartition plus complexes. Par exemple, les cartes graphiques modernes utilisent des algorithmes de répartition/ordonnancement accélérés en matériel, implémentés dans le GPU lui-même.
===La virtualisation des interruptions===
La gestion des interruptions matérielles peut aussi être accélérée en matériel, en complément des techniques de périphériques virtuels vues plus haut. Par exemple, il est possible de gérer des ''exitless interrupts'', qui ne passent pas du tout par l'hyperviseur. Mais cela demande d'utiliser l'affectation directe, en complément de l'usage de périphériques virtuels.
Tout périphérique virtuel émet des interruptions distinctes des autres périphérique virtuel. Pour distinguer les interruptions provenant de cartes virtuelles de celles provenant de cartes physiques, on les désigne sous le terme d''''interruptions virtuelles'''. Une interruption virtuelle est destinée à une seule machine virtuelle : celle à laquelle est assignée la carte virtuelle. Les autres machines virtuelles ne reçoivent pas ces interruptions. Les interruptions virtuelles ne sont pas traitées par l'hyperviseur, seulement par l'OS de la machine virtuelle assignée.
Une subtilité a lieu sur les processeurs à plusieurs cœurs. Il est possible d'assigner un cœur à chaque machine virtuelle, possiblement plusieurs. Par exemple, un processeur octo-coeur peut exécuter 8 machines virtuelles simultanément. Avec l'affectation directe ou à tour de rôle, l'interruption matérielle est donc destinée à une machine virtuelle, donc à un cœur. L'IRQ doit donc être redirigée vers un cœur bien précis et ne pas être envoyée aux autres. Les contrôleurs d'interruption modernes déterminent à quelles machines virtuelles sont destinées telle ou telle interruption, et peuvent leur envoyer directement, sans passer par l'hyperviseur. Grâce à cela, l'affectation directe à tour de rôle ont de bonnes performances.
En plus de ce support des interruptions virtuelles, le contrôleur d'interruption peut aussi être virtualisé, à savoir être dupliqué en plusieurs '''contrôleurs d'interruption virtuels'''. Sur les systèmes à processeur x86, le contrôleur d'interruption virtualisé est l'APIC (''Advanced Programmable Interrupt Controller''). Diverses technologies de vAPIC, aussi dites d'APIC virtualisé, permettent à chaque machine virtuelle d'avoir une copie virtuelle de l'APIC. Pour ce faire, tous les registres de l'APIC sont dupliqués en autant d'exemplaires que d'APIC virtuels supportés. Il existe un équivalent sur les processeurs ARM, où le contrôleur d'interruption est nommé le ''Generic Interrupt Controller'' (GIC) et peut aussi être virtualisé.
La virtualisation de l'APIC permet d'éviter d'avoir à passer par l'hyperviseur pour gérer les interruptions. Par exemple, quand un OS veut prévenir qu'il a fini de traiter une interruption, il doit communiquer avec le contrôleur d'interruption. Sans virtualisation du contrôleur d'interruption, cela demande de passer par l'intermédiaire de l'hyperviseur. Mais s'il est virtualisé, l'OS peut communiquer directement avec le contrôleur d'interruption virtuel qui lui est associé, sans que l'hyperviseur n'ait à faire quoique ce soit. De plus, la virtualisation du contrôleur d'interruption permet de gérer des interruptions inter-processeurs dites postées, qui ne font pas appel à l'hyperviseur, ainsi que des interruptions virtuelles émises par les IO-MMU.
Sur les plateformes ARM, les ''timers'' sont aussi virtualisés.
==Annexe : le mode virtuel 8086 des premiers CPU Intel==
Les premiers processeurs x86 étaient rudimentaires. Le 8086 utilisait une forme très simple de segmentation, sans aucune forme de protection mémoire, ni même de mémoire virtuelle, dont le but était d'adresser plus de 64 kibioctets de mémoire avec un processeur 16 bits. Sur le processeur 286, la segmentation s'améliora et ajouta un support complet de la mémoire virtuelle et de la protection 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. Pour bien faire la différence, la segmentation du 8086 fut appelée le mode réel, et la nouvelle forme de segmentation fut appelée le mode protégé.
Les programmes conçus pour le mode réel ne pouvaient pas s'exécuter en mode protégé. En clair, tous les programmes conçus pour le 8086 devaient fonctionner en mode réel, qui était supporté sur le 286 et les processeurs suivant. Pour corriger les problèmes observés sur le 286, le 386 a ajouté un '''mode 8086 virtuel''', une technique de virtualisation qui permet à des programmes de s'exécuter en mode réel dans une machine virtuelle dédiée appelée la '''VM V86'''. Notez que nous utiliserons l'abréviation V86 pour parler du mode virtuel 8086, ainsi que de tout ce qui est lié à ce mode.
===La virtualisation du DOS et la mémoire étendue===
Utiliser le mode V86 demande d'avoir un programme 8086 à lancer, mais aussi d'utiliser un '''hyperviseur V86'''. L’hyperviseur V86 est un véritable hyperviseur, qui s’exécute en mode noyau, exécute des routines d'interruption, gère les exceptions matérielles, etc. Il réside en mémoire dans une zone non-adressable en mode réel, mais accessible en mode protégé, celui-ci permettant d'adresser plus de RAM. L'hyperviseur est forcément exécuté en mode protégé.
Le système d'exploitation DOS s'exécutait en mode réel, ce qui fait qu'il pouvait être émulé par le mode V86. Il était ainsi possible de lancer une ou plusieurs sessions DOS à partir d'un système d'exploitation multitâche comme Windows. Windows. Beaucoup de personnes nées avant les années 2000 ont sans doute profité de cette possibilité pour lancer des applications DOS sous Windows. Les applications étaient en réalité lancées dans une machine virtuelle grâce au mode V86. Windows implémentait un hyperviseur V86 de type 2, à savoir que c'était un logiciel qui s'exécutait sur un OS sous-jacent, ici
[[File:Hyperviseur.svg|centre|vignette|upright=2|Hyperviseur]]
Les applications DOS dans une VM V86 ne peuvent pas adresser plus d'un mébioctet de mémoire. L'ordinateur peut cependant avoir plus de mémoire RAM, notamment pour gérer l'hyperviseur V86. Diverses techniques permettaient aux applications DOS d'utiliser la mémoire au-delà du mébioctet, appelée la '''mémoire étendue'''. Les logiciels DOS accédaient à la mémoire étendue en passant par un intermédiaire logiciel, qui lui-même communiquait avec l'hyperviseur V86. L'intermédiaire est appelé le ''Extended Memory Manager'' (EMM), et il est concrètement implémenté par un driver sur DOS (HIMEM.SYS).
Les applications DOS ne pouvaient pas adresser la mémoire étendue, mais pouvaient échanger des données avec l'EMM. Les logiciels peuvent ainsi déplacer des données dans la mémoire étendue pour les garder au chaud, puis les rapatrier dans la mémoire conventionnelle quand ils en avaient besoin. L'intermédiaire ''Extended Memory Manager'' s'occupe d’échanger des données entre mémoire conventionnelle et mémoire étendue. Pour cela, il switche entre mode réel et protégé à la demande, quand il doit lire ou écrire en mémoire étendue.
Il ne faut pas confondre mémoire étendue et ''expanded memory''. Pour rappel, l'''expanded memory'' est un système de commutation de banque, qui autorise un va-et-vient entre une carte d'extension et une page de 64 kibioctets mappé en mémoire haute. Elle fonctionne sans mode protégé, sans virtualisation, sans mode V86. La mémoire étendue ne gère pas de commutation de banque et demande que la RAM en plus soit installée dans l'ordinateur, pas sur une carte d'extension.
Par contre, il est possible d'émuler l'''expanded memory'' sans carte d'extension, en utilisant la mémoire étendue. Quelques ''chipsets'' de carte mère intégraient des techniques cela. Une émulation logicielle était aussi possible. L'émulation logicielle se basait sur une réécriture de l'interruption 67h utilisée pour adresser la technologie ''expanded memory''. L'hyperviseur V86 pouvait s'en charger, il avait juste à réécrire son allocateur de mémoire pour gérer cette interruption et quelques autres détails.
===Le fonctionnement du mode virtuel 8086 de base===
En mode V86, la segmentation du mode protégé est désactivée, seule la segmentation du mode réel est utilisée. Il y a quelques subtilités liées à la ligne A20 du bus d'adresse, déjà abordées auparavant dans ce cours. Sur les CPU 286 et ultérieurs, le processeur peut adresser 1 mébioctet (2^20 adresses), plus 64 kibioctets qui ne sont pas adressables sur le 8086. Le tout permet donc d'adresser les adresses allant de 0 à 0x010FFEFH. Et ces adresses sont utilisées pour les programmes en mode réel. Les adresses au-delà de l'adresse 0x010FFEFH sont typiquement le lieu de résidence de l’hyperviseur en RAM.
Par contre, la pagination peut être activée par l’hyperviseur, afin d’exécuter plusieurs logiciels en mode réel simultanément. La mémoire virtuelle par pagination peut aussi être utile si l'ordinateur a peu de mémoire RAM, pas assez pour faire tourner le logiciel : l'espace d'adressage vu par le logiciel est un espace virtuel de grande taille, ce qui permet de lancer le logiciel, au prix de performances dégradées. Enfin, la gestion des entrées-sorties mappées en mémoire est aussi simplifiée. De plus, cela permettait d'adresser plus de mémoire RAM grâce aux adresses plus longues du mode protégé.
Il faut noter que dans le mode 8086 virtuel, les programmes peuvent utiliser les registres ajoutés sur le 386 et ultérieurs. Par exemple, le 8086 n'a que 4 registres de segment, alors que le 286 en a 6. Les programmes en mode 8086 virtuel peuvent utiliser les deux registres de segment supplémentaires. Il en est de même pour d'autres registres ajoutés par le 286, comme des registres de contrôle, des registres de debug, et quelques autres. Il en est de même pour les instructions ajoutées par le 286, le 386 et ultérieur, qui sont exécutables en mode virtuel 8086. Et elles sont nombreuses.
La compatibilité n'était pas parfaite, il y avait quelques petites différences entre ce mode V86 et le mode réel du 8086, idem avec le mode réel du 286. Mais la grande majorité des applications n'avait aucun problème. Les problèmes étaient concentrés sur quelques instructions précises, notamment celles avec un préfixe LOCK.
===L'implémentation matérielle du mode virtuel 8086===
Le processeur est configuré en mode V86, le bit VM du registre d'état spécifique est mis à 1. Le processeur utilise ce bit lorsqu'une instruction utilise les registres de segments, afin de savoir comment calculer les adresses, le calcul n'étant pas le même en mode réel et en mode protégé. Quelques instructions machines dépendent aussi de la valeur de ce bit, mais cela est traité au décodage de l'instruction, et plus précisément dans le microcode.
La plupart des instructions sont en double dans le microcode : il y a une version utilisant la segmentation en mode réel, une autre utilisant la pagination segmentée du mode protégé. Pour être plus précis, ces instructions sont coupées en deux dans le microcode : un microcode qui lit une opérande et dépend de la mémoire virtuelle, un microcode qui exécute le reste de l'instruction. La première partie n'est pas la même suivant qu'on est en mode réel ou protégé. Mais la seconde partie est la même dans les deux modes et est partagée entre les deux. En clair, les instructions ont deux points d'entrées : un pour le mode réel, un autre pour le mode protégé. Les deux lisent la table des segments/pages, font des tests de protection mémoire, puis branchent vers le microcode partagé.
Pour faire la différence, un bit du processeur indique si le CPU est en mode réel ou protégé, et ce bit est utilisé pour adresser le microcode. Il est appelé le bit P et vaut 1 en mode protégé, 0 en mode réel. Le mode virtuel 8086 combine de bit PE avec le bit VM. Le bit utilisé pour adresser le microcode est calculé avec l'équation logique suivante : <math>\text{P} . \overline{\text{VM}}</math>. Ainsi, le bit qui adresse le microcode est mis à 0 quand le bit VM est à 1, ce qui adresse le microcode du mode réel.
Les instructions liées au interruption étaient émulées en mode virtuel 8086. Par exemple, les instructions CTI et STI, qui activaient ou désactivaient les interruptions, n'étaient pas exécutées en mode virtuel 8086. Leur exécution entrainait la levée d'une exception matérielle, qui les émulait en matériel. Mais, il fallait un hyperviseur 8086 pour gérer la situation, notamment pour maintenir un registre d'interruption virtuel.
===Les ''Virtual-8086 mode extensions''===
A partir du processeur Pentium, les processeurs x86 ont introduit des optimisations du mode V86, afin de rendre la virtualisation plus rapide. L'ensemble de ces optimisations est regroupé sous le terme de '''''Virtual-8086 mode extensions''''', abrévié en VME. Les optimisations du VMA étaient, pour certaines, utiles au-delà de la virtualisation et étaient activables indépendamment du reste du VME.
Le VME introduisait des optimisations quant au traitement des interruptions, à savoir la gestion des interruptions virtuelles. De plus, le VME modifie la gestion de l'''interrupt flag'' du registre d'état. Pour rappel, ce bit permet d'activer ou de désactiver les interruptions masquables. Modifier le bit ''interrupt flag'' permettait de désactiver les interruptions masquables ou au contraire de les activer.
Il se trouve que ce bit était accesible par les programmes exécutés en mode réel, qui pouvaient en faire ce qu'ils voulaient. Le mode réel n'étant pas prévu pour la multi-programmation, ce n'était pas un problème. Mais en mode V86, toute modification de ce bit se répercute sur les autres VM en mode V86. Pour éviter les problèmes, le VME a ajouté de quoi virtualiser cet ''interrupt flag'', avec une copie par machine virtuelle V86. Chaque programme modifiait sa propre copie de l'''interrupt flag'' sans altérer celle des autres programmes exécutés en mode V86, et surtout sans déclencher une exception matérielle gérée par l'hyperviseur.
Bien qu'elles aient été introduites sur les processeurs Pentium, elles n'ont réellement été rendues publiques qu'après la sortie des processeurs de microarchitecture P6. Avant d'être rendue publique, la documentation du VME était une annexe de la documentation officielle, la fameuse annexe H. Elle était mentionnée dans la documentation officielle, mais était indisponible au grand public, seules quelques entreprises sous NDA y avait accès.
<noinclude>
{{NavChapitre | book=Fonctionnement d'un ordinateur
| prev=Les coprocesseurs : FPU et IO
| prevText=Les coprocesseurs : FPU et IO
| next=Les ISA optimisés pour la compilation/interprétation
| nextText=Les ISA optimisés pour la compilation/interprétation
}}
</noinclude>
tj495vvlgax3cha5ic81tz0bmqnxk68
Dictionnaire de philosophie/Chamfort
0
83799
763710
2026-04-15T05:47:22Z
PandaMystique
119061
Page créée avec « {{DicoPhilo|Chamfort}} == Repères biographiques : naissance, formation, entrée dans les lettres == === Une naissance substituée === L'homme que la postérité connaît sous le nom de Chamfort est baptisé le 22 juin 1740, en l'église Saint-Genès de Clermont, sous le nom de Sébastien-Roch Nicolas, la date traditionnelle de 1741, que donne la notice d'Auguis, a été corrigée par Claude Arnaud à partir des registres paroissiaux. C'est le nom, et ce sont... »
763710
wikitext
text/x-wiki
{{DicoPhilo|Chamfort}}
== Repères biographiques : naissance, formation, entrée dans les lettres ==
=== Une naissance substituée ===
L'homme que la postérité connaît sous le nom de Chamfort est baptisé le 22 juin 1740, en l'église Saint-Genès de Clermont, sous le nom de Sébastien-Roch Nicolas, la date traditionnelle de 1741, que donne la notice d'Auguis, a été corrigée par Claude Arnaud à partir des registres paroissiaux. C'est le nom, et ce sont les prénoms, d'un enfant qui vient de mourir en bas âge chez l'épicier Nicolas ; c'est aussi le nom d'un enfant qui n'est pas celui du couple Nicolas. Sa mère biologique est Jacqueline de Cisternes de Vinzelles, dite la dame de Montrodeix, issue d'une très ancienne famille d'Auvergne de noblesse chevaleresque ; épouse depuis 1719 du procureur général Jean-François Dauphin de Leyval, seigneur de Montrodeix, mère déjà de deux filles, elle est enceinte à quarante-quatre ans des œuvres de Pierre Nicolas, chanoine semi-prébendé de la cathédrale de Clermont, parent de l'épicier. Pour étouffer le scandale, un échange de berceaux est arrangé avec la famille Nicolas, dont l'enfant légitime vient de s'éteindre. Le nouveau-né prend l'identité du mort, et l'épouse de l'épicier, Thérèse Creuzet, quarante-quatre ans elle aussi, devient sa mère officielle.
Cet épisode, dont les éléments ont été établis par le baron d'Espinchal dans des mémoires inédits et confirmés par les recherches de Claude Arnaud, ne relève pas du simple pittoresque biographique. Claude Arnaud, qui a consacré à cette question les pages les plus neuves de sa biographie, voit dans la découverte de ses origines un événement dont les répercussions sur l'œuvre sont considérables. Vers l'âge de sept ou huit ans, l'enfant apprend de Thérèse Nicolas qu'il descend d'une lignée chevaleresque par sa mère, qu'il aurait pu être un Dauphin de Leyval ou un Vinzelles, et qu'il a été privé de son rang au nom des convenances. Cette découverte laisse sur lui une blessure dont on peut penser que l'œuvre ultérieure porte la trace, tantôt comme ressentiment avoué, tantôt comme méfiance générale à l'égard des institutions sociales, même s'il faut se garder de tout ramener à cette seule clé. L'adoption fait qu'il n'est plus tout à fait de la noblesse, sans être non plus vraiment du peuple : la condition de bâtard, au sens plein, inscrit en lui une double appartenance qui ne se résoudra jamais. Sa mère biologique et son père, le chanoine, semblent être demeurés à distance ; de Pierre Nicolas, mort en 1783, il ne parlera jamais publiquement. C'est à Thérèse Nicolas seule qu'il rendra les devoirs d'un fils, avec une piété filiale qui ne s'est jamais démentie et dont témoignent ses lettres.
=== Le collège des Grassins ===
Conduit à Paris à l'été 1750 muni d'une demi-bourse, Sébastien Nicolas entre au collège des Grassins, sur la montagne Sainte-Geneviève, l'un des établissements les plus exigeants de l'université (Arnaud, Chamfort, p. 23-25). Il s'y distingue d'abord par son caractère difficile, puis, à partir de la troisième, par des succès continus ; en rhétorique, il est présenté aux cinq épreuves du Concours général, remporte quatre prix, il manque le thème latin, puis, sur l'insistance du collège, remet ses quatre titres en jeu et l'emporte cette fois sur toute la ligne : son nom est inscrit en lettres d'or sur les cinq tableaux d'honneur du réfectoire. Ce succès, dont le souvenir ne le quittera plus quand il sera question de concours, de couronnes et d'académies, est inséparable d'une seconde formation, plus irréversible encore : la lecture des Anciens. Homère, Plutarque, Lucien, les Stoïciens nourrissent sa géographie mentale et dessinent un idéal de dignité que la société contemporaine ne cessera, à ses yeux, de démentir. Avant d'être renvoyé pour s'être opposé à son professeur de grec Lebeau, il songe un instant à partir pour l'Amérique avec son condisciple Letourneur : « Avant de faire le tour du monde, si nous faisions le tour de nous-mêmes ? » aurait-il dit à Cherbourg. Le trait est rapporté par Sélis, et le mot résume déjà une disposition dont les Maximes feront la méthode.
Il quitte alors le collège et refuse l'état ecclésiastique auquel l'habit d'abbé, porté comme la plupart des enfants pauvres formés par l'Église, semblait le destiner. « Je ne serai jamais prêtre, dit-il au principal ; j'aime trop le repos, la philosophie, les femmes, l'honneur, la vraie gloire ; et trop peu les querelles, l'hypocrisie, les honneurs et l'argent. » La déclaration est prémonitoire ; elle trace à l'avance, non sans ironie, les limites que sa vie aura voulu tenir.
=== Les succès académiques et mondains ===
Jeté sans fortune dans la vie littéraire, Chamfort, nom qu'il se donne au début des années 1760, vit d'abord de travaux alimentaires et de sermons composés pour des prédicateurs. La reconnaissance vient par les voies convenues : la comédie avec ''La Jeune Indienne'' (1764), puis ''Le Marchand de Smyrne'' (1770) ; l'éloquence académique avec l'Épître d'un père à son fils sur la naissance d'un petit-fils, couronnée par l'Académie française, l'Éloge de Molière qui remporte en 1769 le prix de l'Académie, et l'Éloge de La Fontaine qui, en 1774, lui vaut celui de l'Académie de Marseille face à La Harpe ; la tragédie enfin avec ''Mustapha et Zéangir'', jouée à Fontainebleau en 1776 et qui lui vaut une pension sur les menus-plaisirs ainsi que la place de secrétaire des commandements du prince de Condé, emploi dont il se déchargera bientôt, ne supportant aucune forme de dépendance. Durant ces années, il partage sa vie entre les soins que réclame une santé ruinée par des maladies vénériennes contractées jeune, Spa, Contrexéville, Barèges, et la fréquentation des salons, en particulier celui de Madame Helvétius à Auteuil, qui demeurera pour lui une seconde famille.
La réception à l'Académie française, en avril 1781, n'est obtenue qu'à la troisième tentative, après deux candidatures malheureuses et plusieurs désistements, notamment en faveur de Chabanon, qui avait menacé de se suicider en cas d'échec. Chamfort occupe le fauteuil laissé vacant par La Curne de Sainte-Palaye ; il y prononce un discours qui fit date autant par son brio que par ses ambiguïtés. Il y louait, comme il se devait, le roi, la reine, le prince de Condé et la compagnie qui le recevait, mais s'attaquait aussi, non sans audace, à la chevalerie que son prédécesseur avait étudiée, comme à une caste ayant pour vice principal la morgue et le refus de s'allier à d'autres classes, allusion à peine voilée à la noblesse de son temps. Cette dualité entre reconnaissance officielle et critique voilée, qui deviendra la marque de ses années révolutionnaires, est déjà entière dans ce discours.
=== Mirabeau, Sieyès, les années de la Révolution ===
La rencontre décisive de la vie intellectuelle de Chamfort est celle de Mirabeau, autour de 1783-1784. Pendant près de sept ans, selon le témoignage de Ginguené (Notice, p. xliii sq.) et l'analyse qu'en donne Arnaud (Chamfort, chap. 11-17), les deux hommes se voient presque chaque jour. Chamfort sert au comte plébéien de conseiller, de conscience, de correcteur et parfois de plume : il rédige ou co-rédige des passages importants des ''Considérations sur l'ordre de Cincinnatus'', des discours et des articles que Mirabeau publie ou prononce sous son propre nom, y compris le Discours contre les Académies que le tribun devait lire à l'Assemblée nationale en 1791 ; une fois Mirabeau mort, Chamfort publiera ce texte sous son propre nom. Cette collaboration souterraine, attestée par la correspondance et rappelée par Ginguené puis par Arnaud, place Chamfort dans une position originale : il est, selon le mot d'Arnaud, « l'éminence grise » de la première Révolution, l'homme qui parle par la bouche des autres et qui fait passer ses formules dans les discours et les journaux, refusant de paraître lui-même à la tribune comme il avait refusé de paraître au théâtre. Un collaborateur de Mirabeau, Dumont, résume : « Pendant que d'autres voulaient attaquer le colosse avec un bélier, Chamfort cherchait à le cribler de traits satiriques. »
Sa seconde amitié politique, celle de Sieyès, est de la même veine. Chamfort appelait Sieyès son « puritain » ; l'abbé lui rendait un respect qu'il accordait à peu de ses contemporains. Selon une anecdote rapportée par le comte de Lauraguais et publiée en 1802, mais contestée dès cette date par Suard (voir Arnaud, p. 186-187, et les notes afférentes),, Chamfort aurait lui-même soufflé à Sieyès, au début de 1789, le titre et la formule qui firent le succès du pamphlet le plus célèbre de la Révolution : « Qu'est-ce que le tiers état ? Tout. Qu'a-t-il ? Rien. » Sieyès y ajoutera : « Que veut-il ? Quelque chose. » Vraie ou romancée, l'anecdote dit quelque chose d'exact : dans l'hiver précédant les États généraux, Chamfort était au carrefour des réseaux qui préparèrent la transformation du tiers en Assemblée nationale, et ses formules traversaient les brochures comme les conversations. Il est probable qu'il ait eu, par ces voies obliques, un rôle dans la formulation des mots qui firent la Révolution, sans jamais apparaître en première ligne.
Devenu collaborateur du ''Mercure de France'' après 1789, Chamfort entreprend avec Pierre-Louis Ginguené, dont l'amitié sera le dernier appui de sa vie, et avec d'autres la publication des ''Tableaux historiques de la Révolution française'', accompagnés des gravures célèbres de Prieur. Il en fournit le texte des treize premières livraisons, composées chacune de deux tableaux, avant que la maladie et les persécutions ne l'obligent à abandonner l'entreprise, continuée par Ginguené. Nommé par Roland en 1792 à l'un des postes de direction de la Bibliothèque nationale, il y demeure jusqu'à son arrestation. Rallié d'abord aux Girondins, il dirigea brièvement la ''Gazette de France'' sous la même période,, méfiant à l'égard de Robespierre et des Jacobins dès 1791, il se trouve à partir de 1793 dans la position intenable de celui qui a servi la Révolution avec ferveur et qui la voit se retourner contre lui.
=== L'arrestation, le suicide, la mort ===
Dénoncé en juillet 1793 par un employé subalterne de la Bibliothèque, Tobiesen Duby, qui convoitait sa place et trouva dans ses propos imprudents les prétextes qu'il cherchait, Chamfort avait critiqué Marat publiquement, puis salué Charlotte Corday comme l'auteur d'une « œuvre sublime »,, Chamfort est arrêté à l'aube du 2 septembre 1793, date anniversaire des massacres de l'année précédente (le dossier du Comité de sûreté générale, Archives nationales F7 4638, est analysé par Arnaud, p. 283-292). Il passe deux jours à la prison des Madelonnettes, dans des conditions que son état de santé rendait presque intolérables : entassement, vermine, privation de soins, menace permanente du pire. Libéré sur ordre du Comité de sûreté générale, il doit vivre sous la surveillance d'un gendarme et jure qu'il ne se laissera jamais reconduire en prison. Lorsque, quelque temps plus tard, on vient lui signifier qu'il doit retourner en détention, au Luxembourg et non aux Madelonnettes, comme il l'apprendra trop tard,, il se retire dans un cabinet voisin, se tire une balle dans la tête qui lui fracasse la cloison nasale et l'œil droit sans pénétrer jusqu'au cerveau, puis, l'arme ayant failli, s'entaille à plusieurs reprises le cou, la poitrine, les cuisses et les mollets au rasoir. Les chirurgiens dénombreront vingt-deux plaies, dont plusieurs profondes, et laisseront la balle en place, jugeant son extraction mortelle.
Contre toute attente, il ne meurt pas. Pendant plus de six mois, il survit dans un état de mutilation que Ginguené décrit avec effroi, recevant ses proches, commentant la politique, dictant des notes à son biographe, travaillant même, avec Ginguené et Jean-Baptiste Say, au projet d'une revue philosophique, ''La Décade philosophique'', qui verra le jour après sa mort. Il finit par succomber, non à ses blessures premières, mais à une erreur du chirurgien Brasdor, qui referma ses plaies sans leur ménager d'ouverture : l'humeur se répandit dans le corps et provoqua une violente inflammation de la vessie. Desault, un des plus grands chirurgiens vivants, fut appelé en renfort, mais se trompa de remède et décida trop tard une opération. Chamfort meurt le dimanche 13 avril 1794, veillé par ses derniers amis ; son enterrement, dans l'atmosphère de la Terreur, ne réunit qu'un très petit cortège, Ginguené, Sieyès, Colchen et Van Praet en tête. Son corps, jeté quelque temps plus tard parmi les restes anonymes des cimetières parisiens d'Ancien Régime, finira, selon toute vraisemblance, dans les catacombes. Ses manuscrits, pillés au moment des scellés, ne seront que partiellement retrouvés par Ginguené, qui en tirera en 1795 la première édition de ses Œuvres, celle qui a donné à Chamfort son existence posthume comme moraliste.
== L'œuvre et ses formes ==
L'œuvre de Chamfort, telle qu'elle nous est parvenue, est volontairement hétérogène et ne se laisse pas aisément ranger sous une étiquette unique. Les cinq volumes que rassemble l'édition Auguis en 1824-1825 juxtaposent en effet des registres que la tradition littéraire avait jusque-là tenus séparés : la dissertation académique, l'éloge, la comédie et la tragédie, les contes en vers et les épîtres, les notes sur La Fontaine et sur Racine, les articles critiques destinés au Mercure, les ''Tableaux historiques de la Révolution'', et surtout les deux ensembles qui ont assuré sa survie, les ''Maximes et pensées'' et les ''Caractères et anecdotes'',, tous deux recueillis après sa mort par Ginguené sur les feuillets épars que l'auteur tenait par-devers lui et ne montrait à personne. Il est essentiel, pour bien lire Chamfort, de savoir qu'il n'a jamais voulu publier ces feuillets. Un long préambule, rédigé aux alentours de 1790 et que Ginguené a placé en tête des Maximes, énumère les raisons de ce silence : dégoût du public, peur de mourir sans avoir vécu, certitude que tous les hommes célèbres qu'il a connus ont été malheureux, refus enfin de vouloir plaire encore à qui ne lui ressemble pas.
Cette dispersion formelle n'est pas un accident. Chamfort lui-même, dans le premier chapitre de ses ''Maximes générales'', avertit que la maxime n'est qu'un abrégé qui vaut par la finesse des observations dont elle procède, et que l'esprit médiocre transforme en règle absolue ce qui, chez son auteur, n'était qu'un relevé singulier. « Les maximes, les axiômes sont, ainsi que les abrégés, l'ouvrage des gens d'esprit qui ont travaillé, ce semble, à l'usage des esprits médiocres ou paresseux. » L'homme supérieur, poursuit-il, « saisit tout d'un coup les ressemblances, les différences qui font que la maxime est plus ou moins applicable à tel ou tel cas, ou ne l'est pas du tout ». Le fragment n'est donc pas, chez Chamfort, l'expression d'une vérité synthétique, mais l'enregistrement ponctuel d'un regard : il note ce qu'il voit, comme le naturaliste qui découvre, au-delà de ses classes et de ses divisions, « l'insuffisance des divisions et des classes ». Le caractère aphoristique de son œuvre majeure n'est ainsi pas une concession à la brièveté, mais la traduction formelle d'une méthode d'observation qui se défie des systèmes.
On aurait tort, cependant, d'isoler les Maximes du reste de l'œuvre. Les Éloges de Molière et de La Fontaine, la ''Dissertation sur l'imitation de la nature'', le ''Discours de réception'' à l'Académie, le petit traité ''Des Académies'' qu'il composa pour Mirabeau, et jusqu'aux ébauches d'une histoire du théâtre ancien et moderne conservées dans le tome IV de l'édition Auguis, forment, avec les fragments moraux, un ensemble cohérent. On y reconnaît partout la même main : celle d'un écrivain pour qui la littérature n'est pas séparable de la peinture des mœurs, et pour qui l'histoire des formes, la fable, la comédie, l'éloge, est aussi une histoire sociale. Les ''Tableaux historiques de la Révolution'', de leur côté, prolongent pour le présent ce que les ''Caractères et anecdotes'' avaient entrepris pour la cour de Louis XV : fixer, dans une série de scènes détachées, le visage changeant d'une société qui se défait.
=== Les comédies et la tragédie ===
La carrière théâtrale de Chamfort est brève, cinq pièces entre 1764 et 1776, et inégale, mais elle engage des questions qui resteront au cœur de sa pensée. ''La Jeune Indienne'' (1764), comédie en un acte et en vers inspirée d'un épisode du ''Spectator'' anglais, met en scène une jeune fille élevée dans l'état de nature qui découvre les convenances de la société européenne et s'en étonne ; le thème, proche du Huron de Voltaire et de l'Ingénu, est celui du regard étranger porté sur les mœurs civilisées, et l'on reconnaît déjà, dans cette confrontation entre naïveté naturelle et artifice social, une intuition que les Maximes développeront dans un tout autre registre. ''Le Marchand de Smyrne'' (1770), comédie en un acte en prose, transpose la critique sociale sur un autre plan : un marchand d'esclaves y met en vente des captifs européens, parmi lesquels un gentilhomme et un chevalier, que personne ne veut acheter et qui finissent « donnés pour rien », satire de la noblesse que Chamfort rappellera lui-même, dans sa défense de 1793, comme preuve de ses convictions républicaines.
La tragédie de ''Mustapha et Zéangir'', représentée à Fontainebleau en 1776 devant la cour, est l'œuvre la plus ambitieuse et, au jugement d'Arnaud, la plus révélatrice de ses limites d'écrivain dramatique. Le sujet est emprunté à l'histoire ottomane, les amours et la mort de deux frères, fils de Soliman le Magnifique, et le traitement s'inscrit dans la filiation racinienne : plusieurs scènes, selon Auguis, témoignent de la profondeur avec laquelle Chamfort avait étudié la manière de Racine, et « jusqu'où il en aurait peut-être porté l'imitation ». La pièce obtint un succès de circonstance, mais elle fut plus froidement reçue par le parterre parisien. Le demi-échec marqua Chamfort. Il n'écrira plus pour le théâtre, et ce silence volontaire est à la fois un trait de caractère, il ne voulait s'exposer qu'à coup sûr, et le signe d'un déplacement vers un mode d'écriture plus personnel, plus fragmentaire, moins dépendant du jugement collectif : les Maximes. Arnaud formule ce tournant en termes forts : Chamfort « mourut ainsi, sous la Terreur, en écrivain moyen, et presque oublié. De cette vie anthume il ne reste rien, sinon sa longue agonie, perceptible dans les maximes et pensées du second Chamfort. »
=== La critique littéraire : les Éloges, les Notes sur La Fontaine, le Commentaire sur Racine ===
Les travaux de critique littéraire de Chamfort ont été longtemps éclipsés par les Maximes, mais ils représentent une part substantielle de l'œuvre et méritent d'être considérés pour eux-mêmes. L'Éloge de Molière (1769), couronné par l'Académie française, est un morceau d'éloquence où la célébration du poète comique se double d'une réflexion sur les rapports entre la comédie et la connaissance morale. Chamfort y défend l'idée que Molière est un philosophe autant qu'un dramaturge, et que la peinture des mœurs, lorsqu'elle atteint une certaine profondeur, vaut un traité de morale. L'Éloge de La Fontaine (1774), couronné par l'Académie de Marseille aux dépens de La Harpe, prolonge cette lecture en y ajoutant une attention au détail poétique : Chamfort y analyse la fable non seulement comme un véhicule d'enseignement, mais comme une forme originale d'écriture dont la naïveté apparente est le fruit d'un art savant. Les Notes sur les Fables de La Fontaine qui accompagnent cet Éloge dans l'édition Auguis offrent un commentaire suivi, livre par livre, où Chamfort alterne observations stylistiques et réflexions morales, elles sont l'un des premiers commentaires détaillés consacrés aux Fables.
Le tome V de l'édition Auguis contient, sous le titre d'''Essai d'un commentaire sur Racine'', des notes sur Esther et Athalie qui témoignent d'un égal souci d'analyser le travail du poète dans le détail de son texte. La ''Dissertation sur l'imitation de la nature'', enfin, est un essai théorique où Chamfort aborde la question du naturel dans l'art dramatique : il y distingue l'imitation servile de la nature, qui ne produit que des copies, de l'imitation créatrice, qui saisit les « traits saillants » d'un caractère et les compose en un type intelligible. La question du naturel, qui traverse toute sa réflexion, des personnages de la comédie aux maximes sur la « composition factice » de la société,, trouve ici sa formulation théorique la plus explicite.
== La tradition moraliste et ses déplacements ==
=== Chamfort au terme d'une lignée ===
Chamfort appartient à la lignée des moralistes français qui, de Montaigne à Vauvenargues, ont préféré la forme brève à la construction systématique et ont fait de l'observation des conduites humaines la matière même de la philosophie morale. Il reconnaît ses prédécesseurs sans détour : « Il y a deux classes de moralistes et de politiques : ceux qui n'ont vu la nature humaine que du côté odieux ou ridicule, et c'est le plus grand nombre ; Lucien, Montaigne, La Bruyère, La Rochefoucauld, Swift, Mandeville, Helvétius, etc. ; ceux qui ne l'ont vue que du beau côté et dans ses perfections ; tels sont Shaftesbury et quelques autres. Les premiers ne connaissent pas le palais dont ils n'ont vu que les latrines ; les seconds sont des enthousiastes qui détournent leurs yeux loin de ce qui les offense, et qui n'en existe pas moins. Est in medio verum. » Cette déclaration a l'apparence d'un éclectisme modéré, mais la suite de l'œuvre révèle un parti autrement plus décidé : si la vérité est au milieu, Chamfort campe ordinairement sur le versant sévère, celui qui voit dans la société l'ennemie de la nature et dans les usages consacrés autant d'artifices dont la raison, quand elle se ressaisit, reconnaît l'inanité.
Le rapport à La Rochefoucauld est à cet égard le plus significatif. Chamfort lui doit le goût de l'antithèse courte, de la chute paradoxale, de la démystification de l'amour-propre. Mais il déplace le centre d'analyse : chez l'auteur des Maximes, l'amour-propre est une disposition universelle, une loi de la nature humaine abstraite de tout cadre social ; chez Chamfort, il est toujours situé, inscrit dans un ordre de rangs, d'emplois et de préjugés dont il faut aussi dresser l'inventaire. La critique se fait institutionnelle autant que psychologique. « La plupart des nobles rappellent leurs ancêtres, à peu près comme un Cicerone d'Italie rappelle Cicéron » : la pointe ne porte plus seulement sur un trait de caractère, mais sur un état social qui confond la gloire héritée et la gloire due. C'est en ce point que Chamfort prend congé du moraliste classique : il inscrit les maximes dans une sociologie, et il rend à la critique de l'ordre social ce que le pessimisme janséniste avait rendu à la critique de l'amour-propre.
=== L'ascendant des Lumières ===
À la tradition moraliste s'ajoute la pression des Lumières. Chamfort a lu Helvétius et fréquenté son cercle, il s'est nourri de Rousseau et de Diderot, il connaît les physiocrates et l'Encyclopédie. De l'utilitarisme helvétien, il retient l'idée que l'intérêt bien compris gouverne les actions humaines plus sûrement qu'aucune vertu, et que la morale sociale est largement le produit des institutions ; de Rousseau, il adopte l'intuition maîtresse selon laquelle l'état social a corrompu un état antérieur plus authentique, et que la plupart des douleurs humaines sont filles de la civilisation. Mais Chamfort n'est ni systématique comme Helvétius ni enthousiaste comme Rousseau. Il ne propose aucune refondation théorique, aucun nouveau contrat social, aucune pédagogie. Son matérialisme est un scepticisme ; son rousseauisme, une nostalgie. À la fois héritier des moralistes du Grand Siècle et contemporain des philosophes, il occupe une position qu'on peut qualifier de liminaire : celle d'un moraliste des Lumières, plus proche, par la méthode, de La Bruyère que de d'Holbach, plus proche, par le jugement politique, de Condorcet que de La Rochefoucauld.
== Anthropologie : nature, passions, raison ==
=== La nature et la « composition factice » ===
La pensée anthropologique de Chamfort repose sur une distinction structurante entre la nature et la société, mais cette distinction ne prend jamais, chez lui, la forme d'un mythe des origines à la manière rousseauiste. Il ne remonte pas à un homme primitif dont il reconstituerait la figure ; il se borne à faire apparaître, par contraste, ce que la socialisation a défait. « La société n'est pas, comme on le croit d'ordinaire, le développement de la nature, mais bien sa décomposition et sa refonte entière. C'est un second édifice, bâti avec des décombres du premier. » L'image vaut d'être méditée : elle récuse le modèle progressiste d'une civilisation qui perfectionnerait la nature, mais sans verser dans l'utopie du retour ; elle suggère que la société travaille toujours avec les matériaux de ce qu'elle défait, et que l'on trouve, çà et là, les débris d'une architecture antérieure, comme ces expressions naïves d'un sentiment vrai qui, par surprise, nous émeuvent dans la conversation des grands et qui sont, dit-il, « un hommage à la nature ».
Si la civilisation est une décomposition, c'est qu'elle substitue à la spontanéité des affections primitives un système d'usages, de rangs et d'intérêts qui ne souffre plus d'y paraître. « En général, si la société n'était pas une composition factice, tout sentiment simple et vrai ne produirait pas le grand effet qu'il produit : il plairait sans étonner ; mais il étonne et il plaît. Notre surprise est la satire de la société, et notre plaisir est un hommage à la nature. » Le ressort de la critique n'est donc pas la dénonciation d'une déchéance abstraite ; c'est l'observation que la société du XVIIIe siècle a rendu extraordinaire ce qui devrait être ordinaire, et qu'elle fait de la sincérité une rareté muséale.
=== Passions et raison ===
Le rapport entre passions et raison, qui avait occupé toute la philosophie morale du XVIIe siècle, se voit chez Chamfort notablement redistribué. Aux moralistes augustiniens qui avaient fait des passions la source de la corruption humaine, il oppose une thèse exactement inverse : « L'homme, dans l'état actuel de la société, me paraît plus corrompu par sa raison que par ses passions. Ses passions (j'entends ici celles qui appartiennent à l'homme primitif) ont conservé, dans l'ordre social, le peu de nature qu'on y retrouve encore. » C'est la raison, c'est-à-dire le calcul, l'artifice, l'intérêt social, qui a altéré l'homme ; les passions, en ce qu'elles ont d'immédiat et de naturel, en sauvent encore quelque chose. La formule n'est pas dirigée contre la raison en général, Chamfort reste un homme des Lumières, mais contre la raison sociale, c'est-à-dire contre l'instrument par lequel l'homme en société apprend à dissimuler, à ménager et à se vendre.
De cette valorisation des passions naturelles découle une manière de stoïcisme tempéré. Chamfort ne recommande ni le triomphe sur les passions ni leur libre cours ; il recommande leur authenticité. Un sentiment vrai, éprouvé à temps, vaut, dit-il, mieux que toutes les réflexions savantes : « Le moraliste qui voudrait faire taire ses passions est comme le chimiste qui voudrait éteindre son feu. » La dignité du caractère, qui donnera son titre à l'un des chapitres des Maximes, est moins une maîtrise qu'une fidélité : le refus obstiné de laisser la société défaire en soi ce qui reste de sensibilité spontanée.
== Critique de la société ==
=== Les niches et les rangs ===
L'image la plus achevée qu'ait laissée Chamfort de la hiérarchie sociale est celle de l'édifice aux niches. Il faut la citer presque entièrement, car elle résume, mieux qu'aucune formule générale, sa manière d'analyser l'ordre monarchique : « On peut considérer l'édifice métaphysique de la société comme un édifice matériel qui serait composé de différentes niches ou compartiments, d'une grandeur plus ou moins considérable. Les places avec leurs prérogatives, leurs droits, etc., forment ces divers compartiments, ces différentes niches. Elles sont durables, et les hommes passent. Ceux qui les occupent sont tantôt grands, tantôt petits ; et aucun ou presque aucun n'est fait pour sa place. Là, c'est un géant courbé ou accroupi dans sa niche ; là, c'est un nain sous une arcade : rarement la niche est faite pour la statue. » L'analogie architecturale permet un renversement du principe aristocratique : les places, loin d'être l'expression des mérites qu'elles couronnent, les précèdent et les commandent ; les hommes s'y insèrent avec les disproportions qu'impose la naissance ou la faveur ; et ce qui choque, c'est moins telle inégalité particulière que la règle générale qui veut que l'instrument ne convienne jamais à son étui.
Cette image n'est pas isolée. Elle prolonge une série d'observations où Chamfort met en évidence le caractère purement conventionnel de la considération sociale. « Un sot, fier de quelque cordon, me paraît au-dessous de cet homme ridicule qui, dans ses plaisirs, se faisait mettre des plumes de paon au derrière par ses maîtresses. » La comparaison est volontairement grossière ; elle a pour fonction de rappeler que les ornements sociaux, rubans, croix, charges, décorations, ne valent pas davantage, philosophiquement, que les parures que chacun s'octroie en privé, et que l'adhésion à ces signes trahit chez qui les porte une infériorité morale que nul plumage ne rachète.
=== Les grands, les riches, les gens du monde ===
Le chapitre III des ''Maximes générales'', entièrement consacré « à la société, aux grands, aux riches et aux gens du monde », développe une critique qui, sous l'apparence de notes détachées, constitue une véritable sociologie de la cour et des salons. Chamfort y parle en témoin. Il a vu l'aristocratie de très près, elle l'a fêté autant qu'elle l'a humilié,, il a servi comme secrétaire du prince de Condé et comme secrétaire du Cabinet de Madame Élisabeth, sœur de Louis XVI ; il a subi la protection affectueuse du comte de Vaudreuil, favori du comte d'Artois, dont il fut proche sans jamais se laisser acquérir ; il a connu intimement Julie Careau, Marthe Buffon, Henriette de Nehra. Son regard est d'autant plus aigu que la société qu'il peint est celle où il fut à la fois admis et étranger. « La société, écrit-il, est composée de deux grandes classes : ceux qui ont plus de dînés que d'appétit, et ceux qui ont plus d'appétit que de dînés. » La formule, d'abord plaisante, est en fait une synthèse économique : l'inégalité y est ramenée à un déséquilibre de besoins et de moyens qui dérobe à ceux qui pâtissent le nécessaire et à ceux qui possèdent la capacité même d'en jouir.
À cette critique économique s'ajoute une critique morale. « On ne peut vivre dans la société, après l'âge des passions. Elle n'est tolérable que dans l'époque où l'on se sert de son estomac pour s'amuser, et de sa personne pour tuer le temps. » Ce que Chamfort dénonce n'est pas seulement le luxe ; c'est l'ennui organisé, le remplissage des heures par des plaisirs convenus, la soumission à des liturgies de politesse que rien n'anime plus. Les « gens du monde », remarque-t-il encore, « ne sont pas plutôt attroupés qu'ils se croient en société », mot qui distingue la véritable société, commerce de pensées et d'affections, de cette agrégation où chacun surveille chacun sans jamais rien échanger.
=== La noblesse héréditaire et les préjugés ===
De toutes les institutions qu'il critique, la noblesse héréditaire est celle contre laquelle Chamfort déploie la plus grande énergie. Il y voit non seulement un abus particulier, mais le symptôme de l'impuissance propre à la pensée morale à l'égard des préjugés établis : « Veut-on avoir la preuve de la parfaite inutilité de tous les livres de morale, de sermons, etc. ? Il n'y a qu'à jeter les yeux sur le préjugé de la noblesse héréditaire. Y a-t-il un travers contre lequel les philosophes, les orateurs, les poètes aient lancé plus de traits satiriques, qui ait plus exercé les esprits de toute espèce, qui ait fait naître plus de sarcasmes ? cela a-t-il fait tomber les présentations, la fantaisie de monter dans les carrosses ? » La remarque, frappante par sa conclusion désabusée, engage une thèse générale sur l'impuissance du discours moral face aux institutions : la critique raisonnée ne détruit pas ce que le rang, la fortune et l'habitude soutiennent. Elle explique, par avance, pourquoi Chamfort accueillera avec ferveur la Révolution : seule une transformation politique, et non un supplément d'arguments, peut détruire ce que les arguments n'ont pu défaire.
Le rapport entre opinions et institutions se laisse ainsi penser, chez Chamfort, selon une circularité qui n'est pas loin d'anticiper la critique idéologique : les opinions reçues naissent des institutions qui les soutiennent, et les institutions durent parce qu'elles sont reconduites par les opinions qu'elles ont engendrées. Chamfort cite à ce propos l'exemple de l'éducation, pour montrer que celle-ci ne peut être réformée séparément des réformes politiques et religieuses dont elle dépend. « L'éducation n'ayant d'autre objet que de conformer la raison de l'enfance à la raison publique relativement à ces trois objets [législation, religion, opinion publique], quelle instruction donner, tant que ces trois objets se combattent ? » On reconnaît là l'intuition qui sera celle, au siècle suivant, d'un certain républicanisme français : la formation des citoyens exige la transformation simultanée de toutes les sphères où se constitue leur raison.
=== La question de la bâtardise et du ressentiment ===
Il importe, pour comprendre une part de la critique sociale chez Chamfort, de la rapporter à son expérience propre, tout en se gardant d'en faire la clé unique de l'œuvre. Claude Arnaud a proposé, dans la biographie qu'il lui a consacrée, une lecture d'ensemble articulée autour de la bâtardise et du ressentiment : « Ayant deux identités, celle, aristocratique, de sa mère ; celle, populaire, de sa famille adoptive,, il prend un surnom littéraire à l'âge de vingt ans : Chamfort. Pourtant il restera toujours aussi double que ce faux patronyme, qui commence en douceur et finit en revendication. » Cette lecture, qui a l'avantage de rendre intelligible la trajectoire dans son ensemble, du courtisan couronné au républicain pourchassé,, a le mérite de prendre au sérieux ce que Chamfort lui-même ne cessait de taire mais qui informe, de manière diffuse, la sévérité de son regard sur les distinctions de rang. Elle a toutefois été nuancée par d'autres approches : Jean Dagen, dans son édition des Maximes (GF, 1968), s'attache davantage à la filiation proprement littéraire et philosophique de la pensée de Chamfort, à son rapport aux moralistes classiques, à la logique interne de la forme fragmentaire, et refuse de tout dériver d'une clé biographique. Georges Poulet, dans ''La Distance intérieure'' (1952), lit Chamfort sous l'angle de l'expérience temporelle, la conscience d'un décalage entre le moi et le monde, sans privilégier le ressentiment. Sainte-Beuve, dans les ''Causeries du lundi'', voyait quant à lui un cas « des plus curieux et des plus nets d'ulcération de l'esprit », formule que Nietzsche reprendra pour la retourner. Il y a donc plusieurs lectures possibles du rapport entre la vie et l'œuvre, et la plus prudente est sans doute celle qui reconnaît dans la bâtardise un facteur important sans en faire le principe explicatif exclusif.
Nietzsche a été le premier à lire Chamfort sous l'angle du ressentiment, dans un passage du Gai Savoir qui reste l'un des textes de réception les plus pénétrants qui lui aient été consacrés. Il y voyait, dans le « trop explicable ressentiment » de Chamfort, à la fois la condition de sa lucidité et la raison pour laquelle ce moraliste avait fini par se jeter dans la Révolution plutôt que de demeurer, comme Nietzsche l'eût voulu, dans une supériorité philosophique désintéressée. Dans ''La Généalogie de la morale'' (1887), Nietzsche ira plus loin en intégrant Chamfort à sa théorie du ressentiment comme acte créateur de valeurs. Là où Nietzsche diagnostique une limite, Chamfort n'a pas été « philosophe d'un degré de plus »,, on peut aussi bien voir un geste dont la cohérence est propre à Chamfort : refuser de séparer l'exercice du jugement moral de l'engagement politique. Ce débat entre lecture psychobiographique et lecture proprement philosophique reste, aujourd'hui encore, ouvert.
== Amour-propre, lettres, académies ==
Chamfort hérite de La Rochefoucauld la conviction que l'amour-propre est le ressort caché de la plupart des conduites humaines, mais il en déplace l'analyse du terrain strictement psychologique vers le terrain social. L'amour-propre, tel qu'il le peint, n'est pas seulement la complaisance en soi-même : c'est la dépendance où chacun se trouve du regard d'autrui, et particulièrement du regard de ceux que la société a placés au-dessus. De là cette observation aiguë : « J'ai trois sortes d'amis : mes amis qui me détestent, mes amis qui me craignent, et mes amis qui ne se soucient pas du tout de moi. » La remarque, qui a la brièveté d'un mot, contient une doctrine : l'amitié, dans le monde, n'est pas un commerce des cœurs, mais un arrangement des intérêts et des vanités.
Le chapitre qui, dans les Maximes, est consacré « aux savants et aux gens de lettres » prolonge cette analyse dans le champ particulier qui était celui de Chamfort lui-même. Il y décrit la vie littéraire comme une seconde cour, avec ses clientèles, ses jalousies et ses bassesses, et n'épargne pas ses propres pairs. Mais c'est le petit traité ''Des Académies'', que Mirabeau devait lire à l'Assemblée en 1791 sous le titre de Rapport sur les Académies, et que Chamfort publia sous son propre nom après la mort du tribun, qui donne à cette critique sa dimension politique. Chamfort y analyse les académies comme des institutions de l'Ancien Régime, héritées d'un temps où la protection royale régissait la vie des lettres, et il y reconnaît un double vice : elles soumettent la pensée au goût du pouvoir, et elles la hiérarchisent selon les rangs plutôt que selon les talents. La suppression des académies, qu'il appelle de ses vœux, n'est donc pas un geste iconoclaste, mais la conséquence logique d'une doctrine de la liberté intellectuelle : là où la pensée reçoit ses récompenses du pouvoir, elle cesse d'être libre.
La publication de ce texte valut à Chamfort la rupture de plusieurs de ses anciens amis, et notamment de l'abbé Morellet, qui lui répondit par une brochure, ''De l'Académie française, ou réponse à l'écrit de M. de Chamfort'' (1791), rappelant avec ironie que Chamfort avait mis vingt ans à entrer à l'Académie qu'il demandait à présent de détruire, qu'il y avait prononcé en 1781 un discours où il en louait l'institution et ses protecteurs, et qu'il y avait été assidu pendant dix ans. « Courage de circonstance », concluait le vieil académicien. Le reproche n'est pas entièrement faux : la critique des académies n'est pas seulement un pamphlet contre un ordre révolu, c'est aussi une pièce dans un procès qu'il se fait à lui-même, le dernier acte d'une rupture par laquelle Chamfort renonce solennellement aux honneurs qu'il avait conquis et consent, à perte, à tout ce que la Révolution exigeait de ceux qui avaient appartenu à l'Ancien Régime. On peut lire dans cette rupture, comme l'a fait Arnaud, l'acte par lequel Chamfort solde définitivement ses comptes avec l'Ancien Régime. Morellet, en répliquant, montrait l'autre face du geste : la part d'inconséquence ou de mauvaise conscience qu'il pouvait aussi contenir. Il reste que Chamfort a renoncé sans retour aux bénéfices d'un monde dont il avait été, pendant vingt ans, l'un des ornements.
== Politique : royauté, liberté, République ==
=== La critique de la royauté ===
La pensée politique de Chamfort, aussi longtemps qu'elle s'est exercée sous la monarchie, a pris la forme d'une critique de l'ordre ancien plutôt que d'une théorie positive de la liberté. Il voit dans le régime de Louis XV et de Louis XVI la perpétuation d'un système où les places, les pensions et les charges forment un réseau de dépendances qui corrompt jusqu'à ceux qui s'en défendent. « Quand les sots sortent de place, soit qu'ils aient été ministres ou premiers commis, ils conservent une morgue ou une importance ridicule » : la remarque vise moins les individus que l'institution qui leur a donné cette morgue. De même, sa critique du serment « foi de gentilhomme », « Louis XV a fait banqueroute en détail trois ou quatre fois, et on n'en jure pas moins foi de gentilhomme », n'est pas un trait contre un roi particulier, mais un exemple de la manière dont les formules consacrées résistent à l'évidence des faits.
Cette critique se redouble d'une observation plus profonde sur le mécanisme de la domination. Chamfort note que la servitude la plus humiliante n'est pas celle qu'on subit par contrainte, mais celle qu'on consent par intérêt ou par habitude. « J'ai vu des hommes trahir leur conscience, pour complaire à un homme qui a un mortier ou une simare : étonnez-vous ensuite de ceux qui l'échangent pour le mortier, ou pour la simare même. Tous également vils, et les premiers absurdes plus que les autres. » L'analyse du pouvoir se confond ici avec l'analyse de l'amour-propre : c'est parce que chacun cherche sa considération dans le regard des puissants que le pouvoir dure, et ce qui le soutient n'est pas la force mais la complaisance.
=== L'éminence grise de la première Révolution ===
Lorsque la Révolution survient, Chamfort en accueille les principes avec un enthousiasme qui tranche sur le scepticisme de ses années antérieures. Son rôle, dans la préparation et les premiers temps du mouvement, est bien plus actif qu'on ne l'a longtemps supposé, comme l'ont montré les travaux de John Renwick (« Chamfort patriote en coulisse », 1980) puis la biographie d'Arnaud. Il est l'un des hommes du club des Trente, il inspire ou relit des discours de Mirabeau, il participe à la Société de 1789 aux côtés de Sieyès, Condorcet, Bailly, Talleyrand et La Fayette, il fréquente les réunions où se discute le sort de la monarchie. Il est aux côtés des députés pour le Serment du Jeu de Paume et, selon un témoignage rapporté par Arnaud, il aurait inspiré à Mirabeau la phrase célèbre : « Nous sommes ici par la volonté du peuple et nous n'en sortirons que par la force des baïonnettes. » Chroniqueur de la Révolution dans les ''Tableaux historiques'', rédacteur au Mercure, auteur de mots qui circulent de bouche en bouche, « Guerre aux châteaux, paix aux chaumières » ; la noblesse « intermédiaire entre le roi et le peuple, comme le chien de chasse est un intermédiaire entre le chasseur et les lièvres »,, Chamfort se tient, selon sa formule propre, dans l'ombre : « Pendant que d'autres voulaient attaquer le colosse avec un bélier, Chamfort cherchait à le cribler de traits satiriques. »
Son républicanisme n'est pas une doctrine abstraite ; c'est la conclusion cohérente de toutes ses critiques antérieures. Si les institutions de l'Ancien Régime corrompaient nécessairement ceux qui y entraient, et si les arguments moraux ne pouvaient les faire tomber, il fallait bien que leur chute vînt de l'action politique, et la République offrait la forme où la dignité du caractère pourrait enfin s'exercer sans se compromettre. Chamfort y a cru littéralement. Il a donné ses pensions, sacrifié son revenu, rédigé pour presque rien dans le Mercure, accepté d'être proposé pour plusieurs postes sans jamais les briguer, et s'est installé dans le rôle, ingrat mais cohérent, de celui qui sert la Révolution en la pensant.
=== Le républicanisme mélancolique ===
Mais ce républicanisme n'a pas été sans réserves, et c'est dans le huitième chapitre des Maximes, « De l'esclavage et de la liberté en France, avant et depuis la Révolution », que s'inscrivent ces réserves. Chamfort y observe que les hommes ne passent pas, d'une société à l'autre, sans emporter avec eux les habitudes qu'ils y ont contractées. « Je ne croirai pas à la révolution, disait-il en 1792, tant que je verrai ces carrosses et ces cabriolets écraser les passants » : la formule met en évidence le décalage entre les institutions nouvelles et les mœurs qui les précèdent. De même, sa célèbre traduction ironique de la devise « Fraternité ou la mort », « Sois mon frère ou je te tue », n'est pas un sarcasme conservateur, c'est l'observation qu'une vertu imposée par la menace cesse d'être une vertu, et qu'une fraternité qui se maintient par la terreur détruit le principe même dont elle s'autorise.
Il faut bien mesurer la portée de ces réserves. Elles ne viennent pas d'un modéré qui regrette la monarchie ; elles viennent d'un républicain qui s'inquiète de voir la République se dégrader en ce qu'elle dénonçait. L'homme qui s'est rangé aux côtés des Girondins en 1792, qui a salué Charlotte Corday comme la « sainte » d'une cause perdue, qui a refusé, aux pires moments de la Terreur, de se dissocier publiquement de ses amis déjà frappés, n'est pas un opposant d'occasion. C'est un homme qui a tenu, jusqu'à l'épreuve, la position d'un républicain intransigeant, et dont la lucidité a fini par mesurer le prix. Chamfort appartient à cette lignée de républicains désenchantés, tel Condorcet, son contemporain, qui accueillirent la chute de l'Ancien Régime comme une nécessité morale et qui ne cessèrent de craindre que la Révolution, dans sa précipitation, ne produisît de nouveaux despotes à la place des anciens. Sa mort, qui fut la conséquence de cette position et non d'un hasard, donna à sa vie la signification d'un choix.
=== Les Tableaux historiques ===
Le projet des ''Tableaux historiques de la Révolution française'', pour lequel il fournit treize livraisons composées chacune de deux tableaux et ornées des gravures de Prieur, avant que la tâche ne soit poursuivie par Ginguené, mérite d'être rattaché à sa pensée politique d'ensemble. Chaque « tableau » est une scène : le Serment du Jeu de Paume, la Prise de la Bastille, la Nuit du 14 au 15 juillet, le Roi à l'hôtel de ville de Paris. La méthode est celle de ses ''Caractères et anecdotes'' transportée sur le théâtre révolutionnaire : décrire les événements comme des instants significatifs, en dégager la valeur morale, en repérer les ambiguïtés. On y voit que Chamfort n'était pas un chroniqueur au sens strict ; il concevait l'histoire comme une suite de scènes où se révélait le caractère d'un peuple, et le récit historique comme la prolongation naturelle de la peinture morale.
== Morale de la retraite et dignité du caractère ==
La critique incessante de la société dans laquelle il vit n'aboutit pas, chez Chamfort, à un programme positif ; elle aboutit à une morale de la retraite. Le chapitre IV des ''Maximes générales'', « Du goût pour la retraite, et de la dignité du caractère », en porte le titre explicite. La retraite, chez Chamfort, n'a rien de la clôture religieuse ni de la méditation stoïcienne dans sa forme classique : elle est le refuge d'un esprit que la fréquentation du monde a fatigué, et la condition de possibilité d'un jugement qui ne se soit pas altéré au contact des intérêts. Il faut donc se retirer, non par haine du monde, mais par fidélité à soi-même. C'est le parti qu'il avait voulu prendre dès 1784, lorsqu'il quittait Paris pour la Provence, et qu'il reprit à plusieurs reprises par la suite.
Cette morale de la retraite a un corrélat positif, qui est la dignité du caractère. L'expression est à prendre dans un sens très concret : il s'agit, pour celui qui ne peut plus rien changer à l'ordre social, de maintenir en soi une cohérence entre ce qu'il pense et ce qu'il fait, un refus constant des petites lâchetés qu'exige la vie commune. « Un homme du peuple, un mendiant, peut se laisser mépriser, sans donner l'idée d'un homme vil, si le mépris ne paraît s'adresser qu'à son extérieur : mais ce même mendiant, qui laisserait insulter sa conscience, fût-ce par le premier souverain de l'Europe, devient alors aussi vil par sa personne que par son état. » La dignité, telle qu'elle est pensée ici, n'est pas une position sociale ni même une vertu héroïque : c'est le refus, accessible à tous, de laisser insulter sa conscience. Elle fournit le critère par lequel se distinguent les « honnêtes gens » des « fripons » : « Il faut convenir qu'il est impossible de vivre dans le monde sans jouer de temps en temps la comédie. Ce qui distingue l'honnête homme du fripon, c'est de ne la jouer que dans les cas forcés, et pour échapper au péril ; au lieu que l'autre va au-devant des occasions. »
On a souvent vu dans Chamfort un misanthrope. Le mot est trop court. La misanthropie suppose une haine générale de l'espèce ; Chamfort, à l'observer, ressentait plutôt une tristesse particulière à l'égard d'une société qu'il voyait incapable de se réformer. Lorsque la Révolution lui offrit un objet d'engagement, il s'y engagea sans réserve ; lorsque cet engagement lui-même devint douteux, il revint à la retraite, et c'est dans cet aller-retour que se lit l'unité d'une vie morale. Son geste final, refuser de rentrer vivant dans une prison, n'est pas une désespérance philosophique ; il est le prolongement extrême de cette dignité du caractère qu'il avait définie. « Je suis un homme libre, dit-il encore aux personnes présentes, jamais on ne me fera rentrer vivant dans une prison. » La déclaration, qu'il relut et signa au procès-verbal, peut être lue comme l'épitaphe de toute une vie morale : le refus, jusque dans la chair, que la société atteigne ce qui, en l'homme, ne lui appartient pas.
== La maxime comme forme philosophique ==
La question de la forme ne saurait être disjointe, chez Chamfort, de la question de la pensée. Il a réfléchi lui-même à ce que c'est qu'une maxime, et l'on a vu qu'il la considérait avec quelque méfiance, comme l'outil commode des esprits paresseux. Pourquoi, alors, a-t-il choisi cette forme ? Parce qu'elle permet, mieux qu'aucune autre, de restituer ce qu'il appelle les « mille observations fines dont l'amour-propre n'ose faire confidence à personne ». La maxime, chez lui, n'est pas la règle d'une morale ; elle est la trace écrite d'une observation particulière, susceptible de corrections et de démentis, qui ne prétend valoir que par sa vérité occasionnelle. Ses maximes, souligne-t-il dès le premier fragment de ses Produits, n'ont pas valeur universelle : elles doivent être lues et interprétées, comme l'a montré Claude Arnaud, en fonction du trajet qui a mené à leur naissance, avertissement méthodologique capital, et qui commande toute l'herméneutique de son œuvre.
Cette conception a une conséquence formelle que l'on reconnaît aisément dans les ''Maximes et pensées'' : les fragments y sont souvent introduits par un « j'ai vu », par un « M*** me disait », par un « quelqu'un disait », qui rappellent que la pensée procède toujours d'un cas. La vérité morale, telle que Chamfort l'entend, n'a pas la généralité abstraite d'un principe ; elle s'attache à un contexte, à une scène, à une personne, et ne s'étend au-delà que par la ressemblance que le lecteur consent à reconnaître. Les ''Caractères et anecdotes'' en sont, plus encore que les Maximes, la mise en œuvre exemplaire. Chaque anecdote est un petit récit qui ne dispense aucune leçon formulée, mais qui laisse au lecteur le soin de tirer, ou non, la vérité qu'elle contient.
Il en résulte une poétique de la pensée que l'on peut comparer à celle des Essais de Montaigne ou des Caractères de La Bruyère, mais qui s'en distingue par une ironie plus aiguisée et par une économie de moyens plus stricte. Là où Montaigne développe et où La Bruyère dresse le portrait, Chamfort frappe et passe. Son style, qui fut l'un des plus loués du XVIIIe siècle français, n'est pas un ornement ajouté à la pensée : il en est la condition. La brièveté, le paradoxe, la chute inattendue ne sont pas des effets de surface, mais la manière dont la pensée saisit son objet, en sachant qu'elle ne le tient qu'un instant. Sainte-Beuve a pu dire de Chamfort qu'il écrivait « comme on grave » ; la formule est juste, à condition d'ajouter que l'outil était aussi un instrument de connaissance.
== Postérité et lectures ==
La postérité de Chamfort est sinueuse. Publié posthumément par Ginguené en l'an III (1795) sous le titre de ''Produits de la civilisation perfectionnée'', son corpus fragmentaire fut lu avec gravité par les hommes du Directoire et du premier Empire, qui y cherchaient un jugement sur le monde disparu. Les républicains y reconnurent un des leurs ; les royalistes, un esprit trop libre pour être rangé dans un camp. Le fidèle Ginguené poursuivit son travail d'éditeur malgré la disparition de la majeure partie des manuscrits, pillés au moment des scellés. Les premières Œuvres complètes, celles d'Auguis en 1824, ont joué un rôle important dans cette réception : elles rassemblaient pour la première fois l'ensemble des textes, des éloges académiques aux ''Tableaux historiques'', et elles ont fixé l'image d'un moraliste complet.
Stendhal, qui reconnaissait en Chamfort l'un de ses maîtres de prose, en a absorbé le style et les préoccupations au point qu'on a pu lire les ''Caractères et anecdotes'' comme une préfiguration de la manière stendhalienne, même économie de moyens, même goût pour le détail révélateur, même ironie retenue. Chateaubriand, qui avait connu Chamfort personnellement et l'avait fréquenté dans les derniers mois de l'Ancien Régime, lui a consacré dans son ''Essai sur les révolutions'' un portrait resté célèbre, dont la phrase, « son œil bleu, souvent froid et couvert dans le repos, lançait l'éclair quand il venait à s'animer », fixa pour longtemps l'image physique de l'homme. Balzac, selon Pierre Citron, fut un lecteur attentif des Maximes. Sainte-Beuve, dans les ''Causeries du lundi'', en fit le sujet de l'une de ses études les plus longues, y voyant un cas « des plus curieux et des plus nets d'ulcération de l'esprit ».
C'est Nietzsche qui lui a rendu l'hommage à la fois le plus appuyé et le plus complexe, principalement dans ''Le Gai Savoir'' (1881-1882), dans la préface de ''Humain, trop humain'' (1886) et dans ses ''Fragments posthumes''. Il voyait en Chamfort « le plus malicieux de tous les moralistes » et « un La Rochefoucauld du XVIIIe siècle, mais plus noble et plus philosophe » ; il saluait dans ses Maximes une œuvre possédant « à l'extrême une force de poisson-torpille ». Nietzsche reconnaissait surtout dans Chamfort le portrait d'une intelligence double, tiraillée entre la lucidité de l'observateur et la ferveur du partisan, et à qui son « trop explicable ressentiment » avait fait manquer sa pleine philosophie. « À supposer que Chamfort fût alors demeuré plus philosophe d'un degré, écrit-il dans ''Le Gai Savoir'', la Révolution eût perdu de son tragique mordant et eût été privée de son aiguillon le plus acéré : elle passerait pour un événement beaucoup plus stupide et n'exercerait pas une telle séduction sur les esprits. » Le propos, plus tard nuancé dans ''La Généalogie de la morale'' où Nietzsche intégrera Chamfort à sa théorie du ressentiment, signale à la fois une parenté reconnue, Nietzsche se reconnaissait dans ce moraliste « riche en profondeurs et en arrière-fonds de l'âme, sombre, douloureux, ardent », et une distance : Chamfort, à la différence de Nietzsche, n'a pas voulu se retirer dans la seule supériorité intellectuelle, il a préféré payer de sa personne, et c'est cela même qui lui donne, aux yeux du philosophe allemand, sa singularité tragique.
Au XXe siècle, Chamfort a été lu principalement dans la tradition française du moralisme, à côté de La Rochefoucauld, La Bruyère et Vauvenargues ; Albert Camus, dans une préface souvent citée de 1944, l'a présenté comme un écrivain de la lucidité et du refus, et a contribué à le réintroduire dans le canon moderne. Cioran, à sa suite, l'a placé parmi ses moralistes d'élection. Les éditions modernes, celle de Jean Dagen dans la collection GF (1968), celle de Claude Roy, plus récemment l'anthologie ''La Pensée console de tout'' présentée par Claude Arnaud (2014), ont progressivement rendu disponibles les parties de l'œuvre qui avaient été négligées, notamment les écrits politiques et les ''Tableaux historiques''. La biographie qu'a consacrée à Chamfort le même Claude Arnaud en 1988 a, de son côté, renouvelé en profondeur la connaissance de sa vie : en éclairant la question de la naissance, les réseaux de l'amitié avec Mirabeau et Sieyès, le rôle politique de « l'éminence grise » de la première Révolution, elle a permis de lire enfin Chamfort comme un auteur dont la cohérence n'est pas d'abord celle d'un recueil, mais celle d'une trajectoire.
== Conclusion ==
Chamfort occupe, dans l'histoire de la philosophie morale française, une position qui ne se laisse rattacher à aucune école. Héritier des moralistes classiques par la forme qu'il donne à ses pensées, contemporain des Lumières par la confiance qu'il accorde à l'observation et par sa critique des préjugés, républicain de la première heure par conviction plus que par doctrine, il articule ces héritages avec une indépendance qui lui est propre. Sa critique sociale, l'une des plus incisives de sa génération, ne débouche ni sur un système ni sur une utopie : elle s'appuie sur la conviction simple qu'il existe, par-dessous les compositions factices de la vie commune, une nature humaine qu'il est possible de respecter si l'on ne consent pas à s'en laisser dépouiller.
Cette position l'expose à une forme de tragique propre, que les interprètes ont diversement qualifiée, « ulcération de l'esprit » selon Sainte-Beuve, « ressentiment » selon Nietzsche, « bâtardise » selon Arnaud, et dont aucune formule ne rend compte à elle seule. Né en marge, il a porté sa vie entière la marque d'une double appartenance sociale, et c'est sans doute de là que venait une part de sa lucidité et de son besoin de ne jamais s'établir. Mais d'autres facteurs, la maladie chronique, les échecs littéraires, la fréquentation prolongée d'un monde dont il voyait les artifices, ont contribué à façonner un regard que l'on aurait tort de dériver d'une seule cause. La société, chez lui, n'a jamais été simplement l'objet d'une critique extérieure : elle a été, en même temps, le lieu de son humiliation et de son triomphe, un ordre qu'il observait de l'intérieur parce qu'il n'y avait eu sa place que par effraction, mais aussi par talent, par séduction et par un effort de volonté dont il ne faut pas sous-estimer l'étendue.
Si la société corrompt par ses institutions ce que la nature avait donné de meilleur, et si la réforme politique elle-même ne peut s'accomplir qu'au prix de nouvelles violences, que reste-t-il à l'homme qui pense ? La réponse de Chamfort, celle qu'il a donnée par ses Maximes et qu'il a scellée par sa mort, est que reste du moins le devoir de ne pas mentir à soi-même. C'est peu ; c'est aussi beaucoup, puisque c'est le seul terrain sur lequel la dignité du caractère, sa seule morale, demeure intégralement entre les mains de celui qui la pratique. On comprend alors que ses Maximes, en dépit de leur apparente dispersion, forment une œuvre : elles sont l'inventaire des occasions dans lesquelles cette dignité s'exerce ou se manque, et c'est à cet inventaire que Chamfort a consacré les observations de toute une vie.
Ce qui survit ainsi de lui n'est pas une doctrine, mais une figure et un ton. Figure d'un écrivain qui a refusé toutes les complaisances, et dont les contemporains disaient déjà qu'il écrivait « comme on grave » ; ton d'une ironie qui n'est jamais pure cruauté, parce qu'elle s'applique d'abord à celui qui la formule. Ce ton, la postérité l'a reconnu chez Stendhal, chez Nietzsche, chez tous ceux qui, sans fonder d'école, ont fait de la brièveté et de l'acuité les instruments d'une pensée morale sans illusions. Chamfort est de cette famille : celle des moralistes qui, pour avoir regardé le monde d'assez près, ont conclu qu'il valait mieux le dire que l'expliquer, et dont l'œuvre, précisément parce qu'elle ne se clôt sur aucun système, continue d'être utile à quiconque veut, à son tour, ne pas mentir.
== Indications bibliographiques ==
=== Sources primaires ===
* {{ouvrage|auteur1=Chamfort|responsabilité1=aut.|directeur1=Pierre-Louis Ginguené|titre=Œuvres de Chamfort|lieu=Paris|éditeur=Imprimerie des Sciences et Arts|année=an III (1795)|tome=4 vol.}}
: Première publication posthume, à laquelle on doit l'essentiel de ce que nous lisons aujourd'hui sous le nom de ''Maximes et pensées'' et de ''Caractères et anecdotes''. Ginguené y a accompli un travail d'établissement dans des conditions difficiles, une grande partie des manuscrits ayant été volée au moment de la pose des scellés.
* {{ouvrage|auteur1=Chamfort|responsabilité1=aut.|directeur1=P. R. Auguis|titre=Œuvres complètes de Chamfort|sous-titre=recueillies et publiées avec une notice historique sur la vie et les écrits de l'auteur|lieu=Paris|éditeur=Chaumerot jeune|année=1824-1825|tome=5 vol.}}
: Édition rassemblant les œuvres littéraires, critiques, politiques et morales. C'est à elle que renvoient la plupart des citations du présent travail. La notice d'Auguis, fondée sur les papiers de Ginguené, est le premier récit continu de la vie de Chamfort. Réédition en fac-similé par Slatkine.
* {{ouvrage|auteur1=Chamfort|directeur1=Jean Dagen|titre=Maximes et pensées, caractères et anecdotes|lieu=Paris|éditeur=Garnier-Flammarion|année=1968|id=Dagen 1968}} (rééd. 2013).
: Édition de référence en format courant, avec introduction, notes et établissement philologique solide des fragments ; c'est celle qui s'est imposée dans l'usage universitaire.
* {{ouvrage|auteur1=Chamfort|titre=Maximes et pensées, caractères et anecdotes|préface=[[Albert Camus]]|lieu=Monaco|éditeur=Éditions du Rocher|année=1944}}
: Préface historiquement importante qui a contribué à la redécouverte de Chamfort au {{s|XX}} et qui ouvre toute la réception contemporaine.
* {{ouvrage|auteur1=Chamfort|directeur1=Claude Arnaud|titre=La Pensée console de tout|lieu=Paris|éditeur=[[Éditions Flammarion|Flammarion]]|collection=GF|année=2014}}
: Anthologie récente accompagnée d'un appareil critique à jour et d'une présentation qui prolonge le travail biographique de 1988.
=== Études ===
* {{ouvrage|auteur1=Claude Arnaud|titre=Chamfort. Biographie|sous-titre=suivie de soixante-dix maximes, anecdotes, mots et dialogues inédits ou jamais réédités|lieu=Paris|éditeur=[[Robert Laffont]]|collection=Les hommes et l'histoire|année=1988|id=Arnaud 1988}} (rééd. [[Éditions Gallimard|Gallimard]], coll. « Tel », 2003).
: Biographie de référence, fondée sur un dépouillement systématique des sources d'archives. Arnaud a établi, en s'appuyant sur les mémoires inédits du baron d'Espinchal, les circonstances de la naissance de Chamfort ; il a reconstitué son rôle politique aux côtés de Mirabeau et de Sieyès ; il a proposé une lecture d'ensemble articulée autour de la bâtardise et du ressentiment. L'ouvrage publie en annexe soixante-dix fragments inédits et une enquête sur le vol des manuscrits.
* {{ouvrage|auteur1=John Renwick|directeur1=oui|titre=Chamfort and the French Revolution|lieu=Oxford|éditeur=Voltaire Foundation|collection=Studies on Voltaire and the Eighteenth Century|année=1990}}
: Voir aussi, du même auteur : « Chamfort patriote en coulisse », ''Studies on Voltaire and the 18th century'', vol. 183, 1980, {{p.|165}} sq. Travaux qui, sur la base de documents d'archives inédits, ont établi le rôle politique de Chamfort au cours des années 1789-1793 et publié plusieurs lettres inconnues.
* {{article|auteur1=Jean Dagen|titre=Chamfort moraliste|périodique=Revue d'histoire littéraire de la France|année=1968}}
: Étude préparatoire à l'édition GF qui établit les principales filiations (Montaigne, La Rochefoucauld, La Bruyère, Vauvenargues) et propose une lecture interne de la forme fragmentaire.
* {{ouvrage|auteur1=Louis Van Delft|titre=Le Moraliste classique. Essai de définition et de typologie|lieu=Genève|éditeur=[[Librairie Droz|Droz]]|année=1982}}
: Ouvrage de référence sur la tradition moraliste française, dans lequel la place de Chamfort est discutée à la lumière de ses prédécesseurs et de ses successeurs directs.
* {{ouvrage|auteur1=Maurice Pellisson|titre=Chamfort, étude sur sa vie, son caractère, ses écrits|lieu=Paris|année=1895}} (rééd. Slatkine, 1970).
: Première étude érudite importante consacrée à Chamfort après Sainte-Beuve ; elle demeure utile malgré les corrections qu'a imposées la biographie d'Arnaud.
* {{ouvrage|auteur1=Émile Doucet|titre=Chamfort et son temps|lieu=Paris|éditeur=Fasquelle|année=1944}} (rééd. Volcans, 1974).
* {{ouvrage|auteur1=Julien Teppe|titre=Chamfort, sa vie, son œuvre, sa pensée|préface=[[Jean Rostand]]|lieu=Paris|éditeur=Pierre Clairac|année=1950}}
: Deux monographies de la première moitié du {{s|XX}} qui ont accompagné la redécouverte du moraliste.
* [[Friedrich Nietzsche]], ''[[Le Gai Savoir]]'' (1882), préface à ''[[Humain, trop humain]]'' (1886), et ''Fragments posthumes'' (1881).
: Lieux essentiels de la réception allemande, où Nietzsche fait de Chamfort son moraliste d'élection et reconnaît en lui un frère en pensée.
* {{article|auteur1=[[Charles-Augustin Sainte-Beuve|Sainte-Beuve]]|titre=Chamfort|périodique=[[Causeries du lundi]]|volume=t. IV|année=1852}}
: Étude parmi les plus pénétrantes du {{s|XIX}}, qui voit dans Chamfort « un des plus curieux et des plus nets cas d'ulcération de l'esprit » et qui a longtemps déterminé la lecture du moraliste.
* {{article|auteur1=Pierre Citron|titre=Balzac, lecteur de Chamfort|périodique=L'Année balzacienne|année=1969}}
: Article qui montre, à partir de relevés précis, la présence diffuse mais réelle de Chamfort dans l'œuvre de Balzac.
* {{chapitre|auteur1=[[Georges Poulet]]|titre chapitre=Chamfort|titre ouvrage=La Distance intérieure|lieu=Paris|éditeur=[[Éditions Plon|Plon]]|année=1952}}
: Lecture phénoménologique qui, sous l'angle de la distance et du retrait, dégage l'expérience temporelle propre au moraliste.
* {{ouvrage|auteur1=[[Marc Fumaroli]]|titre=La République des lettres|lieu=Paris|éditeur=[[Éditions Gallimard|Gallimard]]|année=2015}}
: Ouvrage qui replace la trajectoire de Chamfort dans une histoire plus large des institutions lettrées de l'Ancien Régime.
n4u8k3p9wkdojaxc35zvkks5rc9o1pz