Au niveau de l’assembleur, une instruction est une ligne de texte. Le CPU, lui, lit des octets. L’ISA fixe la façon dont chaque instruction s’écrit en bits : quels bits choisissent l’opération, lesquels désignent les registres, où vont les constantes et les adresses. Cette disposition est le format d’instruction, et c’est l’une des choses les plus difficiles à changer une fois un processeur commercialisé. Le x86 décode encore des instructions disposées pour le 8086 en 1978.
Ce qu’une instruction doit dire
Toute instruction a besoin d’un opcode, le champ qui dit quoi faire. La plupart doivent aussi dire où sont leurs opérandes. Les ISA diffèrent par le nombre d’adresses d’opérandes que porte chaque instruction :
| Adresses | Exemple | Style |
|---|---|---|
| 0 | iadd (JVM) | machine à pile : les opérandes sont implicitement au sommet d’une pile |
| 1 | ADC #5 (6502) | accumulateur : l’un des opérandes est toujours le registre accumulateur |
| 2 | add eax, ecx (x86) | la destination est aussi une source |
| 3 | add w0, w1, w2 (ARM64) | deux sources et une destination séparée |
Moins d’adresses rendent chaque instruction plus courte, mais demandent plus d’instructions : calculer a = b + c prend une seule instruction à trois adresses, mais un chargement, une addition et un rangement sur une machine à pile. Les conceptions RISC modernes sont des machines à registres à trois adresses. Le x86 est à deux adresses, avec un opérande autorisé en mémoire.
Longueur fixe ou variable
- Longueur fixe : toutes les instructions ont la même taille — 32 bits sur ARM64 et RISC-V de base. Le décodage est simple et rapide : l’instruction suivante commence toujours 4 octets plus loin, donc un CPU peut en décoder plusieurs à la fois. Mais les instructions simples gaspillent des bits, et les constantes doivent tenir dans les champs restants.
- Longueur variable : une instruction prend autant d’octets qu’il lui en faut — de 1 à 15 sur x86. Le code est plus dense, ce qui aide le cache d’instructions. Mais le décodeur ne peut pas savoir où commence l’instruction suivante tant qu’il n’a pas déterminé la longueur de celle-ci.
Beaucoup d’ISA à longueur fixe ont ajouté une forme compressée de 16 bits pour la densité : Thumb sur l’ARM 32 bits, et l’extension C sur RISC-V.
Les concepteurs arbitrent entre plusieurs contraintes. Des instructions plus courtes, c’est plus d’instructions par ligne de cache et par cycle de lecture, et la bande passante mémoire est rare. Il faut de la place dans l’espace des opcodes pour chaque opération, et pour celles qu’on ajoutera au fil des décennies. Et chaque champ d’adresse doit être assez large pour les registres ou la mémoire qu’il désigne.
Les opcodes expansibles
Un format fixe peut quand même donner plus de bits d’opcode à certaines instructions qu’à d’autres. Prenons une instruction de 16 bits avec un opcode de 4 bits et trois champs de registre de 4 bits. Normalement, cela permet 16 instructions à trois registres. Mais si une valeur d’opcode, 1111, signifie « l’opcode continue dans le champ suivant », l’espace se subdivise :
| Bits d’opcode | Champs d’opérandes restants | Instructions |
|---|---|---|
4 (0000–1110) | 3 × 4 bits | 15 à trois adresses |
8 (1111 0000–1111 1101) | 2 × 4 bits | 14 à deux adresses |
12 (1111 1110 …, 1111 1111 …) | 1 × 4 bits | 31 à une adresse |
16 (1111 1111 1111 …) | aucun | 16 sans adresse |
Chacune des 65 536 combinaisons de bits est utilisée : 15 × 4096 + 14 × 256 + 31 × 16 + 16 = 65 536. La même idée — des opcodes courts pour les instructions qui ont besoin de beaucoup de bits d’opérandes, des opcodes plus longs pour les autres — se retrouve dans presque tous les encodages réels, y compris les octets d’échappement du x86.
x86-64 : visite champ par champ
Une instruction x86-64 est une suite de champs facultatifs autour d’un opcode obligatoire :
| Champ | Taille | Rôle |
|---|---|---|
| préfixes | 0–4 octets | modifient l’instruction : 66 opérandes de 16 bits, f0 lock, f3 rep… |
| REX | 0–1 octet | 0x40–0x4f : W = opérandes de 64 bits ; R, X, B = 4ᵉ bit des numéros de registre (pour r8–r15) |
| opcode | 1–3 octets | l’opération ; 0f est l’octet d’échappement qui ouvre une seconde table d’opcodes |
| ModRM | 0–1 octet | mod (2 bits) : registre ou mémoire, et taille du déplacement ; reg (3) : un registre ou une extension d’opcode ; rm (3) : l’autre opérande |
| SIB | 0–1 octet | scale (2), index (3), base (3), pour les adresses comme [base + index×échelle] |
| déplacement | 0, 1 ou 4 octets | la constante d’une adresse, comme le 8 de [rbx+8] |
| immédiat | 0, 1, 2 ou 4 octets (8 pour movabs) | un opérande constant |
Le total est plafonné à 15 octets. L’opcode lui-même dit peu de chose : le décodeur doit le reconnaître entièrement avant même de savoir si un octet ModRM suit. Le texte de Tanenbaum donne 0xFF comme octet d’échappement ; c’est en réalité 0x0F. 0xFF est un opcode ordinaire, utilisé pour inc, dec, call, jmp et push sur la mémoire.
Tapez une instruction dans l’encodeur : il montre chaque champ, avec les bits de ModRM et de SIB détaillés :
objdump shows03 44 8b 08 add eax, dword ptr [rbx + 4*rcx + 0x8]
03 → ADD r32, r/m32 (dst += src): ModRM.reg is the destination, ModRM.rm the source.
mod=01 memory + disp8 · reg=eax · rm=SIB.
01mod000reg100rm- mod=01
- memory + disp8
- reg=000
- eax
- rm=100
- 100: a SIB byte follows
Scale-Index-Base: address = base + index × scale + disp.
10scale001index011base- scale=10
- ×4
- index=001
- rcx
- base=011
- rbx
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.
03 44 8b 08 : l’opcode 03 signifie « ajouter un opérande registre-ou-mémoire à un registre de 32 bits ». Le ModRM 44 vaut 01 000 100 : mod 01 indique la mémoire avec un déplacement de 8 bits, reg 000 est eax, et rm 100 annonce un octet SIB. Le SIB 8b vaut 10 001 011 : échelle ×4, index rcx, base rbx. Puis vient le déplacement, 08.
L’encodage a ses bizarreries, et chacune se voit dans un désassemblage :
mov eax, DWORD PTR [rsp+8]exige un octet SIB (8b 44 24 08), car rm =100, le code dersp, a été réservé pour dire « un SIB suit ».mov eax, DWORD PTR [rbp]exige un déplacement nul (8b 45 00), car mod00avec rm =101a été réservé à l’adressage relatif àrip.r8–r15exigent un octet REX pour fournir le quatrième bit de registre, etr12hérite en plus de la règle SIB dersp:
objdump shows45 8b 04 24 mov r8d, dword ptr [r12]
REX = 0100WRXB. R: extends ModRM.reg; B: extends ModRM.rm / SIB.base / opcode reg.
0100fixed0W1R0X1B- fixed=0100
- marks a REX prefix (0x40–0x4F)
- W=0
- default size
- R=1
- ModRM.reg + 8 → r8d
- X=0
- index not extended
- B=1
- SIB.base + 8 → base r12
8B → MOV r32, r/m32 (dst = src): ModRM.reg is the destination, ModRM.rm the source.
mod=00 memory, no displacement · reg=r8d · rm=SIB.
00mod000reg100rm- mod=00
- memory, no displacement
- reg=000
- r8d
- rm=100
- 100: a SIB byte follows
Scale-Index-Base: address = base + disp (needed only because r12 as a base is encoded rm=100, which means "SIB follows").
00scale100index100base- scale=00
- ×1
- index=100
- 100: no index
- base=100
- r12 (with REX.B)
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.
45 8b 04 24 : le REX 45 a R à 1 (la destination est le registre 8 + 0 = r8d) et B à 1 (la base est 8 + 4 = r12). L’octet SIB n’est là que parce que les trois bits bas de r12, 100, sont les mêmes que ceux de rsp.
Quelques autres motifs à reconnaître :
48 83 c0 01estadd rax, 1.48est REX.W pour des opérandes de 64 bits, et83est la forme avec un immédiat de 8 bits étendu avec le signe : les petites constantes prennent un octet au lieu de quatre.0f af c1estimul eax, ecx.0frenvoie à la table d’opcodes sur deux octets, où vivent la plupart des instructions ajoutées après le 8086.66 89 d8estmov ax, bx. Le préfixe66fait passer une instruction de 32 bits à 16 bits.f0 83 07 01estlock add DWORD PTR [rdi], 1: un incrément atomique.
ARM64 et RISC-V : des champs fixes
Dans un format fixe de 32 bits, chaque champ est au même endroit dans toutes les instructions de son type. Le add w0, w1, w2 de l’ARM64 vaut 0x0b020020 :
| Bits | 31 | 30–29 | 28–24 | 23–22 | 21 | 20–16 | 15–10 | 9–5 | 4–0 |
|---|---|---|---|---|---|---|---|---|---|
| Champ | sf | op, S | 01011 | shift | 0 | Rm | imm6 | Rn | Rd |
| Valeur | 0 (32 bits) | 00 | add (registre décalé) | 00 | 0 | 2 | 0 | 1 | 0 |
Le add a0, a1, a2 du RISC-V vaut 0x00c58533, au format R : funct7 | rs2 | rs1 | funct3 | rd | opcode = 0000000 | 01100 | 01011 | 000 | 01010 | 0110011. Les numéros de registre a2 = 12, a1 = 11 et a0 = 10 se lisent directement dans les bits. Un décodeur RISC-V sait où se trouve chaque champ de registre avant même de savoir ce que fait l’instruction, et peut donc commencer à lire les registres tôt. C’est tout l’intérêt d’un format régulier.
Pourquoi c’est important pour lire des binaires
Comme les instructions x86 ont une longueur variable, un désassembleur doit trouver où chacune commence. Décodez à partir du mauvais octet et vous obtenez un autre flux d’instructions, tout à fait valide en apparence :
decodes tomov eax, 0x909090c3
B8+r → MOV r32, imm32 (dst = src): the register is in the low 3 bits of the opcode: no ModRM byte.
10111opcode000reg- opcode=10111
- B8+r
- reg=000
- eax
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.
Ces cinq octets sont mov eax, 0x909090c3. Sautez le premier octet — décodez c3 90 90 90 — et la même mémoire se lit comme un ret suivi de nop. Des instructions cachées dans d’autres instructions : c’est pourquoi les désassembleurs peuvent être trompés par des techniques d’anti-désassemblage, et c’est là que la programmation orientée retour (ROP) trouve beaucoup de ses « gadgets » : des séquences d’octets utiles au milieu du propre code d’un programme. Sur ARM64 ou RISC-V, avec des instructions fixes et alignées, c’est impossible : chaque instruction commence sur une frontière de 4 octets.
À retenir
- Un format d’instruction fixe l’emplacement de l’opcode et des champs d’opérandes. Les ISA vont des machines sans adresse (à pile) aux machines à trois adresses (à registres).
- Les formats à longueur fixe (ARM64, RISC-V : 32 bits) se décodent facilement en parallèle. Les formats à longueur variable (x86 : 1 à 15 octets) sont plus denses mais doivent être décodés l’un après l’autre.
- Les opcodes expansibles donnent des opcodes courts aux instructions qui ont besoin de beaucoup de bits d’opérandes, et des opcodes longs aux autres.
- Le x86-64 encode une instruction en préfixes, REX, opcode, ModRM, SIB, déplacement, immédiat. Ses bizarreries — l’octet SIB pour
rsp, le déplacement nul pourrbp, REX pourr8–r15— se voient dans chaque désassemblage. - La longueur variable permet de cacher des instructions dans d’autres, ce qui compte pour les désassembleurs et les exploits.