Поливалку я два месяца хвалил за вежливые русские логи: «Полив начат», «Бак пуст», «Клапан 2 закрыт». Потом подключил её к старому ноуту с Windows XP — захотелось журналу отдельный дом — и вместо слов увидел Привет. Полдня я подозревал плату: перепрошил, поднял и опустил скорость, передёрнул кабель. Виноват оказался не порт и не скетч: плата честно отправляла UTF-8, а терминал ноута ждал однобайтную CP1251 и показывал, как он понял эти байты. Никто не врал — стороны просто не договорились о словаре.
Разберём, почему русская буква в UTF-8 весит два байта и что это делает с length(), буферами и substring; сравним UTF-8, CP1251 и KOI8-R в одной таблице; напечатаем строку и её байты по отдельности; и отдельно возьмём граблю, на которой я разрезал слово пополам. В конце — диагностическое правило: кракозябры от кодировки выглядят упорядоченно, каша от скорости — хаотично, и различить их можно глазами за секунду.
Почему русская буква весит два байта
Механика такая. Английские буквы, цифры и знаки препинания в UTF-8 живут по одному байту и совпадают с ASCII. Русские буквы занимают два: первый — 208 или 209 (0xD0 и 0xD1), второй зависит от буквы. Слово «Привет» — шесть букв и двенадцать байтов: D0 9F, D1 80, D0 B8, D0 B2, D0 B5, D1 82. Главный практический вывод отсюда: length() и strlen() возвращают байты, а не буквы — строка из шести букв честно покажет двенадцать. Всё, что меряет и режет строки, — буферы, substring, лимиты в скетче — работает в байтах, и лимит «сто символов» для кириллицы на деле означает пятьдесят букв.
С char-массивами байтовая арифметика видна без монитора. Массив char slovo[] со строкой «ПРИВЕТ» занимает тринадцать байтов: двенадцать на буквы по два и один на нулевой терминатор, а strlen вернёт двенадцать — она считает до нуля, сам ноль не входит. Отсюда моя разметка буферов: char buf[32] под кириллицу — пятнадцать русских букв с запасом на ноль, а не тридцать два символа. Я один раз собирал журнал в буфере на шестьдесят байтов и терял хвост каждой длинной строки: байтов требовалось вдвое больше, чем букв, и слово «температуры» обрывалось на «темпер».
F-макрос от многобайтовости не спасает: строка во флэше лежит в той же UTF-8 раскладке, просто не съедает RAM — это собирали в уроке про макрос F(). Зато байтовая честность помогает диагностировать: печать по одному байту с HEX-постфиксом из урока про системы счисления показывает пары, начинающиеся с D0 или D1, — перед вами UTF-8. Таблица ниже сводит три кодировки, которые реально встречаются в железе и на компьютерах.
Serial.print шлёт байты как есть — кодировка рождается на двух концах. Arduino IDE и монитор второй версии говорят UTF-8 с обеих сторон; русские сборки Windows и куча старых терминалов ждут CP1251; почта девяностых и часть Unix-утилит — KOI8-R. Классическая сборка граблей: скетч в UTF-8 отправляет кириллицу, а приёмник читает по-своему — на экране Рџ. Лечение всегда на приёмнике: терминал с выбором кодировки, переключение на UTF-8 — и слова возвращаются без правки скетча.
| Кодировка | Байтов на русскую букву | Где встречается | «Привет» займёт |
|---|---|---|---|
| UTF-8 | 2 | Arduino IDE, монитор порта IDE 2, весь веб, современные ОС | 12 байтов |
| CP1251 (Windows-1251) | 1 | русские сборки Windows, старые терминалы | 6 байтов |
| KOI8-R | 1 | старые Unix-системы, почта девяностых | 6 байтов |
// Skolko vesit stroka: pechataem ee i kazhdyj bajt cherez Serial.write.// Na realnoj plate «PRIVET» = 6 bajtov, «ПРИВЕТ» = 12 bajtov.String lat = "PRIVET";String rus = "ПРИВЕТ"; void razbor(String imya, String stroka) { Serial.print(imya); Serial.print(": length="); Serial.print(stroka.length()); // bajty, ne bukvy! Serial.print(" -> "); for (unsigned int i = 0; i < stroka.length(); i++) { byte b = stroka.charAt(i); // charAt po bajtam if (b < 16) { Serial.print("0"); // vedushchij nol dlya HEX } Serial.print(b, HEX); Serial.print(" "); } Serial.println();} void setup() { Serial.begin(9600); razbor("LAT", lat); // LAT: length=6 -> 50 52 49 56 45 54 razbor("RUS", rus); // RUS: length=12 -> D0 9F D1 80 D0 B8 D0 B2 D0 B5 D1 82} void loop() {}Кто что ждёт: кодировки на приёмнике
Диагностическое правило, которое экономило мне вечера. Кракозябры от кодировки — упорядоченные: буква Р повторяется каждые два символа, цифры и знаки препинания стоят на местах, длина строки странная, но ритмичная. Это UTF-8, прочитанный однобайтным терминалом: первый байт пары показался одной буквой, второй — другой. Каша от несовпадения скоростей — хаос: мусор сыплется с самого начала, знаки теряются, длина строк пляшет; за это мы ругали линию в уроке про глючный UART. Одна секунда глазами — и понятно, куда копать: в кодировку приёмника или в скорость порта. Если сомнение всё же осталось, напомните себе базовое из первого разговора с монитором: скорость сверяется за две секунды в выпадающем списке.
Порядок проверки, который я закрепил после того вечера: сначала байты, потом буквы. Новую связку плата-приёмник я начинаю с HEX-дампа: печатаю строку по байтам с постфиксом HEX и глазами ищу лидеры D0 и D1. Лидеры на месте — передатчик честен, виноват словарь приёмника, и править нужно терминал, а не скетч. Лидеров нет вовсе — строка едет битой уже в проводе: скорость, земля, питание. Без дампа обе беды выглядят одинаково — на экране мусор, и половина вечера уходит на перепашку здорового кода.
Мнение про важные прошивки: если лог читают не только вы, пишите транслитом. POLIV NACHAT читается в любом терминале, в любой кодировке и без шрифтов — я перешёл на транслит во всём, что уезжает с платы наружу, а русский оставил в комментариях скетча. Для экранов это не выход: там кириллицу рисуют шрифтами и библиотеками — на OLED это делали через u8g2 в отдельном уроке, и там же встретится та же многобайтовая арифметика, только применительно к глифам.
Экраны — отдельный класс приёмников, и там словарь не выбирают. Символьный LCD на HD44780 читает однобайтную таблицу знакогенератора, прошитую в ROM: в русских партиях раскладка близка к CP1251, но совпадает с ней не вся, и lcd.print, получив пару байтов UTF-8, рисует два чужих знакоместа на каждую букву. У поливалки на балконе команды приходили из монитора IDE: в журнале русские слова читались, а на дисплее росла каша — сторона приёмника сменилась, а словарь остался портовым. Та же арифметика двух байтов, только знакоместа вместо букв; чем её лечат — перекодировщиками и своими глифами, разобрано в уроке про UTF-8 и кракозябры на I2C-LCD.
Мой рабочий порядок для дисплеев простой: строка, которую человек читает на экране, не едет туда из монитора без перекодировки. Либо транслит — POLIV NACHAT кладётся в однобайтную таблицу любого экрана, либо печать кодами lcd.write по карте собственного знакогенератора, либо библиотека-перекодировщик, превращающая UTF-8 в однобайтные коды на лету. Цена перекодировщика — десятки байтов RAM под таблицу и один проход по строке; для шкалы из двух строк это пустяк, для длинных журналов — уже аргумент в пользу транслита.
В симуляторе — честная оговорка о словаре: движок хранит строки как Unicode-коды, поэтому length() в нём покажет буквы, а не байты — шесть для «Привет». Настоящая плата ответит двенадцатью. Чтобы показать байтовую раскладку, сим-скетч держит коды букв и пары байтов в таблицах — ровно те, что раскладывает компилятор на настоящей плате: лидер 208 или 209 и второй байт. Цифры в журнале совпадают с настоящей платой байт в байт, а различие length() проговорено здесь и в brief, чтобы вы не унесли в жизнь шестёрку.
Попробуйте три команды: lat разберёт PRIVET по байту на букву, rus покажет «Привет» парами байтов, razrez N отрежет N байтов и честно скажет, остался ли хвост без пары. Наберите razrez 7 — семь байтов дадут три целые буквы и одинокий байт 208, который на экране превратится в квадрат. Светодиод на макетке мигает на каждом разборе: удобно, когда журнал ушёл далеко вниз.
- Кириллица в UTF-8 — два байта на букву: «Привет» весит двенадцать байтов, и length() на плате это подтвердит; буферы и substring считают байты
- Первый байт пары — 208 или 209: по нему UTF-8 узнаётся в потоке и по нему же ловится незакрытая буква
- CP1251 и KOI8-R однобайтные, но их ждут только старые терминалы: плата шлёт байты как есть, словарь выбирает приёмник
- Кракозябры различают по рисунку: Р-повторы каждые два символа — кодировка, сплошная каша с начала строки — скорость
- Чужим читателям — транслит: POLIV NACHAT выживает в любом терминале, кириллицу для экранов рисуют шрифтами отдельно
- 1. В мониторе каждые два символа повторяется буква Р, цифры стоят на местах. Чем это пахнет?
- 2. Сколько байтов займёт слово «Дом» в UTF-8?
- 3. Строку с кириллицей режут substring(0, 7), и граница попала посреди буквы. Что получит приёмник?
- 4. Старый терминал на CP1251 показывает Привет. Что ему отправили?
- 5. Что вернёт length() на настоящей плате для строки с шестью русскими буквами в UTF-8?
Частые вопросы по теме урока
- В мониторе каждые два символа повторяется буква Р, цифры стоят на местах. Чем это пахнет?
- Ритмичные Р-повторы — почерк UTF-8, прочитанного как CP1251. Хаотичная каша с начала строки — уже скорость. UTF-8 читают как однобайтную кодировку: второй байт пары печатается буквой
- Сколько байтов займёт слово «Дом» в UTF-8?
- Три русские буквы по два байта: шесть. length() на настоящей плате вернёт 6, хотя букв видно три. 6
- Строку с кириллицей режут substring(0, 7), и граница попала посреди буквы. Что получит приёмник?
- Резать можно только по границам: последний байт 208 или 209 означает незакрытую букву. Надёжнее резать по запятым и пробелам. Первый байт буквы без второго — на экране квадрат
- Старый терминал на CP1251 показывает Привет. Что ему отправили?
- «Привет» в UTF-8 — двенадцать байтов; однобайтный терминал показал их двенадцатью буквами. Лечение — переключить терминал на UTF-8. UTF-8: он показал пару байтов каждой буквы как две буквы
- Что вернёт length() на настоящей плате для строки с шестью русскими буквами в UTF-8?
- length() считает байты: шесть букв по два байта — двенадцать. Лимиты буферов и substring тоже работают в байтах. 12