Термостат на LCD дорос до четырёх экранов меню и однажды начал странно: после подключения дисплея и датчика скетч стал изредка подвисать, а печать строк — распадаться на куски. freeRam из урока про замер памяти показал сто восемьдесят свободных байт, и это ещё до резервов под стек. Виновники нашлись быстро: восемнадцать строк меню на русском лежали в RAM как обычные константы. Подвисания при этом выглядели как пара пропущенных ответов датчику в минуту — редкость, которую так и хочется списать на провод.
Арифметика у кириллицы тяжёлая: в UTF-8 буква занимает два байта, строка «Нагрев выключен» — тридцать байт с ноль-терминатором. Восемнадцать строк по пятнадцать-двадцать байт — около трёхсот, плюс тексты сообщений Serial — ещё полсотни. Итого пятая часть всей RAM Uno ушла на тексты, которые не меняются никогда: они зашиты в скетч и нужны только для печати.
Наивное лечение я тоже пробовал: удалить лишние строки и сократить формулировки. Через неделю половина экранов стала невнятной — «Рег. темп.» вместо «Регулировка температуры», а свободных байт вернулось семьдесят. Лотерея не окупилась: проблема не в длине строк, а в том, что они вообще живут в RAM, хотя предназначены только для чтения.
Между тем во флеше свободно тридцать две тысячи байт против двух тысяч RAM: строки можно держать там, где лежит сам код скетча, и доставать по мере надобности. Одиночные литералы закрывает F()-макрос — его механика разобрана в уроке про F() и PROGMEM. Здесь следующий шаг: целые таблицы строк и массивы во флеше — меню, справки, списки команд.
Двухшаговое чтение: указатель, потом строка
Таблица объявляется с двумя уровнями const: const char* const MENU[] PROGMEM. Первый const делает неизменяемыми сами строки, второй — не пускает в RAM массив указателей: без него Arduino-среда заботливо переправит массив в память, экономия исчезнет, а компилятор промолчит — я на этом потерял один вечер и двести байт. Проверить просто: avr-size по скетчу до и после объявления — блок на два байта на строку виден в секции данных сразу.
Чтение двухшаговое. pgm_read_word(&MENU[i]) достаёт из флеша адрес строки — первый шаг. Дальше по адресу работают функции с суффиксом _P: strcpy_P копирует строку в RAM-буфер, strlen_P меряет длину во флеше, strcmp_P сравнивает. Обычные strcpy и strlen на флеш-адресе вернут мусор: они не умеют команду чтения флеша LPM и честно прочитают RAM.
Числа из PROGMEM-таблиц читаются одним pgm_read_byte или pgm_read_word на месте — их механика уже была в уроке про PROGMEM-таблицы чисел. Со строками сложнее ровно на один шаг: сначала адрес, потом содержимое. Зато буфер нужен один, под самую длинную строку, а не под всю таблицу: у моего меню это двадцать шесть байт вместо трёхсот.
Заголовок avr/pgmspace.h обязателен: без него компилятор не знает ни PROGMEM, ни семейства _P-функций и честно сообщит, что pgm_read_word не объявлен. Для одиночных строк на месте удобен PSTR: strcpy_P(bufer, PSTR("gisteresis 0.5")) — строка уходит во флеш без объявления отдельной константы. В таблицах я PSTR не использую: адрес нужен по имени, и именованные константы STR0, STR1 читаются в объявлении таблицы сами за себя.
// Меню термостата: строки во флеше, в RAM - один буфер на печать.#include <avr/pgmspace.h> const char STR0[] PROGMEM = "T: 23.5 C NAGREV: VKL";const char STR1[] PROGMEM = "VLAZHNOST: 41 %";const char STR2[] PROGMEM = "NASTROJKI: gisteresis 0.5";const char STR3[] PROGMEM = "help: vvedite nomer 1-4"; const char* const MENU[] PROGMEM = { STR0, STR1, STR2, STR3 }; char bufer[26]; // самая длинная строка + ноль-терминатор void pecatStroku(byte i) { strcpy_P(bufer, (char*)pgm_read_word(&MENU[i])); // адрес, потом строка Serial.println(bufer);} void setup() { Serial.begin(9600); Serial.println(F("menyu termostata:")); for (byte i = 0; i < 4; i++) { Serial.print(i + 1); Serial.print(F(". ")); pecatStroku(i); }} void loop() { if (Serial.available()) { int nomer = Serial.parseInt(); // разбор числа из строки монитора if (nomer >= 1 && nomer <= 4) { pecatStroku(nomer - 1); } }}Буфер считайте заранее: длина равна strlen_P самой длинной строки плюс один байт под ноль-терминатор, и для кириллицы байты вдвое больше букв. «Проветривание» — двенадцать букв, двадцать четыре байта, плюс ноль: буфер в двадцать шесть держит мою самую длинную строку с запасом. Короткий буфер не упадёт, а молча обрежет строку — на экране это выглядит как «Проветрив» и ищется дольше, чем пишется.
Номер из монитора разбирается Serial.parseInt: тонкости чтения чисел из Serial уже разобраны в уроке про parseInt. Второй шаг — тот самый pgm_read_word — в симуляторе не живёт: движок флеш и PROGMEM не эмулирует, поэтому честная модель ниже показывает логику меню на обычном массиве строк. Смотрите не на хранение, а на поведение: печать меню циклом по таблице, выбор по номеру, одна функция печати на все строки — на плате она же ходит по указателям из флеша без единой правки.
Для команд консоли тот же шаг даёт сравнение без копирования: strcmp_P(cmd, PSTR("status")) == 0 — команда, пришедшая в RAM, сверяется со строкой во флеше на месте. Механику чтения Serial я уже разбирал в уроке про available и read. Комбинация «буфер под пришедшую команду плюс strcmp_P против таблицы во флеше» съедает ноль байт RAM на сами команды — на тридцати командах экономия видна даже на глаз.
Мусор от println и граница F()
Ловушка, на которую я наступил первым же скетчем: Serial.println(MENU[i]) по указателю из таблицы печатает мусор. println не знает про флеш: он читает байты по адресу как из RAM, а там лежит всё, что угодно, только не ваша строка. Компилятор молчит — типы совпадают, указатель есть указатель, и «повезёт», если мусор хоть что-то напомнит о строке.
Лечения два, оба честные. Универсальное — strcpy_P в буфер и печать буфера: работает в любом месте скетча, но требует объявить буфер. Однострочное для Serial — каст к __FlashStringHelper*: println увидит тип «строка из флеша» и прочитает её правильной командой. Я печатаю через буфер: в буфере строку можно не только напечатать, но и сравнить, поискать подстроку или отправить на дисплей — для меню это каждый второй случай.
Кодировка при переезде не меняется: во флеше строка лежит теми же байтами UTF-8, что и в RAM, и экран со шрифтом покажет её одинаково. Проверка одна: первые байты кириллической строки в дампе — 0xD0 и 0xD1; видите их в буфере после strcpy_P — копирование прошло честно. На этом же строится поиск мусора: буфер после strcpy_P всегда кончается нулём — нет нуля, значит, длина буфера и строки разошлись.
С дисплеем буфер работает тем же способом: lcd.print(bufer) после strcpy_P — и строка на экран пришла из флеша. У меня на термостате одна и та же функция pecatStroku печатает и в Serial, и на LCD: буфер общий, источники разные, при замене монитора правится одна строка. Копеечная деталь с большим следом: strcpy_P не знает границ буфера — строка длиннее ожидания затрёт соседнюю переменную, поэтому длина буфера берётся с запасом от самой длинной строки таблицы.
Цена вопроса по фактам: восемнадцать строк переехали во флеш и вернули в RAM около трёхсот байт — freeRam вырос со ста восьмидесяти до четырёхсот восьмидесяти свободных. Карта памятей и то, где что лежит, разобраны в уроке про PROGMEM и память. Перенос таблицы строк — самая дешёвая экономия RAM из известных мне: полчаса работы, ноль изменений в логике.
Замер до и после переноса я делаю всегда одним скетчем: печатаю freeRam в setup до и после первого обращения к таблице. Цифры сошлись с арифметикой один в один — минус триста байт занятости, и ни один байт не «пропал». Если после переноса freeRam не вырос, ищите пропавший второй const в объявлении массива указателей или копию таблицы, которую кто-то заботливо завёл ещё и в EEPROM.
Когда хватит F(): если строк до пяти и каждая печатается по месту, размазывайте F() по коду и не создавайте таблицу. Таблица оправдана, когда строки перебирает цикл — меню, справка, список команд, — или когда их больше пяти и список растёт. Диспетчеру команд из урока про таблицу команд имена так же достаются strcpy_P — две техники склеиваются без швов. Моя граница простая: цикл по строкам или пятая строка — повод собирать всё в PROGMEM-массив.
| Где живёт строка | RAM на 18 строк меню | Как печатать |
|---|---|---|
| Обычные литералы и массивы | около 300 байт навсегда | println как обычно |
| F() на каждом вызове | ноль, но повтор строки в коде под каждый вызов | println(F("...")) |
| PROGMEM-массив указателей | ноль под строки, два байта на указатель | strcpy_P в буфер или каст к __FlashStringHelper* |
| String из кучи | десятки байт на строку плюс служебные расходы | println(s) — как обычно |
- 1. Сколько байт RAM занимает кириллическая строка из 15 букв в UTF-8?
- 2. Что достаёт pgm_read_word(&MENU[i])?
- 3. Зачем второй const в const char* const MENU[] PROGMEM?
- 4. Как правильно напечатать строку из PROGMEM через Serial?
- 5. Когда достаточно F()-макроса без PROGMEM-таблицы?
- Два килобайта RAM против тридцати двух во флеше: строки меню, справок и команд не должны жить в RAM
- const char* const MENU[] PROGMEM: второй const держит во флеше сам массив указателей, иначе экономия уходит молча
- Чтение двухшаговое: pgm_read_word достаёт адрес, strcpy_P, strlen_P и strcmp_P работают со строкой — обычные функции читают мусор
- println по PROGMEM-указателю печатает мусор: копируйте в буфер или кастуйте к __FlashStringHelper*
- До пяти строк хватает F(); таблица оправдана, когда строки перебирает цикл или список растёт
Частые вопросы по теме урока
- Сколько байт RAM занимает кириллическая строка из 15 букв в UTF-8?
- Кириллица в UTF-8 занимает два байта на букву: 15 букв — 30 байт, плюс ноль-терминатор — 31. Именно поэтому кириллические меню съедают RAM вдвое быстрее латинских. 31 байт
- Что достаёт pgm_read_word(&MENU[i])?
- Это первый шаг: из флеша читается адрес строки. Сама строка достаётся вторым шагом — strcpy_P, strlen_P или strcmp_P по этому адресу. адрес строки во флеше
- Зачем второй const в const char* const MENU[] PROGMEM?
- Без второго const среда копирует массив указателей в RAM: строки лежат во флеше, а двести с лишним байт указателей — в памяти. Компилятор об этом не предупреждает. без него массив указателей уедет в RAM
- Как правильно напечатать строку из PROGMEM через Serial?
- println по флеш-указателю читает RAM и печатает мусор. Скопируйте строку в буфер через strcpy_P или отдайте println тип «строка из флеша» кастом. strcpy_P в буфер или каст к __FlashStringHelper*
- Когда достаточно F()-макроса без PROGMEM-таблицы?
- F() закрывает одиночные литералы. Как только строки перебирает цикл — меню, справка, список команд, — или их больше пяти, собирайте таблицу в PROGMEM. когда строк до пяти и каждая печатается по месту