Chaque opérande doit dire où se trouve sa valeur : dans l’instruction, dans un registre, ou quelque part en mémoire. Les différentes façons de le dire sont les modes d’adressage de l’ISA. C’est un choix de conception. Un jeu riche rend chaque instruction plus puissante mais plus difficile à décoder ; un jeu minimal garde le matériel simple mais demande plus d’instructions. Le niveau de l’assembleur a présenté les trois sortes d’opérandes. Ce chapitre regarde les modes eux-mêmes, d’une ISA à l’autre.
Les modes
| Mode | L’opérande est… | Exemple x86 | Usage typique |
|---|---|---|---|
| immédiat | dans l’instruction | mov eax, 5 | constantes |
| registre | dans un registre | mov ebx, eax | variables locales gardées en registre par le compilateur |
| direct (absolu) | à une adresse fixe donnée dans l’instruction | mov eax, DWORD PTR [0x404000] | globales, dans du code dépendant de la position |
| indirect par registre | à l’adresse contenue dans un registre | mov ecx, DWORD PTR [rsi] | pointeurs : *p |
| base + déplacement | à un registre plus une constante | mov edx, DWORD PTR [rsi+8] | locales ([rbp-4]), champs de structure (p->y) |
| base + index × échelle (+ déplacement) | à base + index × 1, 2, 4 ou 8 (+ constante) | mov r8d, DWORD PTR [rsi+rdi*4] | tableaux : a[i], tableaux de structures |
| relatif au PC | à un décalage du pointeur d’instruction | mov r10d, DWORD PTR [rip+arr+4] | globales en code indépendant de la position, cibles de branchement |
| pile (implicite) | au sommet de la pile | push rax, pop r11 | appels, registres sauvegardés |
Les noms de Tanenbaum diffèrent un peu : indexé pour un registre plus une constante, et basé-indexé pour deux registres plus une constante facultative. Ce sont les mêmes idées.
Voir l’adresse se calculer
Ce programme lit le même petit tableau de plusieurs façons. L’adresse de chaque opérande mémoire est calculée par l’AGU (unité de génération d’adresses) avant le chargement. Le simulateur s’ouvre au niveau µarch, où chaque étape montre ce calcul :
- .data
- arr: .long 10, 20, 30, 40
- .text
- mov eax, 5 ; immédiat
- mov ebx, eax ; registre
- lea rsi, [rip+arr] ; relatif au PC : rsi = &arr
- mov ecx, DWORD PTR [rsi] ; indirect par registre : arr[0] = 10
- mov edx, DWORD PTR [rsi+8] ; base + déplacement : arr[2] = 30
- mov edi, 3
- mov r8d, DWORD PTR [rsi+rdi*4] ; base + index×4 : arr[3] = 40
- mov r9d, DWORD PTR [rsi+rdi*4-4] ; base + index×4 + déplacement : arr[2] = 30
- mov r10d, DWORD PTR [rip+arr+4] ; relatif au PC : arr[1] = 20
- push rax ; pile : écriture en rsp - 8
- pop r11 ; pile : lecture en rsp
Pour mov r8d, DWORD PTR [rsi+rdi*4], l’AGU affiche rsi (0x404000) + rdi × 4 = 0x40400c, puis la lecture sur le bus renvoie 40. Le facteur d’échelle existe parce que les éléments d’un tableau font 1, 2, 4 ou 8 octets : l’index reste un numéro d’élément, et le matériel le multiplie gratuitement par la taille de l’élément.
Du C aux modes d’adressage
Les compilateurs font correspondre les expressions C à ces modes si directement qu’on peut souvent relire le C dans l’assembleur :
| C | Opérande x86-64 typique |
|---|---|
locale x | un registre, ou [rbp-4] / [rsp+12] |
*p | [rax] |
p->y (champ au décalage 8) | [rax+8] |
a[i] (int) | [rax+rcx*4] |
a[i].y (structures de 16 octets, y au décalage 8) | shl rcx, 4 puis [rax+rcx+8] — l’échelle ne va que jusqu’à 8 |
globale g | [rip+g] |
&a[i] | lea rdx, [rax+rcx*4] — la même adresse, sans le chargement |
Cette dernière ligne explique l’existence de lea, et pourquoi les compilateurs s’en servent aussi pour de l’arithmétique ordinaire : il exécute l’addition et la mise à l’échelle de l’AGU sans toucher à la mémoire.
Ce que coûtent les modes en octets
Des modes plus riches, ce sont des instructions plus longues. Le même chargement, avec des adresses différentes :
objdump shows8b 56 08 mov edx, dword ptr [rsi + 0x8]
8B → MOV r32, r/m32 (dst = src): ModRM.reg is the destination, ModRM.rm the source.
mod=01 memory + disp8 · reg=edx · rm=base rsi.
01mod010reg110rm- mod=01
- memory + disp8
- reg=010
- edx
- rm=110
- base rsi
64-bit mode, Intel syntax. Where several encodings are valid, this picks the one clang emits; the text is what objdump -d -M intel prints. Branch targets are written relative to the instruction: jmp $+0x10.
objdump shows8b 86 00 10 00 00 mov eax, dword ptr [rsi + 0x1000]
8B → MOV r32, r/m32 (dst = src): ModRM.reg is the destination, ModRM.rm the source.
mod=10 memory + disp32 · reg=eax · rm=base rsi.
10mod000reg110rm- mod=10
- memory + disp32
- reg=000
- eax
- rm=110
- base rsi
64-bit mode, Intel syntax. Where several encodings are valid, this picks the one clang emits; the text is what objdump -d -M intel prints. Branch targets are written relative to the instruction: jmp $+0x10.
[rsi+8] loge son déplacement dans un octet (8b 56 08). [rsi+0x1000] en demande quatre (8b 86 00 10 00 00). Un index mis à l’échelle ajoute un octet SIB : mov r8d, DWORD PTR [rsi+rdi*4] s’écrit 44 8b 04 be. Et en mode 64 bits, une adresse absolue comme [0x404000] ne peut s’encoder qu’à travers un octet SIB sans base (8b 04 25 00 40 40 00), parce que l’encodage court qui signifiait « adresse absolue » en mode 32 bits a été réaffecté à l’adressage relatif à rip. C’est l’une des raisons pour lesquelles le code 64 bits accède à ses globales par [rip+…].
Adresser les cibles de branchement
Les branchements ont aussi besoin d’adresses, avec leurs propres modes :
- Relatif au PC :
jmp,jccetcallencodent normalement un décalage signé depuis l’instruction suivante, sur 8 bits pour les cibles proches et 32 bits sinon. Le code fonctionne où qu’il soit chargé, ce qui rend possibles le code indépendant de la position et l’ASLR. - Indirect par registre ou mémoire :
jmp rax,call QWORD PTR [rax+16]. La cible est calculée à l’exécution. C’est ainsi que fonctionnent les tables de saut desswitch, les pointeurs de fonction et les appels virtuels du C++, et c’est pourquoi ils ont besoin d’un prédicteur de branchements indirects. - Pile :
retprend sa cible au sommet de la pile.
D’autres ISA font d’autres choix
- RISC-V est une architecture load/store pure : l’arithmétique ne travaille que sur des registres, et chargements et rangements n’ont qu’un seul mode mémoire, registre de base + décalage de 12 bits. Lire
a[i]demande trois instructions : décaler i de 2, ajouter la base, puislwavec un décalage nul. Le décodeur reste trivial, et le compilateur fait le reste. - ARM64 est aussi load/store, mais ses chargements acceptent un registre de décalage mis à l’échelle (
ldr w0, [x1, x2, lsl #2]lita[i]en une instruction). Il a aussi des modes pré- et post-indexés qui mettent à jour le registre de base au passage :ldr x0, [x1], #8charge, puis avancex1de 8, ce qui est idéal pour parcourir un tableau. - x86 autorise un opérande mémoire sur la plupart des instructions arithmétiques (
add eax, [rsi+rdi*4]), avec la forme d’adresse la plus riche des trois, mais au plus un opérande mémoire par instruction.
La liberté avec laquelle modes et instructions se combinent s’appelle l’orthogonalité. Dans une ISA totalement orthogonale, chaque instruction accepte chaque mode pour chaque opérande. Les anciens PDP-11 et VAX s’en approchaient. Le x86 en est loin — un seul opérande mémoire, des registres imposés pour certaines instructions. Les RISC load/store contournent la question en réservant la mémoire aux chargements et aux rangements.
Un mode qui n’existe plus : le code auto-modifiant
Avant l’adressage indirect par registre, les programmes parcouraient les tableaux en réécrivant l’adresse dans leurs propres instructions à chaque itération. Von Neumann lui-même l’avait suggéré. Cela a disparu du code ordinaire : cela empêche de partager le code entre processus, entre en conflit avec des caches d’instructions et de données séparés, et devient impossible sur une mémoire marquée non modifiable.
Mais le code qui écrit du code existe toujours. Les compilateurs JIT génèrent du code machine à l’exécution, et les exécutables compressés ou obfusqués déchiffrent leur vrai code avant d’y sauter. Le x86 détecte les écritures sur des instructions déjà dans le pipeline et les vide automatiquement. ARM exige que le programme synchronise explicitement son cache d’instructions avec le nouveau code. Et les deux doivent composer avec les règles W^X de l’OS : rendre la mémoire modifiable, puis exécutable, mais jamais les deux à la fois.
À retenir
- Un mode d’adressage dit où est un opérande : dans l’instruction (immédiat), dans un registre, à une adresse fixe (direct), à l’adresse d’un registre (indirect par registre), à un registre plus une constante (base + déplacement), à base + index × échelle, relativement au PC, ou sur la pile.
- Ces modes correspondent directement au C : locales,
*p,p->champ,a[i]et globales ont chacun une forme caractéristique, etleacalcule l’adresse sans charger. - Des modes plus riches coûtent des octets : déplacements de 1 ou 4 octets, octet SIB, et adressage relatif à
ripen mode 64 bits. - Les branchements utilisent des décalages relatifs au PC, des cibles indirectes (tables de saut, appels virtuels) et la pile (
ret). - RISC-V offre un seul mode mémoire, ARM64 ajoute des formes mises à l’échelle et auto-indexées, et x86 autorise des opérandes mémoire dans l’arithmétique. L’orthogonalité mesure la liberté de combinaison entre modes et instructions.