Про оптимизацию с помощью PROGMEM в AVR


В младших контроллерах AVR используется два независимых адресных пространства: для кода и для оперативной памяти и регистров (т.н. «гарвардская архитектура»). Инструкции выполняются из flash, а переменные, стек и обычные объекты данных находятся в SRAM. Изначально язык Си проектировался для других архитектур, поэтому обычные указатели в Си сами по себе не несут признака, к какому адресному пространству они относится. Из-за этого компилятор не может узнать, где будет использоваться переменная и разместить ее там, а следовательно программисту требуется заботиться об этом самому, иначе можно столкнуться с ситуацией при которой программа внезапно "сжирает" весь доступный SRAM.


Пара слов про архитектуру...
В гарвардской архитектуре память программы и память данных разделены в основном ради оптимизации выполнения программ. Основная идея в том, что процессор может одновременно читать инструкцию и работать с данными. Например, ядро AVR может брать следующую команду из flash, пока отдельная логика обращается к SRAM. Если код и данные сидят на одной шине, как в классической фон-неймановской машине, возникает узкое место: надо по очереди то читать инструкцию, то читать/писать данные. Это называют “фон-неймановским бутылочным горлышком”.

Вторая причина особенно важна для микроконтроллеров: код и данные просто удобно хранить в физически разной памяти. Программа обычно лежит во flash: она энергонезависимая и дешёвая, но медленнее и неудобна для частой записи. Переменные лежат в SRAM: она быстрая и нормально перезаписывается, но дорогая и занимает больше площади кристалла. Для AVR это естественно: прошивка живёт во flash, стек/глобальные переменные/буферы — в SRAM.

В фон-неймановской архитектуре используется другая идея: любой код и данные — это просто байты в одном адресном пространстве. Процессору всё равно, что он читает: инструкцию, число, строку или таблицу. Это заметно упрощает модель программирования. Обычный указатель указывает куда-то «в память», и не нужно думать, из какой именно памяти читать. Поэтому язык Cи, Unix-подобные ОС и большинство прикладного программирования в целом исторически хорошо уложились именно на такую модель.

Вкратце проблему можно описать так: для обычного кода в Си строка должна быть доступна как обычный массив байт через обычный указатель, это значит, если ничего специально не указать, строковый литерал или инициализированный массив в итоге оказываются доступными из SRAM. При этом исходные байты всё равно находятся и во flash как часть образа прошивки, откуда они копируются или используются для инициализации. Из этого следует неприятный эффект: строка выглядит константной, но всё равно расходует SRAM. Например, строка в вызове Serial.print("Connecting...") для AVR не является тем же самым, что строка, оставленная во flash и читаемая специальным кодом.

Для решения данной проблемы и был придуман PROGMEM. Он позволяет явно сказать компилятору AVR-GCC: этот объект нужно положить во flash, а не переносить в SRAM как обычные данные.



Что такое PROGMEM

PROGMEM — это не ключевое слово языка C или C++. Это макрос из avr/pgmspace.h, который разворачивается в GCC-атрибут для размещения объекта в программной памяти, то есть во flash.

Типовой вариант объявления выглядит так:

const uint8_t image[] PROGMEM = { 0x00, 0x01, 0x02, 0x03 };

Важная деталь: PROGMEM применяется к самому объекту. Поэтому безопаснее всего использовать его с массивами и явно размещать после имени массива, перед инициализатором. Для строк это выглядит так:

const char message[] PROGMEM = "Hello world!";

Просто написать const недостаточно. const означает только то, что объект нельзя изменять через этот идентификатор. Оно не говорит, в каком физическом адресном пространстве должен лежать объект.

После добавления PROGMEM объект действительно оказывается во flash. Но обычное выражение чтения по-прежнему будет скомпилировано как чтение из пространства данных. Компилятор не начинает автоматически подставлять специальные инструкции чтения flash только потому, что где-то в объявлении стоял атрибут.

То есть такой код для массива в PROGMEM неверен:

uint8_t value = image[i];

Правильный вариант — взять адрес нужного элемента и явно передать его в подходящий макрос чтения:

uint8_t value = pgm_read_byte(&image[i]);

Для байта используется pgm_read_byte, для слова — pgm_read_word, для указателя — pgm_read_ptr. Есть и far-варианты для адресов за пределами ближней области программной памяти. Это важно для крупных AVR с большим объёмом flash: двухбайтовый указатель даёт доступ только к первым 64 КБ flash, для near-доступа, данные выше этой области тоже можно читать, но уже другими средствами.



Функции с суффиксом _P

Так как строка во flash не является обычной строкой в SRAM, стандартные строковые функции не подходят. Для этого в avr-libc есть отдельные функции, у которых в имени обычно стоит суффикс _P.

  • strcpy копирует строку из обычной памяти данных.
  • strcpy_P копирует строку из программной памяти во flash в буфер в SRAM.
  • strlen считает длину обычной строки.
  • strlen_P считает длину строки, лежащей в программной памяти.
  • printf ожидает обычную строку формата.
  • printf_P ожидает строку формата во flash.

Поэтому код вроде этого опасен:

char dest[20];
strcpy_P(dest, "Hello world!");

Второй аргумент здесь выглядит как строка, но для strcpy_P он должен быть адресом строки в программной памяти. Если передать обычный строковый литерал, функция будет читать flash по адресу, который в данном контексте не является корректным адресом такой строки. Результат — мусор, повреждённые данные или просто непредсказуемое поведение.



PSTR: строка во flash внутри функции

Для одноразовых строковых литералов в C-коде avr-libc даёт макрос PSTR(). Он создаёт строку в программной памяти и возвращает указатель, который можно передать функции, умеющей работать с PROGMEM-строками.

Такой вариант корректен:

char dest[20];
strcpy_P(dest, PSTR("Hello world!"));

Теперь strcpy_P получает именно то, что ожидает: указатель на строку во flash. SRAM не расходуется на постоянное хранение этого литерала, хотя временный буфер dest, разумеется, всё равно находится в SRAM.

Но у PSTR() есть важное ограничение: его нельзя использовать как обычный глобальный инициализатор. Такой код для AVR-GCC обычно не компилируется: const char *pStr = PSTR("Hello");. Причина в том, что реализация PSTR() использует расширение GCC, рассчитанное на выражение внутри функции. Если нужна именованная глобальная строка во flash, её надо объявлять как объект:

const char pStr[] PROGMEM = "Hello";

F(): удобный вариант для Arduino Print

В Arduino чаще всего видят не PSTR(), а макрос F(): Serial.print(F("Connecting to WiFi..."));

По смыслу он тоже оставляет строковый литерал во flash. Но F() решает ещё одну задачу: он приводит результат к типу const __flashStringHelper *. Это нужно для перегрузок Serial.print() и других классов, наследующих Arduino Print.

Если передать в Serial.print() просто PSTR("text"), тип будет похож на обычный const char *, и может выбраться перегрузка, которая читает строку как обычную строку из SRAM. Поэтому для вывода на ардуинах обычно используют именно F(), а для C-функций avr-libc — PSTR() и функции с суффиксом _P.

Резюмируя:

  • Serial.print(F("text")) — нормальный путь для одноразового вывода строки из flash в Arduino.
  • strcpy_P(buffer, PSTR("text")) — вариант для C-функции, которая ждёт PROGMEM-строку.
  • Serial.print(PSTR("text")) — плохая идея, потому что тип не помогает выбрать правильную перегрузку.


Если одна и та же строка используется несколько раз

F() и PSTR() удобны для строк, которые встречаются один раз. Но если один и тот же текст используется в нескольких местах, компилятор не обязан объединять эти литералы как один объект во flash. В результате одинаковая строка может быть записана в прошивку несколько раз.

В таком случае лучше объявить строку отдельно:

const char msgConnecting[] PROGMEM = "Connecting...";

// Для вывода через Arduino Print её можно привести к ожидаемому типу:
Serial.print((const __flashStringHelper *)msgConnecting);

// Для копирования в SRAM используется функция из avr-libc:
strcpy_P(buffer, msgConnecting);


Массив строк во flash

Отдельная ловушка — массив строк. Если написать только так:

const char *messages[] PROGMEM = { "Start", "Stop", "Error" };

Во flash попадёт прежде всего сам массив указателей. Но это ещё не гарантирует, что сами строки "Start", "Stop" и "Error" тоже лежат там, где их ждут функции работы с программной памятью.

Надёжный вариант — объявить каждую строку отдельно:

const char msgStart[] PROGMEM = "Start";
const char msgStop[] PROGMEM = "Stop";
const char msgError[] PROGMEM = "Error";

// Потом собрать таблицу указателей, которая сама тоже лежит во flash:
const char * const messages[] PROGMEM = { msgStart, msgStop, msgError };
// При чтении сначала нужно достать из flash указатель на нужную строку, а уже потом работать с самой строкой как с PROGMEM-строкой:
PGM_P p = (PGM_P)pgm_read_ptr(&messages[i]);
strcpy_P(buffer, p);

Старые примеры часто используют pgm_read_word, потому что на классических AVR указатель занимает 16 бит. Более ясный современный вариант — pgm_read_ptr, потому что по смыслу читается именно указатель.



Где чаще всего ошибаются

Первая ошибка — считать, что const и PROGMEM решают одну и ту же задачу. Не решают. const запрещает изменение, PROGMEM меняет место хранения объекта.

Вторая ошибка — положить объект во flash, а потом читать его обычным индексированием. Для AVR это не становится автоматически корректным. Нужны pgm_read_byte, pgm_read_word, pgm_read_ptr или специальные строковые функции.

Третья ошибка — использовать PSTR() в глобальной области. Для именованных глобальных строк надо объявлять массив с PROGMEM, а не пытаться сохранить результат PSTR() в глобальный указатель.

Четвёртая ошибка — объявить таблицу строк с PROGMEM и решить, что этого достаточно для самих строк. Таблица указателей и строки, на которые она указывает, — разные объекты. Если строки тоже должны быть во flash, их нужно объявить отдельно.

Пятая ошибка — путать PSTR() и F(). Они близки по идее, но используются в разных местах. PSTR() удобен для функций avr-libc с суффиксом _P. F() удобен для Arduino Print и Serial.print().



Важно помнить: PROGMEM не делает программу быстрее! Наоборот, чтение из flash требует специальных инструкций и дополнительного кода. Иногда это чуть увеличивает размер прошивки и время выполнения. Но на маленьких AVR это обычно нормальный обмен: SRAM очень мало, а flash заметно больше. Для Arduino Uno речь идёт всего о 2 КБ SRAM, и несколько строк интерфейса, таблица символов или картинка для дисплея могут занять существенную часть этой памяти. В таких условиях выигрыш от освобождения SRAM часто важнее небольшой платы по скорости.


Что с другими микроконтроллерами

PROGMEM — это прежде всего история про AVR и разделённые адресные пространства. Данная оптимизация релевантна для 8-битных AVR-based MCU (Arduino Uno, Nano, Mega на ATmega), где SRAM мала (0.5–8 кБ), а flash 16–256 кБ. На ARM-микроконтроллерах (STM32, ESP32, Arduino Due) архитектура единого адресного пространства (unifed memory map) позволяет читать const напрямую из flash без макросов.

На других платформах Arduino этот макрос может существовать для совместимости, но эффект и необходимость отличаются:
  • На AVR вроде ATmega328P, ATmega2560 или ATtiny85 это практически значимый инструмент экономии SRAM.
  • На ESP8266 и ESP32 модель памяти другая, а SRAM обычно больше; макросы совместимости есть, но их смысл не полностью совпадает с классическим AVR.
  • На ARM-платформах вроде SAMD или STM32 обычные const-данные часто и так могут оставаться во flash, а доступ к ним устроен иначе.
  • На PIC и 8051 похожая проблема тоже возможна, но синтаксис и библиотечные функции зависят от конкретного компилятора.

Поэтому переносить AVR-рецепты буквально на любой микроконтроллер не стоит. Надо смотреть, как именно у выбранной архитектуры устроена память и что делает конкретный компилятор.







ˆ