Справочный стенд для лаборатории: меню из четырнадцати строк на русском, журнал измерений, подсказки по кнопкам. На этапе отладки, когда строк было пять, всё летало. Я дописал блок помощи — и стенд стал врать: текст в мониторе рвался кракозябрами, а при входе в меню плата замолкала намертво, спасала только перезаливка. Компилятор при сборке кричал предупреждением: Global variables use 1893 bytes (92%) of dynamic memory. Виновников я нашёл быстро — это были мои же строки, которые я считал безобидными литералами.
Механика такая. ATmega328P не умеет читать константы из флеша обычной командой загрузки — для флеша есть отдельная инструкция LPM, и компилятор C++ на AVR не вставляет её автоматически. Поэтому каждый строковый литерал при старте скетча копируется из флеша в RAM и живёт там до выключения питания. Килобайт подсказок плюс обычные глобальные переменные плюс стек — и 2048 байт SRAM Uno кончаются раньше, чем вы завели первый буфер.
Здесь разбираю прицельно строки: как устроен F()-макрос, что он экономит и чем платит, сколько байт весит кириллица в UTF-8 и как замером увидеть всё это в цифрах, а не в ощущениях. Карта трёх памятей уже была в уроке про PROGMEM и 2 килобайта, таблицы чисел во флеше — в уроке про PROGMEM-таблицы; здесь — текст, который вы печатаете каждый день и который ест память каждый старт.
Куда попадает литерал без F()
Считаем на абстрактном примере: строка из двадцати букв кириллицы, в UTF-8 каждая буква занимает два байта, плюс нулевой терминатор. Итого 41 байт. Компилятор кладёт сам текст во флеш — и это хорошо. Но он же записывает литерал в таблицу инициализации, и при каждом старте скетча 41 байт физически копируется в RAM. Это происходит до setup, без вашего участия, для каждого литерала в скетче — включая те, что напечатаны один раз за всю жизнь программы. Двадцать строк по сорок букв — это уже 1620 байт, четыре пятых всей RAM.
F() разрывает эту цепочку. Макрос оборачивает литерал в PSTR и приводит указатель к специальному типу __FlashStringHelper — по сути ярлыку «эта строка живёт во флеше». Для этого типа у Serial.print есть собственная перегрузка, которая читает текст по одному байту через pgm_read_byte и отправляет в UART на ходу. В RAM попадают два байта указателя — и всё. Цена — время: чтение из флеша через LPM медленнее загрузки из RAM, длинные тексты печатаются заметно дольше. Для меню и логов разница незаметна, для покадрового вывода на матрицу — уже существенна.
Границы приёма честные. F() работает в месте вызова: результат нельзя присвоить обычному char* и куда-то передать — тип специальный, живёт до конца строки вызова. На ESP32 и платах SAMD литералы и так лежат во флеше, и F() там остался для совместимости: ничего не экономит, но и не мешает. И русская специфика: два байта на букву считает именно UTF-8, латиница в нём однобайтная, поэтому англоязычные скетчи прожорливость строк замечают вдвое позже.
Таблица инициализации несёт не только строки. Любая глобальная переменная с начальным значением — int limit = 500, массив настроек, буфер с «пустой» строкой внутри — попадает туда же и копируется в RAM тем же стартовым кодом. Механику выбора между #define, const и constexpr, когда значения остаются только во флеше компилятора, разобрал в уроке про константы — для строкового бюджета достаточно запомнить: то, что инициализировано значением, уезжает из флеша в RAM целиком при каждом старте.
Считать бюджет надо в байтах, а не в буквах — на кириллице разница ровно вдвое. Слово «Температура» — одиннадцать букв и двадцать три байта в UTF-8 с терминатором: каждая буква живёт парой байтов, старший всегда D0 или D1, по нему декодер понимает, что дальше идёт второй. В UART летят те же два байта без перекодировки — F() читает флеш по байту, монитор порта собирает их обратно в буквы; что доезжает до платы байт за байтом, разбирал в уроке про монитор порта IDE 2. Однажды я прикинул меню по символам, получил вдвое заниженный бюджет и полчаса искал, куда делись двести с лишним байт.
#include <avr/pgmspace.h> int freeRam() { extern int __heap_start, *__brkval; int v; return (int)&v - (__brkval == 0 ? (int)&__heap_start : (int)__brkval);} // Вариант А (до рефакторинга): литералы копируются в RAM при старте// void menu() {// Serial.println("1 - Temperatura");// Serial.println("2 - Vlazhnost");// Serial.println("3 - Svet");// } // Вариант Б (после): текст во флеше, в RAM только указателиconst char m1[] PROGMEM = "1 - Temperatura";const char m2[] PROGMEM = "2 - Vlazhnost";const char m3[] PROGMEM = "3 - Svet";const char* const MENU[] PROGMEM = { m1, m2, m3 }; void setup() { Serial.begin(9600); Serial.print(F("start: freeRam = ")); Serial.println(freeRam()); Serial.println(F("KOMANDY: 1-3 punkt, R raspechatat menu, ram - svobodnaya RAM"));} void loop() { if (Serial.available()) { String cmd = Serial.readString(); cmd.trim(); if (cmd == "R") { for (int i = 0; i < 3; i++) { char buf[24]; strcpy_P(buf, MENU[i]); // чтение из флеша во временный буфер Serial.println(buf); } } else if (cmd == "ram") { Serial.print(F("freeRam = ")); Serial.println(freeRam()); } else { Serial.print(F("prinyato: ")); Serial.println(cmd); } }}В варианте Б спрятаны две детали, на которых я спотыкался. Таблица указателей MENU тоже помечена PROGMEM — это двойной уровень: сами тексты во флеше и массив адресов во флеше, а во время работы адрес достаётся косвенно, через чтение указателя. Если забыть PROGMEM у массива, указатели уедут в RAM, и всё ещё будет работать — просто по два лишних байта на строку, тихо, без симптомов. Функции с суффиксом _P — strcpy_P, strcmp_P, memcpy_P — понимают адреса во флеше; обычный strcpy с PROGMEM-адресом прочитает мусор.
Функцию freeRam() я привожу как есть, без разбора — её анатомия, __heap_start и __brkval, метод дельты и ловля утечек подробно разобраны в уроке про FreeRam. Здесь она — манометр: цифру печатаем сразу после setup и потом по команде ram. Замечу про предупреждение компилятора: порог у него консервативный, предупреждение выскакивает, когда RAM ещё не кончилась, а когда скетч ведёт себя странно от нехватки — порой молчит. Цифра freeRam надёжнее криков.
Грабля на стыке функций, о которой предупреждал абзац про границы: передача F()-строки в функцию, ждущую const char*. Компилятор ловит её на месте — __FlashStringHelper молча в char* не превращается, и правильно делает: под ярлыком спрятан адрес во флеше, и код, принявший его за указатель в RAM, прочитал бы мусор. Обхода два: завести параметр const __FlashStringHelper* и печатать его той же перегрузкой print, либо скопировать текст во временный буфер через strcpy_P и отдать буфер. Ругань компилятора здесь полезная: она честно говорит, что вы собирались читать флеш как оперативку.
Замер: сколько возвращают F() и PROGMEM
Методика из двух сборок одного скетча. Сборка А — все печати как обычные литералы; после setup печатаю freeRam: у моего стенда с четырнадцатью строками подсказок выходило 1187 байт свободной RAM. Сборка Б — те же строки в F() и PROGMEM-таблице: 1799 байт. Разница — 612 байт, и это ровно суммарный размер литералов в UTF-8 с терминаторами, до байта. Такой замер снимает все споры: не «вроде стало легче», а конкретные шестьсот двенадцать байт, которые теперь держат буферы, стек и переменные.
Дальше полезно посмотреть на динамику. Печатайте строку меню сотню раз подряд и понаблюдайте freeRam: с F() цифра стоит на месте, строка читается из флеша на лету. А вот склейка строки отчёта из F()-куска и значения — каждый раз копирует литерал из флеша в кучу ради сборки; в цикле отчёта это та же история роста, что в уроке про утечки String. Правило простое: печатать кусками — print(F(...)), print(значение), println() — и не собирать строку ради одной печати.
И последняя проверка на трезвость: счётчик байт виден и без симулятора — компилятор при сборке печатает строку про глобальные переменные и процент использованной динамической памяти. Но она видит только статику: литералы и глобальные буферы. Библиотеки, просящие память у кучи в runtime, в этой строке не видны — поэтому замер freeRam до и после каждого init в setup остаётся единственным честным способом узнать, кто съедает остаток.
Самые выгодные кандидаты на F() — строки, которые печатаются один раз в setup: приветствие, версия прошивки, приглашение консоли. Нужны они секунду, а копия в RAM после старта сидит до выключения питания — аренда за всю жизнь скетча за один показ. У моего стенда примерно половина из возвращённых шести сотен байт пришлась как раз на приветственный экран и справку, а не на частые логи. Частые повторы в цикле тоже стоят перевода, но они хотя бы видны в профиле печати; одноразовые строки проедают память молча.
И обратная сторона: не всё стоит оборачивать. Пара коротких ASCII-меток в отладочной печати — байт двадцать, и возня с F() её не окупит: чтение из флеша медленнее, а код длиннее на три буквы в каждом вызове. Я оборачиваю всё, что длиннее десятка символов или повторяется в цикле, и не трогаю однобуквенные ярлыки уровня OK. Граница субъективная, но она есть: цель приёма — вернуть RAM, а не собрать коллекцию макросов.
На ESP32 и SAMD цифры замера выглядят скучно, и это тоже информация. Там флеш отображён в адресное пространство, литералы читаются обычными командами загрузки, копировать их при старте незачем — экономить нечего, цена по скорости ноль. F() там безвреден и остаётся для переносимости, а вот мой манометр ломается: freeRam через __heap_start и __brkval — сугубо AVR-овская конструкция, на ESP32 она не соберётся, свободную память показывает ESP.getFreeHeap(). Перенося стенд с Uno, я прячу freeRam в обёртку под плату — манометр должен меняться вместе с архитектурой.
| Хранилище строк | Флеш | RAM при старте | RAM в процессе |
|---|---|---|---|
| Литерал без F() | сам текст | копия всего текста, до setup | не растёт: строка уже в RAM |
| F("...") в print | сам текст | только 2 байта адреса на время вызова | чтение из флеша по байту, RAM чиста |
| PROGMEM-таблица строк | тексты и массив указателей | ноль | чтение через strcpy_P во временный буфер |
| String со склейкой += в loop | текст-источник | первая копия | растёт круг за кругом — та же утечка, что с отчётом из урока про FreeRam |
- 1. Что происходит с литералом в Serial.print("текст") на Uno?
- 2. Сколько байт RAM занимает кириллическая строка из 20 букв в UTF-8?
- 3. Что возвращает макрос F("...")?
- 4. Когда F() не экономит ничего?
- 5. freeRam() после перевода всех литералов на F()…
- Каждый строковый литерал на AVR копируется из флеша в RAM при старте — 20 кириллических строк по 40 букв съедают 1620 байт из 2048
- F() оборачивает литерал в __FlashStringHelper: печать читает флеш по байту через pgm_read_byte, в RAM уезжают 2 байта указателя, цена — скорость
- PROGMEM у таблицы строк ставится дважды: на тексты и на массив указателей, чтение — только функции с суффиксом _P
- Замер из двух сборок с freeRam даёт точную цифру экономии: у стенда урока 14 строк вернули 612 байт
- Склейка String(F(...)) + значение копирует литерал в кучу каждый раз — печатайте кусками, а на ESP32/SAMD F() ничего не экономит
Частые вопросы по теме урока
- Что происходит с литералом в Serial.print("текст") на Uno?
- AVR не читает флеш обычной загрузкой, поэтому компилятор копирует каждый литерал в RAM ещё до setup. Печать из флеша — только через F() или функции с суффиксом _P. копируется в RAM при старте скетча и живёт там до выключения
- Сколько байт RAM занимает кириллическая строка из 20 букв в UTF-8?
- Два байта на букву — сорок, плюс нулевой терминатор — 41. Латиница в UTF-8 однобайтная, поэтому англоязычные скетчи расход заметно позже. 41
- Что возвращает макрос F("...")?
- Специальный тип-ярлык включает перегрузку print, которая читает байты из флеша через pgm_read_byte. Присвоить его обычному char* нельзя — тип не совместим. указатель на __FlashStringHelper — строку во флеше
- Когда F() не экономит ничего?
- На ARM-платах литералы по умолчанию живут во флеше, и F() остался для переносимости кода. На AVR эффект максимальный — там без него всё едет в RAM. на ESP32 и SAMD, где литералы и так лежат во флеше
- freeRam() после перевода всех литералов на F()…
- Копии литералов больше не занимают RAM при старте, поэтому зазор между кучей и стеком растёт ровно на их суммарный размер. У стенда из урока — 612 байт. вырастет на суммарный размер заменённых строк