Журнальник в теплице я собирал по всем правилам первых серий: раз в минуту плата печатает в порт строку состояния — температура, влажность, форточка, — а при событиях добавляет пояснение человеческим языком: «Svet: vklyuchen po tajmeru». Печатать в порт мы научились ещё в первом уроке про монитор. Первую неделю всё было идеально. Я подкармливал устройство — добавлял датчик, правил формулировки, — десятки дозагрузок за две недели. А потом начались странности: плата изредка зависала намертво, иногда печатала кашу из символов, а однажды сама перезагрузилась посреди отчёта. Компилятор молчал: ни одной ошибки, флеша — 32 КБ — занята меньше чем наполовину.
Разгадка была в байтах, которых я не считал. Оперативной памяти — SRAM — у Uno 2048 байта, и каждый литерал в кавычках компилятор копирует туда при старте скетча. Мои длинные строки-пояснения, даже те, что печатаются раз в час, лежали в оперативке мёртвым грузом — ещё до первой строчки setup(). Десяток строк по 50–60 байт — около шестисот байт, почти треть всей SRAM. Дальше классика: стек растёт навстречу данным, они сталкиваются — и начинаются зависания и ресеты, которых не повторить по заказу.
Лечится это одной буквой: F(). Макрос из ядра Arduino оставляет строку во флеше — там, где и так лежит код, — и отдаёт её Serial.print прямо оттуда, не заводя копию в оперативке. Разберём, почему строки попадают в SRAM без нашего ведома, что делает F() и какой ценой, замерим выигрыш и пройдёмся по граблям. Практика — в симуляторе: журнальник печатает одни и те же сообщения двумя способами; фундамент под такие журналы закладывает урок про массивы и журнал показаний.
Каждая строка в кавычках живёт в SRAM
Память у AVR устроена по гарвардской схеме: флеш и SRAM — два отдельных склада с отдельными шинами, читаются разными командами процессора. Флеш на Uno — 32 КБ, в нём живёт программа; SRAM — 2048 байта: переменные, стек и буферы порта. По-человечески строка в кавычках — константа: не меняется, значит, место ей во флеше. Но у AVR по умолчанию хитрее: компилятор кладёт литерал во флеш как часть образа программы, однако стартовый код ядра перед setup() копирует его в SRAM — обычные функции C читают данные только из SRAM, и ядро подтаскивает инициализированные данные поближе к исполнению.
Сколько это весит — считаем с оговорками: цифра зависит от версии ядра и того, что вы подключили. Пустой скетч с Serial.begin(9600) занимал у меня около 200–220 байт SRAM: буфер приёма на 64 байта, передающий ящик, служебные структуры — все они жильцы того же котла, о котором шла речь в уроке про буфер UART. Дальше ваши строки: каждые десять слов текста — примерно 30–60 байт, в зависимости от длины слов. А если текст кириллицей — умножайте на два: в UTF-8 русская буква весит два байта, строка из сорока русских букв — около 80. Отсюда моя привычка печатать транслитом: и мониторы не спорят с кодировкой, и байт вдвое меньше.
Самое коварное — что всё это происходит тихо. Скетч компилируется, флеша хватает с запасом, IDE не ругается — а в оперативке тает запас. Единственный честный сигнал — строчка после сборки: Global variables use ... bytes, а при нехватке — Low memory available, stability problems may occur. У меня она была в каждом логе, и я её игнорировал: «заливается же». Заливается — до первого странного зависания.
Что делает F(): строка остаётся во флеше
F() — макрос из ядра Arduino, и делает он сразу две вещи. Первая: помечает литерал секцией PROGMEM — строка остаётся во флеше, и копия в SRAM не заводится. Вторая: наружу отдаётся указатель особого типа __FlashStringHelper* — договорённость «это строка во флеше, читать только побайтно, по-обычному нельзя». Никакой магии: одна строчка в заголовках ядра превращает F("текст") в указатель на флеш-байты.
Дальше работает полиморфизм класса Print — того самого, от которого наследуется Serial. У методов print() и println() есть перегрузка print(const __FlashStringHelper*), читающая строку из флеша командой pgm_read_byte — по байту за раз — и шлющая её в порт. Поэтому Serial.println(F("...")) работает без единой дополнительной строки с вашей стороны: обернули — и строка уехала во флеш. В монитор попадают ровно те же символы, что и без F(): глазами вывод не отличить.
В этом и коварство: разницы в выводе нет, и макрос легко снять «для чистоты кода» — всё продолжит работать. Разница живёт только в цифрах памяти, а их надо ловить специально. Монитора памяти в IDE 2 нет; проще всего смотреть на строчку Global variables use после компиляции — она меняется на глазах между вариантами. Для точного замера есть freeRam(), считающая свободное ОЗУ через границу кучи __heap_start: полная реализация — в уроке про PROGMEM и память, ленивым — библиотека MemoryFree.
И сразу о том, чего F() не делает. Она не сжимает текст и не экономит флеш: литерал лежит во флеше в обоих случаях, экономится только копия в SRAM. И не переводит переменные во флеш: F() работает исключительно с текстом в кавычках на этапе компиляции — из этого вырастет половина грабель ниже.
| Что считаем | Сколько выходит | При каком условии |
|---|---|---|
| Пустой скетч с Serial.begin(9600) | ~200 байт SRAM | ядро и буферы порта; зависит от версии ядра |
| Каждые 10 слов текста в кавычках | ~30–60 байт SRAM | зависит от длины слов; кириллица в UTF-8 удваивает |
| Строка журнала из 40 букв | 40 байт транслитом, ~80 кириллицей | UTF-8: два байта на русскую букву плюс терминатор |
| Десяток строк отчёта по ~60 байт | ~600 байт — треть SRAM Uno | столько съедал мой журнальник до F() |
| 1 КБ SRAM под строки | ~17 строк по 60 байт | стеку и переменным остаётся вторая половина — впритык |
| Те же строки через F() | 0 байт SRAM | литерал остаётся во флеше (на ESP32 экономии нет) |
// ДО: каждый литерал в кавычках стартовый код скопирует в SRAMvoid setup() { Serial.begin(9600); Serial.println("=== ZHURNALNIK TEPLICY: SISTEMA ZAPUSHCHENA ==="); Serial.println("Pechat pokazaniy raz v minutu, datchik vlazhnosti na A0"); Serial.println("Kriticheskie sobytiya: peregrev, zamoroz, otkaz datchika");} void loop() { Serial.println("T=23.4 C, H=48%, svet: vklyuchen po tajmeru, okno zakryto"); delay(60000);}// ПОСЛЕ: строки через F() живут во флеше, копий в SRAM нетvoid setup() { Serial.begin(9600); Serial.println(F("=== ZHURNALNIK TEPLICY: SISTEMA ZAPUSHCHENA ===")); Serial.println(F("Pechat pokazaniy raz v minutu, datchik vlazhnosti na A0")); Serial.println(F("Kriticheskie sobytiya: peregrev, zamoroz, otkaz datchika"));} void loop() { Serial.println(F("T=23.4 C, H=48%, svet: vklyuchen po tajmeru, okno zakryto")); delay(60000);}Грабли: где F() не работает и где не нужна
Мой главный тупик — заворачивать в F() вообще всё подряд: каждый знак, каждую метку, каждую цифру, записанную строкой. Выигрыш — ноль: короткие метки вроде «T=» экономят по три-четыре байта, а строка кода превращается в кашу из скобок, которую через месяц не хочется читать. Хуже того, я пытался писать F(переменная) — и получил ошибку компиляции: макрос работает с текстом в кавычках на этапе сборки, а переменная живёт в SRAM, и переложить её во флеш задним числом невозможно. Строки, собранные на лету, тоже мимо: они рождаются в оперативке. Правило, к которому я пришёл: F() — для длинных литералов, которые платят аренду напрасно; остальное — обычным способом.
Вторая грабля — свои функции. Объявляю void log(const char* s), вызываю log(F("Privet")) — и компилятор отказывается: тип __FlashStringHelper* не превращается в const char* автоматически. Это не каприз, а честность: обычный указатель читает SRAM, флеш-строка — другими командами. Лечение — перегрузка: рядом заводим log(const __FlashStringHelper* s) и внутри читаем побайтно. У Print обе перегрузки есть — в Serial.print и println F() проходит без церемоний.
Третья грабля — строковые функции из C. strcpy(buf, F("Privet")) и strcat с флеш-строкой не работают по той же причине: ждут указатели на SRAM. Для флеша существуют _P-версии — strcpy_P, strcat_P, strcmp_P. А если вы собираете строки через String, метод concat() понимает флеш-строку напрямую: myString.concat(F(" Privet")). Ориентир простой: функция принимает __FlashStringHelper* — F() работает; только const char* — ищите _P-аналог.
Четвёртое — другие платы, и тут хорошая новость. На ESP32 и ESP8266 F() не экономит ни байта: строки и так лежат во флеше, а флеш мапится в адресное пространство по требованию — копировать литералы при старте не нужно. Но и вреда нет: макрос определён и ведёт себя как пустышка, скетч один и тот же на Uno и на ESP32. Если проект должен жить на обеих платформах, пишите F() смело: на AVR это экономия, на ESP — нейтрально. Первые кандидаты на обёртку — длинные шпаргалки вроде меню из урока про текстовое меню.
- Запустите симулятор, откройте «Плоттер»: ряд
uptimeрисует ступеньку раз в секунду. Потом попробуйте глазами отличить в мониторе строку через F() от строки без неё. - Посчитайте на бумажке байты скетча «до»: длины всех литералов плюс по байту на терминатор. Сверьте сумму с подписью к коду — так считают бюджет SRAM до заливки.
- Оберните в F() только половину строк журнальника и пересчитайте выигрыш: экономия идёт построчно, каждая невывернутая строка продолжает платить аренду.
- Объявите
void log(const char* s)и вызовитеlog(F("Privet")): прочитайте ошибку компилятора, затем добавьте перегрузкуlog(const __FlashStringHelper* s)и добейтесь сборки. - Проверьте границу макроса:
Serial.println(F(1))иF(счётчик)не компилируются — F() работает только с текстом в кавычках на этапе сборки.
- SRAM на Uno — 2048 байта, и каждый литерал в кавычках стартовый код копирует туда при включении, даже если строка печатается раз в год
- F() оставляет строку во флеше и отдаёт Print указатель __FlashStringHelper*: чтение побайтно, вывод тот же, экономится только SRAM
- Разницу ищите цифрами: «Low memory available» от компилятора или freeRam() (реализация — в уроке про PROGMEM и память); монитора памяти в IDE 2 нет
- F() не превращается в const char* — нужна перегрузка; strcpy и strcat не работают, берите _P-версии или String.concat
- На ESP32 F() не экономит (строки и так во флеше), но и не мешает; заворачивайте только длинные литералы, не всё подряд
- 1. Куда попадает литерал "Teplica: zharko" на Uno по умолчанию?
- 2. Что изменится в мониторе порта после оборачивания всех строк в F()?
- 3. Почему log(F("Privet")) не компилируется при void log(const char* s)?
- 4. Почему на ESP32 F() не экономит память — и что с ней делать?
Частые вопросы по теме урока
- Куда попадает литерал "Teplica: zharko" на Uno по умолчанию?
- Литерал лежит во флеше как часть образа, но стартовый код до setup() копирует его в SRAM — аренда платится даже без печати. Во флеш, а стартовый код копирует его в SRAM при старте
- Что изменится в мониторе порта после оборачивания всех строк в F()?
- F() меняет только место хранения: Print читает флеш побайтно и шлёт те же байты. Выигрыш — в цифрах памяти, не в выводе. Ничего: символы те же, разница только в SRAM
- Почему log(F("Privet")) не компилируется при void log(const char* s)?
- Флеш-строка живёт в другом адресном пространстве. Лечение: перегрузка, _P-версии функций или String.concat. F() отдаёт __FlashStringHelper*, не превращающийся в const char* без перегрузки
- Почему на ESP32 F() не экономит память — и что с ней делать?
- Копировать литералы при старте ESP32 не нужно — флеш мапится в адресное пространство. Скетч один на обе платформы. Строки и так во флеше, макрос — пустышка: можно писать смело