Лаборатория кодера

Справочник · Данные и форматы

Кодировки и UTF-8: почему текст превращается в кракозябры

Чем символ отличается от байта, как устроен UTF-8, откуда берутся «Ð¿Ñ€Ð¸Ð²ÐµÑ‚» и знаки вопроса и как перекодировать файлы.

Обновлено · 6 мин чтения

Компьютер хранит байты, а не буквы. Кодировка — это правило, по которому байты превращаются в символы и обратно. Если записали текст в одной кодировке, а прочитали в другой, получаются кракозябры. Сегодня почти везде используется UTF-8, но старые файлы, Windows-программы и Excel до сих пор подкидывают сюрпризы.

Символы, кодовые точки и байты

Юникод — это таблица, где каждому символу присвоен номер, кодовая точка. Записывается как U+ и шестнадцатеричное число: латинская A — U+0041, русская Я — U+042F, знак евро — U+20AC, эмодзи с улыбкой — U+1F600. Всего точек больше миллиона, от U+0000 до U+10FFFF.

Как номер превратить в байты, решает кодировка:

КодировкаСколько байт на символГде встречается
ASCII1, только 128 символовоснова всех остальных
Windows-1251 (cp1251)1, кириллица и латиницастарые файлы Windows, 1С, Excel
KOI8-R1старые письма и Unix-системы
CP8661консоль Windows, DOS
UTF-8от 1 до 4веб, Linux, macOS, git, почти всё новое
UTF-162 или 4внутри Windows, Java, JavaScript, .NET
UTF-32всегда 4редко, во внутренних структурах

Как устроен UTF-8

Чем больше номер символа, тем больше байт. По первым битам байта сразу видно, сколько байт в символе.

Диапазон кодовых точекБайты в двоичном видеБайт
U+0000–U+007F0xxxxxxx1
U+0080–U+07FF110xxxxx 10xxxxxx2
U+0800–U+FFFF1110xxxx 10xxxxxx 10xxxxxx3
U+10000–U+10FFFF11110xxx 10xxxxxx 10xxxxxx 10xxxxxx4

Пример: буква Я — это U+042F, двоичное 100 0010 1111. Она попадает во второй диапазон. Раскладываем 11 бит по шаблону 110xxxxx 10xxxxxx: получаются байты 11010000 10101111, то есть D0 AF.

Что важно знать:

  • ASCII-текст в UTF-8 не меняется ни на байт. Поэтому код, конфиги и протоколы спокойно переходят на UTF-8.
  • Кириллица занимает 2 байта, большинство иероглифов — 3, эмодзи — 4.
  • Продолжающие байты всегда начинаются с 10, поэтому по любому месту в потоке можно найти начало символа.
  • Не всякая последовательность байт — правильный UTF-8. Например, одиночный 0xD0 в конце файла — ошибка.
bash
printf 'Я' | xxd          # 00000000: d0af
printf 'Я' | wc -c        # 2 байта
printf 'Я' | wc -m        # 1 символ (если локаль UTF-8)

Длина строки — понятие неоднозначное

Строка "ёж👍" в разных языках имеет разную «длину»:

Где считаемРезультатЧто считается
байты в UTF-882 + 2 + 4
JavaScript .length4единицы UTF-16: эмодзи занимает две
Python len()3кодовые точки
то, что видит человек3графемы

А буква ё может быть записана и одной кодовой точкой U+0451, и двумя: е плюс комбинируемая диакритика U+0308. На экране разницы нет, но строки не равны, и поиск по ним не совпадёт. Это решает нормализация: форма NFC собирает символы в составные, NFD раскладывает.

text
"ё".normalize("NFD").length               → 2
[..."ёж👍"].length                         → 3, перебор по кодовым точкам
new TextEncoder().encode("ёж👍").length    → 8 байт
python
import unicodedata
unicodedata.normalize("NFC", s) == unicodedata.normalize("NFC", t)

Узнаём кракозябры по виду

По характерному мусору часто сразу понятно, что произошло.

Что видите вместо «привет»Что случилось
приветUTF-8 прочитали как Latin-1 или Windows-1252
приветUTF-8 прочитали как Windows-1251
ïðèâåòWindows-1251 прочитали как Latin-1
������ или привет с ромбикамибайты не являются правильным UTF-8, программа заменила их на U+FFFD
??????при перекодировании символов не нашлось в целевой кодировке, они потеряны

Первые три случая обратимы: байты целы, их просто неправильно прочитали. Последние два — нет, исходные данные уже испорчены.

BOM

Метка порядка байтов (BOM) — символ U+FEFF в начале файла. В UTF-8 это байты EF BB BF. Для UTF-8 она не нужна, но Блокнот Windows и Excel любят её ставить и ожидать.

  • Скрипт с BOM в начале не запустится: строка #!/bin/bash перестаёт быть первой.
  • JSON с BOM многие парсеры отвергают.
  • Excel, наоборот, без BOM откроет CSV в UTF-8 как Windows-1251. Подробнее — в статье о CSV.
bash
head -c 3 file.txt | xxd          # ef bb bf — значит BOM есть
sed -i '1s/^\xEF\xBB\xBF\(.*\)/\1/' file.txt   # убрать BOM

Перекодирование файлов

bash
file -i report.txt                         # угадать кодировку
iconv -f WINDOWS-1251 -t UTF-8 old.txt > new.txt
iconv -c -f UTF-8 -t WINDOWS-1251 in.txt > out.txt   # -c: непереводимые символы выбросить
iconv -l | grep -i 1251                    # как называется кодировка

В Python кодировку всегда стоит указывать явно, иначе на Windows подставится системная:

python
with open("data.txt", encoding="utf-8") as f:
    text = f.read()

raw.decode("cp1251")                 # байты из Windows-1251
text.encode("utf-8", errors="strict")
"привет".encode("cp1252").decode("utf-8")   # починка первого вида кракозябр

Кодировка в вебе и базе данных

  • HTML: <meta charset="utf-8"> в первых 1024 байтах страницы, а лучше ещё и заголовок Content-Type: text/html; charset=utf-8.
  • В адресах не-ASCII символы кодируются как байты UTF-8 в виде %D0%AF. Посмотреть и перевести можно в кодировщике URL.
  • MySQL: используйте utf8mb4. Старая utf8 в MySQL хранит максимум 3 байта на символ и не принимает эмодзи.
  • PostgreSQL: кодировка задаётся при создании базы, ENCODING 'UTF8'.
  • Base64 работает с байтами, поэтому перед кодированием текста его всё равно переводят в UTF-8. Наш инструмент Base64 делает это сам.

Короткие правила

  1. Везде UTF-8 без BOM, если нет причины иначе.
  2. Указывайте кодировку явно при чтении и записи файлов.
  3. Считайте длину в тех единицах, которые важны: байты для лимитов хранения, графемы для интерфейса.
  4. Сравнивайте пользовательские строки после нормализации NFC.
  5. Встретили кракозябры — сначала сохраните исходные байты, потом экспериментируйте.