Un type de données a un support matériel quand une instruction attend des données dans ce format. Le logiciel peut inventer toutes les représentations qu’il veut — un entier de 256 bits, une date, un nombre rationnel —, mais seuls les formats pris en charge par le matériel ont des instructions qui les traitent directement, en une étape. Tout le reste est construit en logiciel à partir d’eux. Ce chapitre passe en revue les formats que les CPU prennent réellement en charge : entiers, décimal, virgule flottante, caractères, bits et pointeurs.
Le chapitre sur les variables a regardé ces types depuis le C : tailles, signe, ordre des octets. Ici, la question est ce que supposent les instructions elles-mêmes.
Les entiers : les mêmes bits, deux lectures
Toutes les ISA courantes prennent en charge des entiers de 8, 16, 32 et 64 bits, non signés et signés, les signés étant en complément à deux : pour prendre l’opposé d’un nombre, on inverse tous ses bits et on ajoute 1. Un octet contient 0 à 255 en non signé, ou −128 à 127 en signé. 1111 1111 vaut 255 ou −1, selon ce qu’on veut dire.
Le complément à deux l’a emporté sur les autres représentations parce que l’addition ne fait pas la différence. Additionner les motifs de bits donne le bon résultat qu’on les lise comme signés ou non signés, donc un seul additionneur et une seule instruction add servent aux deux, et la soustraction est l’addition de l’opposé. Des machines plus anciennes utilisaient le signe et valeur absolue (un bit de signe plus la valeur absolue) ou le complément à un (l’opposé inverse simplement les bits) ; les deux ont deux zéros, +0 et −0, et demandent un matériel séparé pour l’arithmétique signée. La norme C23 les a enfin abandonnés : les entiers du C sont désormais obligatoirement en complément à deux.
Puisque les bits ne disent pas quelle lecture est voulue, c’est l’instruction qui le dit. Les opérations où signé et non signé diffèrent vont par paires :
| Opération | Non signé | Signé |
|---|---|---|
| élargir vers un registre plus grand | movzx (extension par des zéros) | movsx (extension du signe : recopier le bit de poids fort) |
| comparer et brancher | jb, ja (inférieur/supérieur : CF) | jl, jg (plus petit/plus grand : SF et OF) |
| indicateur de débordement | CF = le résultat ne tient pas | OF = le résultat ne tient pas |
| décalage à droite | shr (fait entrer des zéros) | sar (fait entrer des copies du bit de signe) |
| multiplication, division | mul, div | imul, idiv |
À 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.
- mov al, 0xFF ; the byte 1111 1111
- movsx ebx, al ; read as signed: -1
- movzx ecx, al ; read as unsigned: 255
- mov dl, 200
- add dl, 100 ; 300 needs 9 bits: dl = 44, CF = 1
- mov sil, 100
- add sil, 100 ; 200 > 127: signed overflow, OF = 1
movsx remplit ebx de copies du bit de poids fort, 0xFFFFFFFF, soit −1. movzx remplit de zéros : 255. La première addition positionne la retenue, le débordement non signé. La seconde positionne à la place l’indicateur de débordement : 200 tient dans un octet en non signé, mais en signé, 100 + 100 a fait le tour et vaut −56. Chaque add positionne les deux indicateurs ; c’est le programme qui choisit lequel regarder, comme l’a montré le chapitre sur les indicateurs.
Le complément à deux n’est pourtant pas le seul codage d’entiers encore utilisé. La notation excédent k (ou biaisée) range une valeur v sous la forme v + k : avec k = 127, l’octet 0111 1111 signifie 0. Son avantage est que les grandes valeurs ont de grands motifs de bits, même négatives, et se comparent donc comme des entiers non signés. C’est exactement ainsi que sont rangés les exposants des nombres flottants, comme on va le voir.
Le décimal : le DCB
Le décimal codé binaire (DCB, ou BCD en anglais) range un chiffre décimal sur 4 bits, deux par octet : 42 s’écrit 0100 0010, soit 0x42. C’est du gaspillage — un octet contient 0 à 99 au lieu de 0 à 255 —, mais la conversion vers le texte et depuis le texte est triviale, et les fractions décimales comme 0,10 sont exactes, ce qui compte pour les comptables et les programmes COBOL. L’addition binaire donne de faux résultats sur des chiffres DCB (0x09 + 0x01 donne 0x0A, pas 0x10), donc les machines qui prennent en charge le DCB ont des instructions d’ajustement décimal qui corrigent le résultat, à l’aide de la retenue sortant du bit 3 — l’indicateur de retenue auxiliaire que le x86 calcule encore à chaque addition.
Tanenbaum range le DCB parmi les types du Core i7. C’est vrai, mais seulement en mode 32 bits. Les instructions d’ajustement décimal du x86 (DAA, DAS, AAA, AAS, AAM, AAD) ont été retirées du mode 64 bits, et leurs opcodes réaffectés : nasm assemble daa en l’octet 0x27 en mode 32 bits, et le refuse en mode 64 bits avec instruction not supported in 64-bit mode. Ce qui subsiste, ce sont les instructions FBLD et FBSTP de l’unité flottante x87, qui chargent et rangent un nombre DCB compact de 18 chiffres. ARM et RISC-V n’ont jamais eu de DCB. Les machines qui prennent encore le décimal au sérieux sont les grands systèmes et les processeurs POWER d’IBM, qui implémentent en matériel la virgule flottante décimale — les formats décimaux d’IEEE 754.
La virgule flottante
Pour les nombres non entiers, les CPU utilisent la virgule flottante : un nombre écrit ± significande × 2^exposant, l’équivalent binaire de la notation scientifique. Depuis 1985, pratiquement tous les CPU suivent la norme IEEE 754, qui fixe les formats bit à bit, ainsi que l’arrondi — si bien qu’un calcul donne le même résultat sur toute machine conforme.
Un nombre flottant binaire a trois champs :
- un bit de signe ;
- un exposant, rangé en notation excédent, avec un biais de 127 en simple précision ;
- une fraction : les bits du significande après la virgule. Pour les nombres normaux, le significande vaut 1,fraction : le 1 de tête n’est pas stocké, puisqu’un nombre binaire normalisé commence toujours par 1. C’est le bit caché, un bit de précision gratuit.
L’explorateur ci-dessous range un nombre dans chaque format. Tapez une valeur, ou cliquez sur un bit pour l’inverser :
À essayer : Tapez un nombre ou cliquez sur un exemple, changez de format (en haut à droite), ou cliquez sur un bit pour l’inverser.
- sign
- 0 → positive
- exponent
- 123 − 127 = -4
- significand
- 1.fraction = 1.60000002384185791015625
- value
- +1.60000002384185791015625 × 2^-4 = 0.100000001490116119384765625
Rounding is round-to-nearest, ties-to-even, as in hardware. Sums like 0.1+0.2 are computed in double precision first, as a program would, then stored in the chosen format.
0,1 n’a pas de représentation binaire finie : comme 1/3 en décimal, ses chiffres binaires se répètent à l’infini (0,000110011001100…). Le format garde les 24 premiers bits significatifs et arrondit, donc ce qui est réellement rangé dans un float vaut 0,100000001490116119384765625. Passez en fp64 et l’erreur tombe à environ 5,6 × 10⁻¹⁸, mais elle ne disparaît pas. C’est pourquoi, en double précision, 0.1 + 0.2 donne 0,30000000000000004 et pas 0,3 : les deux opérandes sont déjà arrondis, et leur somme aussi. Essayez 0.1+0.2 dans l’explorateur.
Le même arrondi limite les nombres entiers : un float a 24 bits de significande, donc au-delà de 2²⁴ = 16 777 216, tous les entiers ne tiennent pas. Tapez 16777217 : il est rangé comme 16777216. Un double a 53 bits, ce qui explique que le plus grand entier représentable exactement en JavaScript soit 2⁵³ − 1.
La norme réserve les exposants extrêmes aux valeurs spéciales :
- exposant nul, fraction nulle : ±0 — il existe un zéro négatif, qui est égal à +0 dans les comparaisons ;
- exposant nul, fraction non nulle : les nombres dénormalisés (subnormaux), sans 1 caché, qui comblent l’écart entre le plus petit nombre normal et zéro en perdant progressivement de la précision (essayez
1e-40en fp32) ; - exposant tout à un, fraction nulle : ±∞, le résultat d’un dépassement ou d’une division d’un nombre non nul par zéro ;
- exposant tout à un, fraction non nulle : NaN, not a number, le résultat de 0/0 ou de √−1. Un NaN est différent de tout, y compris de lui-même.
Les formats
| Format | Bits | Exposant | Fraction | Plus grande valeur finie | Chiffres décimaux |
|---|---|---|---|---|---|
binary64 (double) | 64 | 11 | 52 | 1,8 × 10³⁰⁸ | ~16 |
binary32 (float) | 32 | 8 | 23 | 3,4 × 10³⁸ | ~7 |
| binary16 (demi-précision) | 16 | 5 | 10 | 65 504 | ~3 |
| bfloat16 | 16 | 8 | 7 | 3,4 × 10³⁸ | ~2 |
Le livre donne des longueurs flottantes de 32, 64 et 128 bits. Depuis, l’apprentissage automatique a poussé dans l’autre sens : la demi-précision (fp16) et le bfloat16 de Google sont pris en charge en matériel par les x86 récents (AVX-512 FP16 et BF16), par ARM64 et par les GPU, et les accélérateurs d’IA descendent à 8 et même 4 bits. Le bfloat16 est simplement un float amputé de ses 16 bits de fraction les plus faibles : même exposant de 8 bits et même étendue, bien moins de précision — ce dont l’entraînement des réseaux de neurones a justement besoin. Passez l’explorateur de fp16 à bf16 sur 65504 ou 0.1 pour voir le compromis. À l’autre bout, la quadruple précision sur 128 bits est surtout réalisée en logiciel ; le x86 garde aussi le format étendu sur 80 bits du x87.
La virgule flottante a en général ses propres registres : xmm0–xmm15 sur x86-64 (l’ancien x87 avait une pile de 8 registres), v0–v31 sur ARM64, f0–f31 sur RISC-V avec ses extensions F et D.
Les caractères
Un caractère est rangé sous la forme d’un nombre, son point de code. L’ASCII attribue 128 caractères sur 7 bits. Tanenbaum décrit l’Unicode comme un code sur 16 bits, ce qui était sa conception d’origine. C’est dépassé depuis 1996, quand Unicode a dépassé 65 536 caractères : les points de code vont désormais jusqu’à U+10FFFF, un espace de 21 bits de 1 114 112 valeurs. On le range dans l’un de plusieurs codages :
| Caractère | Point de code | Octets UTF-8 | UTF-16 |
|---|---|---|---|
| A | U+0041 | 41 | 0041 |
| é | U+00E9 | c3 a9 | 00e9 |
| € | U+20AC | e2 82 ac | 20ac |
| 😀 | U+1F600 | f0 9f 98 80 | d83d de00 (une paire de substitution) |
L’UTF-8 utilise 1 à 4 octets par caractère et coïncide avec l’ASCII pour les 128 premiers, ce qui explique qu’il ait conquis les fichiers et le Web. L’UTF-16, utilisé à l’intérieur de Windows, de Java et de JavaScript, a besoin de deux unités de 16 bits, une paire de substitution, pour les caractères au-delà de U+FFFF.
Au niveau de l’ISA, les caractères ne sont que des octets : aucun CPU courant ne connaît l’UTF-8. Le x86 a en revanche des instructions de chaînes qui travaillent sur des suites d’octets : rep movsb copie un bloc, repne scasb cherche un octet, et pcmpistri, de SSE4.2, compare 16 octets à un ensemble de caractères en une instruction. ARM et RISC-V n’ont rien de tout cela, et laissent les chaînes aux chargements, rangements et instructions vectorielles ordinaires.
Bits et pointeurs
Un booléen n’a besoin que d’un bit, mais un bit isolé n’a pas d’adresse, donc les langages rangent chaque booléen dans un octet. Quand ils sont nombreux — une table de bits des blocs libres d’un disque, l’ensemble des pages touchées par un programme —, on les tasse 64 par mot, et l’ISA a des instructions pour cela : les bt/bts du x86 testent et positionnent le bit n, popcnt compte les bits à 1 d’un mot, et tzcnt/lzcnt trouvent le premier ou le dernier. ARM64 et RISC-V ont des équivalents.
Un pointeur est un entier non signé aussi large qu’une adresse. Le matériel le traite comme n’importe quel entier jusqu’à ce qu’il serve à adresser la mémoire. Comme les CPU 64 bits n’utilisent que 48 à 57 bits d’adresse, les ISA récentes ont commencé à exploiter le reste : le top-byte ignore d’ARM64 permet au logiciel de ranger une étiquette dans les 8 bits de poids fort, et l’authentification de pointeurs y range une signature cryptographique, vérifiée avant l’usage du pointeur, ce qui déjoue beaucoup d’attaques par corruption de mémoire. Le x86 ajoute une fonction voisine, le linear address masking.
Qui prend en charge quoi
| x86-64 | ARM64 | RISC-V (RV64GC) | AVR ATmega168 | |
|---|---|---|---|---|
| entiers | 8, 16, 32, 64 | 8, 16, 32, 64 (arithmétique en 32 et 64, chargements/rangements de toutes tailles) | 8, 16, 32, 64 (arithmétique en 32 et 64) | 8 (plus des pointeurs de 16 bits) |
| DCB | mode 32 bits seulement ; DCB compact du x87 | — | — | — |
| virgule flottante | 16 (récent), 32, 64, 80 du x87 | 16, 32, 64 | 32, 64 (16 avec Zfh) | — |
| instructions de chaînes | oui | — | — | — |
La colonne ATmega168 vient du livre : un microcontrôleur dont le seul vrai type de données est l’octet. La colonne ARM s’écarte encore sur un point de l’OMAP4430 32 bits du livre : celui-ci affirme que tous ses opérandes doivent être alignés, mais les chargements et rangements ordinaires de l’ARM 32 bits acceptent les adresses mal alignées depuis ARMv6, comme l’ont montré sur ARM64 les mesures du chapitre précédent.
À retenir
- Un type a un support matériel quand des instructions supposent son format ; tout le reste est construit en logiciel.
- Les entiers sont en complément à deux : un seul additionneur pour le signé et le non signé. Les bits ne disent pas lequel ; c’est l’instruction qui le dit —
movsx/movzx,jl/jb,sar/shr,imul/mul, OF/CF. - Le DCB range des chiffres décimaux sur 4 bits. Le x86 a abandonné ses instructions d’ajustement décimal en mode 64 bits ; les machines IBM gardent une virgule flottante décimale en matériel.
- Virgule flottante IEEE 754 : signe, exposant biaisé, fraction avec un 1 caché ; valeurs spéciales ±0, dénormalisés, ±∞, NaN. 0,1 est toujours arrondi, et un
floatne peut pas contenir 16 777 217. - Le fp16 et le bfloat16 sont désormais en matériel pour l’apprentissage automatique ; le bfloat16 garde l’étendue d’un
floatavec bien moins de précision. - Unicode est un espace de 21 bits, pas de 16 ; l’UTF-8 le code sur 1 à 4 octets. Les CPU ne voient que des octets, avec quelques instructions de chaînes sur x86.
- Les pointeurs sont des entiers de la taille d’une adresse ; ARM64 range des étiquettes et des signatures dans leurs bits de poids fort inutilisés.