Encodages de caractères, de l'ASCII à UTF-8

15 mai 2026

encodageunicodeutf-8

Un é qui devient é dans un terminal, un fichier "Latin-1" qui contient en fait du Windows-1252, un BOM caché en tête d'un CSV qui fait planter un script. Tout ça vient du même endroit : ranger des lettres dans des octets est un problème simple qui s'est compliqué dès qu'on a voulu sortir de l'anglais. ASCII a tenu tant qu'il a pu, Latin-1 et Windows-1252 ont tenté de pousser les murs, UTF-16 a essayé des unités 16 bits avant de devoir les étendre avec des surrogate pairs, et UTF-8 a fini par résoudre les problèmes que les autres avaient laissés derrière. Voici la suite, avec les détails qui servent au moment où on tombe dessus.

Sommaire

1. Encodages 8 bits

ASCII

  • 7 bits, 128 caractères
  • 0x000x1F : caractères de contrôle (NUL, LF, CR, TAB, BEL...)
  • 0x200x7E : imprimable (espace inclus)
  • 0x7F : DEL
  • Base universelle : tout encodage 8 bits commence par étendre ASCII

Latin-1 (ISO 8859-1)

  • 8 bits, 256 caractères = ASCII + 128
  • 0x000x7F : identique ASCII
  • 0x800x9F : C1 controls (vides en pratique)
  • 0xA00xFF : caractères imprimables Europe occidentale (é, à, ç, ñ, ü...)

Windows-1252 (CP1252)

  • Identique à Latin-1 sauf 0x800x9F qui contient des caractères utiles ( en 0x80, guillemets typographiques, , , , ...)
  • 0xA00xFF : strictement identique à Latin-1
  • C'est la réalité de la plupart des fichiers texte créés sous Windows, même quand le header dit "Latin-1"

2. Détecter Latin-1 vs Windows-1252

Pour trancher sur un buffer 8 bits :

  • Tout octet < 0x80 → c'est de l'ASCII pur, les deux encodages s'accordent
  • Octets dans 0xA00xFF seulement → indistinguable, mais peu importe : Latin-1 et Windows-1252 donnent le même rendu sur cette plage (on peut traiter comme Latin-1)
  • Au moins un octet dans 0x800x9F → forcément Windows-1252, Latin-1 n'utilise pas cette plage

Détection peu fiable sur les petits buffers : il est facile de passer à côté de la plage 0x800x9F et de se tromper.

3. UTF-16 et UTF-32

256 caractères ne suffisent évidemment pas pour couvrir toutes les écritures du monde. Unicode a d'abord essayé de tout caser dans une unité fixe plus large que l'octet : 16 bits, puis 32.

UTF-16

  • Longueur variable : 2 ou 4 octets par caractère
  • U+0000U+FFFF (BMP, Basic Multilingual Plane) : 2 octets directs
  • U+10000U+10FFFF : 4 octets via surrogate pair
  • Endianness obligatoire → BOM nécessaire

UCS-2 vs UTF-16

UCS-2 (1991), l'ancêtre de UTF-16, était à largeur fixe : 2 octets par caractère, point final. Mais le BMP - les 65 536 premiers code points Unicode - s'est révélé insuffisant pour couvrir toutes les écritures. UTF-16 (1996) a hérité de UCS-2 et a introduit les surrogate pairs pour étendre la couverture sans casser la rétrocompatibilité avec les implémentations UCS-2 existantes.

La zone surrogate (U+D800–U+DFFF)

Les 2048 code points de cette plage sont définitivement interdits comme caractères Unicode - pas juste réservés pour la mécanique UTF-16, mais inutilisables dans n'importe quel encodage. Un fichier qui contient un caractère seul dans cette plage est par définition malformé.

Algorithme surrogate pair

Pour un code point hors-BMP (U+10000U+10FFFF), on construit deux unités 16 bits :

offset = code_point - 0x10000
high   = 0xD800 + (offset >> 10)
low    = 0xDC00 + (offset & 0x3FF)

Pourquoi cette construction :

  • Soustraction de 0x10000 ramène l'offset à zéro. La plage hors-BMP va de U+10000 à U+10FFFF, soit 0x100000 valeurs = exactement 20 bits d'information.
  • 20 bits = 10 + 10 : on découpe en deux unités 16 bits, chacune avec un préfixe fixe de 6 bits (0xD800 pour le high, 0xDC00 pour le low), laissant 10 bits utiles par unité.
  • High surrogate (U+D800U+DBFF) : 1024 valeurs (10 bits hauts du code point).
  • Low surrogate (U+DC00U+DFFF) : 1024 valeurs (10 bits bas du code point).
  • 1024 × 1024 = 1 048 576 combinaisons → couvre exactement la plage hors-BMP.

Exemple : 😀 (U+1F600)

Code point : U+1F600 = 128512
Plage      : hors-BMP → surrogate pair nécessaire

Offset :
  offset = 0x1F600 - 0x10000 = 0xF600 = 62976

Découpage en 20 bits (10 hauts + 10 bas) :
  0xF600 sur 20 bits : 0000 1111 0110 0000 0000
  Découpage          : 0000 1111 01 | 10 0000 0000
                     = 0x03D         | 0x200

Calcul des deux unités 16 bits :
  high = 0xD800 + 0x03D = 0xD83D
  low  = 0xDC00 + 0x200 = 0xDE00

Résultat (octets, big-endian) : 0xD8 0x3D 0xDE 0x00

Pièges pratiques

  • "😀".length en JS et String.length() en Java/Kotlin retournent 2, pas 1 - la longueur compte les unités 16 bits, pas les code points. Pour avoir le nombre de code points : [...s].length en JS, s.codePointCount(0, s.length) en Java.
  • Un surrogate isolé (high sans low qui suit, ou low sans high qui précède) est du texte malformé : la paire n'a de sens qu'ensemble.
  • La JVM accepte des surrogates isolés en mémoire (les String Java sont des séquences d'unités 16 bits, pas de vraies chaînes Unicode). Mais convertir une telle String en UTF-8 (par exemple s.getBytes("UTF-8")) produit des octets invalides ou throw selon l'implémentation - source classique de bugs sur les pipelines I/O.

Pourquoi UTF-16 existe encore

UTF-8 (1992, Ken Thompson et Rob Pike) précède UTF-16 avec surrogates (~1996) de 4 ans. UTF-16 survit aujourd'hui par lock-in : Windows NT (1993) et Java (1995) avaient déjà parié sur UCS-2 avant que le BMP s'avère insuffisant. Quand Unicode a dépassé U+FFFF en 1996, ces plateformes ne pouvaient pas casser leur ABI - d'où le retrofit des surrogate pairs sur UCS-2 pour produire UTF-16. Sans cet héritage, UTF-8 serait probablement la norme partout.

UTF-32

  • Toujours 4 octets par caractère, longueur fixe
  • Simple à indexer, mais verbeux ; rare sur disque, courant en mémoire pour du traitement
  • Endianness obligatoire → BOM nécessaire

4. Endianness

Ordre de stockage des octets pour les unités multi-octets (UTF-16, UTF-32).

Exemple avec U+4E2D (中) en UTF-16 :

  • Big-endian : 4E 2D (octet de poids fort en premier - most significant byte first)
  • Little-endian : 2D 4E (octet de poids faible en premier - least significant byte first)

Contextes :

  • Big-endian : réseau TCP/IP (network byte order), PowerPC, mainframes IBM
  • Little-endian : x86, x64, ARM (mode courant)
  • Bi-endian : ARM (configurable), MIPS, PowerPC

5. BOM (Byte Order Mark) - U+FEFF

Le BOM est le caractère U+FEFF placé en tête de fichier. Sa représentation en octets indique l'encodage et l'endianness.

EncodageBOMNotes
UTF-8EF BB BFOptionnel, pas d'endianness. Créé par Windows/Notepad. À stripper en lecture.
UTF-16 big-endianFE FFObligatoire
UTF-16 little-endianFF FEObligatoire
UTF-32 big-endian00 00 FE FFObligatoire
UTF-32 little-endianFF FE 00 00Obligatoire

Piège classique : FF FE ressemble à un BOM UTF-16 LE, mais si les deux octets suivants sont 00 00, c'est en fait un BOM UTF-32 LE. Lire 4 octets avant de trancher.

6. UTF-8

UTF-8 contourne tous les inconvénients ci-dessus : plus de gaspillage de place pour les caractères ASCII, plus d'endianness, plus de BOM obligatoire.

  • Unité de base : 8 bits (1 octet)
  • Longueur variable : 1 à 4 octets par caractère
  • Le chiffre "8" désigne la taille de l'unité de base, pas la taille max d'un caractère
  • Rétrocompatible ASCII : tout fichier ASCII est un UTF-8 valide
  • Pas de problème d'endianness puisque l'unité fait 1 octet

Patterns des octets

Notation : les 0 et 1 sont les bits fixes des marqueurs (ce qui identifie le format) ; les x sont les bits de données du code point qui seront placés à ces positions.

0xxxxxxx                              → 1 octet  (U+0000–007F, ASCII)
110xxxxx 10xxxxxx                     → 2 octets (U+0080–07FF)
1110xxxx 10xxxxxx 10xxxxxx            → 3 octets (U+0800–FFFF)
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx   → 4 octets (U+10000–10FFFF)
10xxxxxx                              → octet de continuation
                                        (jamais un premier octet)

Manipulation de bits - rappels

L'encodage et le décodage UTF-8 reposent sur deux opérations bit à bit : le masquage avec AND (&) pour isoler certains bits d'un octet, et le décalage (>> à droite pour l'encodage, << à gauche pour le décodage) pour les repositionner.

Masque avec AND (&)

Un masque est une valeur qu'on combine avec AND pour isoler des bits. Règles : 1 AND x = x, 0 AND x = 0. Les bits à 1 du masque "laissent passer", les bits à 0 "effacent".

Exemple - isoler les 6 bits bas de 0xE9 avec le masque 0x3F :

  1110 1001   (0xE9)
& 0011 1111   (0x3F = masque des 6 bits bas)
-----------
  0010 1001   → 6 bits bas isolés

Le masque 0x3F revient partout en UTF-8 parce que chaque octet de continuation porte exactement 6 bits.

Décalage à droite (>>)

x >> n pousse tous les bits n positions vers la droite. Les bits bas tombent, les bits hauts descendent à leur place.

Exemple - récupérer ce qui reste au-dessus des 6 bits bas de 0xE9 :

0xE9       = 1110 1001
0xE9 >> 6  = 0000 0011   → les 2 bits hauts restants

En UTF-8, après avoir extrait les 6 bits bas pour l'octet de continuation, >> 6 permet de récupérer les bits au-dessus pour les placer dans l'octet de tête.

Décalage à gauche (<<)

x << n pousse tous les bits n positions vers la gauche. Les bits hauts sortent (au-delà de la largeur du type), les bits bas remontent.

Exemple - remettre 0x03 à sa place six bits plus haut :

0x03       = 0000 0011
0x03 << 6  = 1100 0000   → bits repositionnés

En UTF-8, c'est l'opération inverse de >> : au décodage, on l'utilise pour remettre les bits d'un octet de tête en haut avant de les fusionner avec ceux d'un octet de continuation.

En combinant les deux

Pour construire les deux octets UTF-8 de é (U+00E9) en C :

uint8_t c = 0xE9;

uint8_t byte1 = 0xC0 | (c >> 6);     // marqueur 110 + bits hauts
uint8_t byte2 = 0x80 | (c & 0x3F);   // marqueur 10  + 6 bits bas
  • c & 0x3F isole les 6 bits bas
  • c >> 6 isole les bits au-dessus
  • | 0xC0 colle le marqueur 110 devant l'octet de tête
  • | 0x80 colle le marqueur 10 devant l'octet de continuation

C'est la traduction directe du découpage "bits hauts / 6 bits bas" en code.

Les masques utiles pour décoder

Détection du format (sur l'octet de tête) :

  • 0xC0 = marqueur 11000000 (début séquence 2 octets)
  • 0xE0 = marqueur 11100000 (début séquence 3 octets)
  • 0xF0 = marqueur 11110000 (début séquence 4 octets)
  • 0x80 = marqueur 10000000 (octet de continuation)

Extraction des bits de données :

  • 0x1F = masque des 5 bits bas (00011111) - bits utiles d'un octet de tête sur 2 octets
  • 0x0F = masque des 4 bits bas (00001111) - bits utiles d'un octet de tête sur 3 octets
  • 0x07 = masque des 3 bits bas (00000111) - bits utiles d'un octet de tête sur 4 octets
  • 0x3F = masque des 6 bits bas (00111111) - bits utiles d'un octet de continuation

Exemple de décodage

Pour décoder la séquence UTF-8 0xC3 0xA9 :

Octets reçus : 0xC3 0xA9

Étape 1 - identifier le format depuis l'octet de tête
  0xC3 = 1100 0011
  Préfixe `110` → séquence sur 2 octets

Étape 2 - extraire les bits utiles de chaque octet
  Octet de tête         : 0xC3 & 0x1F = 0000 0011   (5 bits)
  Octet de continuation : 0xA9 & 0x3F = 0010 1001   (6 bits)

Étape 3 - recoller en décalant les bits de tête de 6 places
  (0x03 << 6) | 0x29 = 1100 0000 | 0010 1001 = 1110 1001 = 0x00E9

Résultat : U+00E9 = é

En C :

uint8_t byte1 = 0xC3;
uint8_t byte2 = 0xA9;
uint32_t code_point = ((byte1 & 0x1F) << 6) | (byte2 & 0x3F);   // 0xE9

Pour 3 octets, l'octet de tête utilise 0x0F (4 bits utiles) et les décalages deviennent << 12, << 6. Pour 4 octets, 0x07 (3 bits utiles) avec << 18, << 12, << 6. Le principe est toujours le même : masquer chaque octet pour récupérer ses bits de données, puis les positionner avec << avant de les fusionner avec |.

Encoder un code point en UTF-8

Pour encoder un code point Unicode en octets UTF-8, il faut choisir le format en fonction de la valeur du code point, puis répartir ses bits dans les emplacements x du format.

Plage du code pointBits significatifsFormat UTF-8Nombre d'octets
U+0000U+007F7 bits0xxxxxxx1
U+0080U+07FF11 bits110xxxxx 10xxxxxx2
U+0800U+FFFF16 bits1110xxxx 10xxxxxx 10xxxxxx3
U+10000U+10FFFF21 bits11110xxx 10xxxxxx 10xxxxxx 10xxxxxx4

Chaque octet de continuation porte toujours 6 bits ; seul l'octet de tête varie.

Algorithme général :

  1. Trouver la plage du code point → en déduire le nombre d'octets et le format
  2. Écrire le code point en binaire sur le nombre de bits significatifs requis (compléter à gauche avec des zéros)
  3. Découper en morceaux et remplir les x du format (6 bits par octet de continuation)
  4. Coller les marqueurs (110, 1110, 11110, 10) devant chaque morceau
  5. Convertir chaque octet binaire en hexadécimal (1 chiffre hexa par groupe de 4 bits)

Exemples d'encodage

1 octet : A (U+0041)

Code point : U+0041 = 65
Plage      : U+0000–U+007F → 1 octet, format 0xxxxxxx

Binaire sur 7 bits : 1000001

Remplissage du format :

  Format :  0xxxxxxx
  Bits   :   1000001
  Octet  :  01000001

Conversion en hexa (1 chiffre hexa = 4 bits) :

  01000001 = 0100 0001 = 0x41

Résultat : 0x41

ASCII et UTF-8 sont identiques sur cette plage - c'est la rétrocompatibilité.

2 octets : é (U+00E9)

Code point : U+00E9 = 233
Plage      : U+0080–U+07FF → 2 octets, format 110xxxxx 10xxxxxx

Binaire sur 11 bits : 000 1110 1001
Découpage           : 00011 | 101001  (5 bits hauts, 6 bits bas)

Remplissage du format (les `110`/`10` viennent du format, les bits remplissent les `x`) :

  Format :  110xxxxx   10xxxxxx
  Bits   :     00011     101001
  Octets :  11000011   10101001

Conversion en hexa :

  11000011 = 1100 0011 = 0xC3
  10101001 = 1010 1001 = 0xA9

Résultat : 0xC3 0xA9

En Latin-1, é = 0xE9 (1 octet). Quand un é apparaît en sortie, c'est du UTF-8 (0xC3 0xA9) lu comme si c'était du Latin-1 : 0xC3Ã, 0xA9©.

3 octets : (U+4E2D)

Code point : U+4E2D = 20013
Plage      : U+0800–U+FFFF → 3 octets, format 1110xxxx 10xxxxxx 10xxxxxx

Binaire sur 16 bits : 0100 1110 0010 1101
Découpage           : 0100 | 111000 | 101101  (4 bits, 6 bits, 6 bits)

Remplissage du format :

  Format :  1110xxxx   10xxxxxx   10xxxxxx
  Bits   :      0100     111000     101101
  Octets :  11100100   10111000   10101101

Conversion en hexa :

  11100100 = 1110 0100 = 0xE4
  10111000 = 1011 1000 = 0xB8
  10101101 = 1010 1101 = 0xAD

Résultat : 0xE4 0xB8 0xAD

Tous les caractères CJK (chinois, japonais, coréen) communs sont sur 3 octets.

4 octets : 😀 (U+1F600)

Code point : U+1F600 = 128512
Plage      : U+10000–U+10FFFF → 4 octets, format 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

Binaire sur 21 bits : 0 0001 1111 0110 0000 0000
Découpage           : 000 | 011111 | 011000 | 000000  (3 bits, 6 bits, 6 bits, 6 bits)

Remplissage du format :

  Format :  11110xxx   10xxxxxx   10xxxxxx   10xxxxxx
  Bits   :       000     011111     011000     000000
  Octets :  11110000   10011111   10011000   10000000

Conversion en hexa :

  11110000 = 1111 0000 = 0xF0
  10011111 = 1001 1111 = 0x9F
  10011000 = 1001 1000 = 0x98
  10000000 = 1000 0000 = 0x80

Résultat : 0xF0 0x9F 0x98 0x80

Tous les emojis sont sur 4 octets - c'est aussi le cas des plans Unicode supplémentaires (écritures historiques comme le cunéiforme, symboles mathématiques, etc.).

Ressources

Tables de caractères

Lectures

← retour au blog