Les niveaux au-dessus de celui-ci — langages de programmation, assembleur, système d’exploitation — produisent ou exécutent tous du code machine. Les niveaux en dessous — microarchitecture, logique, composants — construisent une machine qui l’exécute. L’architecture du jeu d’instructions (ISA, instruction set architecture) est la frontière entre les deux : un contrat qui dit exactement ce que fait chaque instruction, et rien de la façon dont le matériel y parvient. Tout ce qui est au-dessus ne s’appuie que sur le contrat ; tout ce qui est en dessous est libre de changer tant qu’il le respecte. C’est pourquoi un programme compilé pour x86-64 en 2005 tourne encore sur un processeur de 2026 construit sur une microarchitecture entièrement différente.
Ce chapitre présente le contenu de ce contrat : modes, registres, modèle mémoire — et les deux endroits où le matériel sous-jacent transparaît : l’alignement et l’ordre des accès.
Un contrat écrit
Tanenbaum définit l’ISA comme ce que doit savoir un auteur de compilateur : le modèle mémoire, les registres, les types de données et les instructions. Que la machine soit pipelinée, superscalaire ou microprogrammée n’en fait pas partie, même si cela influe sur les performances, auxquelles les compilateurs tiennent.
Pour la plupart des ISA, le contrat est un document formel, écrit pour permettre à plusieurs entreprises de construire des puces compatibles. L’ARM Architecture Reference Manual définit ARM. Les spécifications RISC-V, publiées par RISC-V International, sont ouvertes : chacun peut les implémenter sans licence. Le x86 est décrit par le Software Developer’s Manual d’Intel, qui comptait selon le livre 4 161 pages pour le premier Core i7, et n’a fait que grossir depuis. Ces documents sont rédigés comme des textes juridiques : une instruction doit (shall) lever une exception dans tel cas, tandis que d’autres comportements sont définis par l’implémentation, laissés à chaque concepteur de puce. Un code qui s’appuie sur un comportement défini par l’implémentation fonctionne sur une puce et casse sur une autre.
Le contrat définit aussi des niveaux de privilège. Les programmes tournent en mode utilisateur, où les instructions qui contrôlent la machine — tables de pages, interruptions, ports d’E/S, contrôle des caches — sont interdites et provoquent une faute. Le système d’exploitation tourne en mode noyau, où tout est permis. Le x86 les appelle anneaux 3 et 0, ARM64 EL0 et EL1 (avec EL2 pour les hyperviseurs et EL3 pour le micrologiciel), et RISC-V mode U et mode S (avec le mode M pour le micrologiciel). Tout ce niveau se place en mode utilisateur, sauf mention contraire.
Les registres
Les registres sont la mémoire la plus rapide de l’ISA : une poignée d’emplacements nommés, chacun large d’un mot, que les instructions utilisent directement. Il en existe deux sortes : les registres spécialisés — compteur de programme, pointeur de pile, indicateurs — et les registres généraux pour les données.
| Registres généraux | Remarques | |
|---|---|---|
| x86-64 | 16 (rax…r15) | certains ont un rôle imposé dans d’anciennes instructions (rdx pour mul/div, rcx pour les décalages et rep) ; l’extension APX d’Intel les porte à 32 |
| ARMv7 (ARM 32 bits) | 16 (r0…r15) | le compteur de programme est r15, l’un des seize : y écrire est un saut |
| ARM64 | 31 (x0…x30) | plus un numéro de registre qui se lit comme zéro ou désigne le pointeur de pile, selon l’instruction |
| RISC-V | 32 (x0…x31) | x0 vaut toujours zéro |
Le livre affirme que les machines RISC ont en général au moins 32 registres généraux. C’est vrai de MIPS, SPARC, RISC-V et ARM64, mais le RISC le plus répandu à l’époque du livre, l’ARM 32 bits, n’en avait que 16, compteur de programme compris.
Le registre d’indicateurs (le PSW de Tanenbaum) contient les codes de condition — zéro, négatif, retenue, débordement — par lesquels communiquent les instructions de comparaison et de branchement, comme l’a montré le chapitre sur les indicateurs. Toutes les ISA n’en ont pas : RISC-V n’a délibérément aucun indicateur. Ses branchements comparent directement deux registres (blt a0, a1, cible), et détecter un débordement demande une comparaison explicite. Cela supprime un registre que toutes les instructions mettraient sinon à jour, et une dépendance qui complique l’exécution dans le désordre.
Le modèle mémoire : des octets et des adresses
Au niveau de l’ISA, la mémoire est un tableau d’octets, chacun doté d’une adresse, de 0 à un maximum. Sur les ISA 64 bits, les adresses sont des nombres de 64 bits, mais aucune puce actuelle n’implémente les 2⁶⁴ octets. Le x86-64 utilise des adresses virtuelles de 48 bits (57 avec la pagination à cinq niveaux), dont les bits de poids fort inutilisés doivent recopier le bit 47 : ce sont les adresses canoniques. ARM64 utilise 48 ou 52 bits.
La plupart des machines utilisent un seul espace d’adressage pour le code et les données — le modèle de von Neumann. Certains petits microcontrôleurs, comme l’AVR ATmega168 que Tanenbaum prend en exemple, séparent mémoire de programme et mémoire de données dans deux espaces d’adressage distincts — le modèle Harvard —, si bien que l’adresse 8 ne désigne pas la même chose pour une lecture d’instruction et pour un chargement. Les processeurs de bureau modernes sont von Neumann au niveau de l’ISA, même si leurs caches L1 sont séparés en interne entre instructions et données.
L’alignement : ce qu’il coûte aujourd’hui
Une valeur est alignée quand son adresse est un multiple de sa taille : un mot de 8 octets à l’adresse 0, 8, 16… Le chapitre sur les variables a montré les compilateurs insérer du remplissage pour le garantir. C’est l’ISA qui décide de ce qui se passe sinon.
Le x86 a toujours accepté les accès mal alignés, depuis le 8088 et son bus de 1 octet. Le simulateur, qui est un x86-64, les accepte aussi :
À essayer : Appuyez sur Step pour exécuter une instruction, Run pour animer ou Continue pour aller au bout ; les boutons L2 à L7 changent de niveau, vers le bas ou le haut.
- .data
- bytes: .quad 0x0807060504030201, 0x100f0e0d0c0b0a09
- .text
- lea rsi, [rip+bytes]
- mov rax, QWORD PTR [rsi] ; aligné : octets 0 à 7
- mov rbx, QWORD PTR [rsi+1] ; mal aligné : octets 1 à 8
- mov ecx, DWORD PTR [rsi+6] ; mal aligné : octets 6 à 9
rbx finit par valoir 0x0908070605040302 : les 8 octets à partir du décalage 1, assemblés dans l’ordre petit-boutiste.
Tanenbaum explique que les accès mal alignés sont lents parce que l’interface mémoire ne transfère que des mots de 8 octets alignés : le CPU doit alors faire deux accès et recoller le résultat. C’était vrai du bus mémoire, mais ce n’est plus ce que voit un chargement : les chargements sont servis par le cache L1, qui fournit n’importe quels octets à l’intérieur d’une ligne de cache. Ce qui coûte, c’est de franchir la limite d’une ligne de cache ou d’une page. Mesuré sur un Apple M2 (ARM64, lignes de cache de 128 octets, pages de 16 Kio) avec une chaîne de chargements de 8 octets dépendants :
| Où tombe le chargement de 8 octets | Temps par chargement |
|---|---|
| aligné | 1,19 ns |
| décalé de 1, 60 ou 120 octets, dans une même ligne de cache | 1,19 ns — aucune différence |
| à cheval sur deux lignes de 128 octets | 1,26 ns |
| à cheval sur deux pages de 16 Kio | environ 4 fois plus lent que des chargements alignés dans des pages distinctes |
Le chargement à cheval sur deux pages demande deux traductions d’adresse et touche deux pages : c’est là qu’est le vrai coût. L’alignement compte encore à quelques endroits : certaines instructions l’exigent (le movaps du x86 provoque une faute sur un vecteur qui n’est pas aligné sur 16 octets) ; RISC-V permet aux puces de lever une exception sur un accès mal aligné et de l’émuler en logiciel, des centaines de fois plus lentement ; et les opérations atomiques à cheval sur deux lignes de cache posent problème partout — sur x86, un tel split lock bloque tout le système mémoire, et Linux peut avertir ou tuer les programmes qui en font.
L’ordre des accès mémoire : ce que voient les autres cœurs
Le livre soulève une question plus difficile. Un chargement qui suit un rangement à la même adresse doit voir la valeur rangée — toutes les ISA le garantissent pour un seul cœur. Mais le cœur réordonne ses accès mémoire en interne, comme l’a montré le chapitre sur l’exécution dans le désordre, et garde ses rangements dans un tampon de rangement (store buffer) avant qu’ils n’atteignent le cache. Avec plusieurs cœurs partageant la mémoire, la question devient : dans quel ordre un autre cœur voit-il mes chargements et mes rangements ? La réponse de l’ISA est son modèle de cohérence mémoire, et le livre esquisse la gamme qui va de l’ordre strict à l’absence de toute garantie. Les ISA actuelles ont des réponses précises :
- x86 — TSO (total store order) : presque séquentiel. Le seul réordonnancement permis est qu’un chargement se termine avant qu’un rangement antérieur à une adresse différente soit devenu visible : le rangement attend encore dans le tampon de rangement.
- ARM64 et RISC-V — ordre faible (RISC-V appelle son modèle RVWMO) : les chargements et rangements à des adresses différentes peuvent devenir visibles dans presque n’importe quel ordre, sauf si le programme en demande davantage.
De petits tests litmus rendent cela visible. Dans le test store buffering, deux threads écrivent chacun une variable puis lisent l’autre :
Thread 0 Thread 1
x = 1 y = 1
r1 = y r2 = x
Quel que soit l’entrelacement, au moins un thread doit voir l’écriture de l’autre — sauf si les rangements peuvent attendre dans un tampon pendant que les chargements suivants passent devant. Exécuté 2 millions de fois sur le M2 en mode ARM64 natif, les deux threads lisent 0 dans environ 91 % des cas. Exécuté comme programme x86-64 sous Rosetta 2, qui fait passer les cœurs du M2 dans un mode TSO matériel pour respecter les règles du x86, cela arrive encore dans environ 97 % des cas : TSO autorise exactement ce réordonnancement. Avec une barrière complète entre le rangement et le chargement, cela n’est arrivé aucune fois dans les deux modes.
Le test message passing est celui qui casse les programmes :
Écrivain Lecteur
data = 42 f = flag
flag = 1 d = data // f peut-il valoir 1 alors que d vaut 0 ?
TSO interdit ce résultat : les rangements deviennent visibles dans l’ordre, et les chargements s’effectuent dans l’ordre. Les modèles d’ARM64 et de RISC-V l’autorisent. En 2 millions d’exécutions sur le M2, il ne s’est jamais produit, dans aucun des deux modes — mais l’architecture le permet, donc un code correct ne peut pas compter sur le fait qu’une puce donnée ne le fasse pas.
Les programmes rétablissent l’ordre avec des barrières (fences) et des accès ordonnés. En C, atomic_store_explicit(&flag, 1, memory_order_release) et le chargement memory_order_acquire correspondant rendent le motif message passing correct partout, et le compilateur choisit les instructions dont chaque ISA a besoin. D’après clang :
| x86-64 | ARM64 | |
|---|---|---|
rangement release de flag | simple mov | stlr (store-release) |
chargement acquire de flag | simple mov | ldar (load-acquire) |
| barrière complète | lock or [rsp-64], 0 | dmb ish |
Sur x86, TSO fournit déjà l’ordre acquire et release, donc de simples mov suffisent. Seule la barrière complète coûte quelque chose, et clang l’implémente par une instruction verrouillée, moins coûteuse que mfence. Sur ARM64, l’ordre doit être demandé à chaque accès. C’est le compromis dans les termes du livre : plus le modèle est faible, plus le matériel a de liberté, et plus le compilateur et le programmeur doivent être explicites. Un code qui fonctionne par chance sur x86 — des variables ordinaires servant de drapeaux entre threads — peut casser une fois porté sur ARM. Les modèles mémoire du C et du C++ ont été conçus pour masquer ces différences derrière les atomiques.
À retenir
- L’ISA est un contrat : registres, modèle mémoire, types de données et instructions, spécifiés précisément (shall / défini par l’implémentation), tandis que la microarchitecture est libre de changer en dessous.
- Le mode utilisateur interdit les instructions qui contrôlent la machine ; le mode noyau permet tout.
- Le x86-64 a 16 registres généraux, ARM64 31, RISC-V 32 dont
x0vaut toujours zéro. RISC-V n’a aucun registre d’indicateurs. - La mémoire est un tableau d’octets adressables ; les puces 64 bits implémentent de 48 à 57 bits d’adresse. La plupart des CPU sont von Neumann ; certains microcontrôleurs sont Harvard.
- Les chargements mal alignés sont gratuits à l’intérieur d’une ligne de cache sur les CPU actuels ; franchir une ligne coûte un peu, franchir une page beaucoup. Certaines instructions et les atomiques exigent encore l’alignement.
- Le modèle de cohérence mémoire dit ce que voient les autres cœurs. Le TSO du x86 ne laisse un chargement passer que devant un rangement antérieur (le tampon de rangement) : mesuré sur un M2, 91 à 97 % des exécutions du test store buffering le montrent. ARM64 et RISC-V sont faiblement ordonnés. Les barrières, acquire et release rétablissent l’ordre.