Работа с PROGMEM памятью

Часто бывает нужно сохранить в памяти микроконтроллера большой объём данных, которые не будут меняться в процессе работы, например:

  • Калибровочный массив
  • Текст названий пунктов меню
  • Просто какой-то текст
  • Посчитанная тригонометрия (синус, косинус)
  • Изображения для дисплея (bitmap)
  • И многое другое

Хранить такие данные в оперативной памяти (в виде обычной переменной) - не самая лучшая идея, ведь они не будут меняться, а место займут! Оперативной памяти всегда гораздо меньше, чем программной (Flash) памяти: в той же ATmega328 (Arduino UNO/Nano/Pro mini) - 32 кб Flash и 2 кб SRAM, в 16 раз меньше! Так что гораздо эффективнее хранить такие данные во Flash, он же программная память, он же program memory, он же PROGMEM. Но как?

Мы привыкли к тому, что переменные мы можем менять во время выполнения программы, на то они и переменные, на то и память называется динамической. А вот с Flash памятью всё не так просто - писать в неё может только программатор, при помощи которого загружается код программы, либо загрузчик (bootloader), который практически выполняет функцию программатора. Есть кстати модифицированный загрузчик, который позволяет иметь доступ к Flash памяти прямо из программы, но в этих уроках мы рассматриваем стандартные средства, в данном случае - утилиту PROGMEM. Для работы с PROGMEM используется встроенная библиотека avr/pgmspace.h, подключать её не нужно, она подключится сама (в версиях Arduino IDE выше 1.0).

В esp8266/esp32 PROGMEM работает точно так же и использует такой же синтаксис и рассмотренные в этом уроке конструкции.

Запись


Ключевое слово (модификатор переменной) PROGMEM позволяет записать данные во Flash память. Синтаксис такой:

const тип_данных data[] PROGMEM = {};   // так 
const PROGMEM тип_данных data[] = {};   // или так

Всё! Данные, в показанном случае массивы тип_данных будут помещены во Flash память. PROGMEM может работать со всеми целочисленными типами (8, 16, 32, 64 бита), float и char.

Важный момент! Модификатор PROGMEM можно применять только к глобальным (определённым вне функций) или статическим (глобальным или локальным, но со словом static) переменным! Читай урок про типы данных. Полный список возможностей pgmspace можно посмотреть в документации.

Чтение


Если с записью всё очень просто (добавляется ОДНО ключевое слово), то с чтением всё гораздо интереснее: оно осуществляется при помощи специальных функций. Основная функция чтения из progmem – pgm_read_тип(адрес). Мы можем использовать вот эти 4:

  • pgm_read_byte(data); – для 1-го байта (char, byte, int8_t, uint8_t)
  • pgm_read_word(data); – для 2-х байт (int, word, unsigned int, int16_t, uint16_t)
  • pgm_read_dword(data); – для 4-х байт (long, unsigned long, int32_t, uint32_t)
  • pgm_read_float(data); – для чисел с плавающей точкой

Где data - адрес (или указатель) сохранённого блока данных! Вспомните урок про указатели, чтобы понимать, о чём речь. Полный список возможностей pgmspace можно посмотреть в документации.

Одиночные числа


Рассмотрим запись и чтение одиночных чисел:

const uint16_t data PROGMEM = 125;
const int16_t signed_data PROGMEM = -654;
const float float_data PROGMEM = 3.14;

void setup() {
  Serial.begin(9600);
  Serial.println(pgm_read_word(&data)); // выведет 125

  uint16_t *dataPtr = &data;  // попробуем через указатель
  Serial.println(pgm_read_word(dataPtr));  // выведет 125

  Serial.println(pgm_read_word(&signed_data));  // выведет 64882
  Serial.println((int16_t)pgm_read_word(&signed_data));  // выведет -654

  Serial.println(pgm_read_float(&float_data));  // выведет 3.14
}

Что здесь важно помнить: читая отрицательные числа (например типы int и long) нужно обязательно приводить тип, потому что PROGMEM хранит числа в беззнаковом представлении. Обратите внимание на чтение signed_data из примера выше, без приведения к int число выводится некорректно!

Одномерные массивы


С массивами чисел всё весьма ожидаемо:

const uint8_t data[] PROGMEM = {10, 20, 30, 40};

void setup() {
  Serial.begin(9600);
  for (byte i = 0; i < 4; i++) {
    Serial.println(pgm_read_byte(&data[i]));
  }
  // выведет 10 20 30 40
}

Читай урок по массивам в блоке базовых уроков программирования.

Двумерные массивы


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

const uint16_t data[][5] PROGMEM = {
  {10, 20, 30, 40, 50},
  {60, 70, 80, 90, 100},
  {110, 120, 130, 140, 150},
};

void setup() {
  Serial.begin(9600);
  // выведет 70, вторая строка второй столбец
  Serial.println(pgm_read_word(&data[1][1]));

  // выведет 150, третья строка пятый столбец
  Serial.println(pgm_read_word(&data[2][4]));
}

Массив массивов


Можно хранить несколько массивов в одном, объявив так называемую таблицу ссылок, то есть ещё один массив, который содержит указатели на массивы данных. Этот вариант отличается от двумерного массива тем, что количество "столбцов" в каждой строке может быть любое, необязательно одинаковое:

// массивы
const uint16_t data0[] PROGMEM = {10, 20, 30, 40, 50};
const uint16_t data1[] PROGMEM = {60, 70, 80, 90, 100};
const uint16_t data2[] PROGMEM = {110, 120, 130, 140, 150};
const uint16_t data3[] PROGMEM = {160, 170, 180, 190, 200};

// таблица ссылок
const uint16_t* const data_array[] PROGMEM = {data0, data1, data2, data3};

void setup() {
  Serial.begin(9600);
  // выведет 170, второй элемент четвёртого массива
  Serial.println(pgm_read_word(&data_array[3][1]));
}

Другая ситуация - есть у нас функция, которая принимает 1-мерный массив байтов (например), который хранится в PROGMEM. Например эта функция выводит на дисплей изображение, которое как раз и закодировано в массиве, и функция ожидает, что массив будет PROGMEMный и сама знает, как с ним работать (например так сделано в библиотеках GyverOLED и GyverGFX). Условный пример вывода:

const uint8_t frame0[] PROGMEM = {...};
const uint8_t frame1[] PROGMEM = {...};
const uint8_t frame2[] PROGMEM = {...};
const uint8_t frame3[] PROGMEM = {...};

...

display(frame0);
display(frame1);
display(frame2);
display(frame3);

Если изображений нужно вывести много и подряд, или иметь возможность вызывать "по номеру" - такой подход будет очень громоздким. Можно заменить его на список массивов и выводить по индексу. Но для этого придётся спуститься на уровень ниже - получить адрес массива из списка, и уже его передать в функцию:

const uint8_t* const frames[] PROGMEM = {frame0, frame1, frame2, frame3};

...

for (int i = 0; i < 4; i++) {
  display((const uint8_t*)pgm_read_word(&frames[i]));
}

Как это работает - мы читаем адрес указанного массива (т.к. массив - указатель сам на себя) в памяти и преобразуем его к (const uint8_t*) т.к. наша функция ожидает именно его. Что тут важно - мы читаем адрес массива в памяти, в AVR Arduino (Nano, UNO...) адресация 16 битная, поэтому адрес из PROGMEM списка мы читаем как pgm_read_word. Если работать на 32-битных МК (ESP8266, ESP32...), то нужно будет использовать pgm_read_dword!

Строки в PROGMEM


Строка (как массив символов) хранится в оперативной памяти программы. Речь идёт о строках вида:

char str[] = "Hello!";

PROGMEM позволяет хранить символьные строки в программной памяти. Это очень удобно, ведь большинство текста не меняется в процессе работы программы: названия пунктов меню, имена параметров запросов к сайту, статичные части веб-страниц и так далее. Далее по тексту для краткости будем называть такие строки PGM-строки, или строки в программной памяти.

Для манипуляций с PGM-строкой (вывод, сложение с другими строками, передача в функции) понадобятся дополнительные преобразования. Почему? Программа не знает, что строка хранится не в оперативной памяти: для неё это обычная const char* строка. Но данные ведь находятся в другой области памяти! Если начать читать их как обычную строку - можно прочитать "мусор", либо программа и вовсе зависнет из за ошибок чтения по указанному адресу.

  • Для удобства программиста существует "тип данных" PGM_P, который является макросом на  const char*, то есть это просто указатель на строку. Так сделано для того, чтобы визуально разделить в программе обычные строки от PGM-строк:
    • const char* - строка в оперативной памяти
    • PGM_P - строка в программной памяти
  • Для работы с PGM-строками существует целый набор функций, это "прогмемные" аналоги функций из урока про строки: это те же самые функции, но с постфиксом _P. Полный набор можно посмотреть в документации.

Рассмотрим сначала запись, а потом чтение, при помощи разных инструментов.

Глобальная строка


Строка объявляется глобально, то есть вне функций в программе. Это удобно и выгодно в том случае, когда строка будет использоваться в программе несколько раз. Объявив её один раз, мы будем избегать дубликатов. Синтаксис следующий, строка объявляется как массив в примерах выше:

const char message[] PROGMEM = "Global pgm string";

Обращаться к этой строке в программе можно по её имени, message.

Массив строк


Иногда бывает удобно хранить несколько строк под одним именем, например для пунктов меню. В таком случае можно использовать массив строк, про него мы говорили в уроке про строки. Механизм абсолютно такой же, как у массива массивов, который мы рассмотрели чуть выше: создаём сами строки, а затем - массив с указателями на них:

// объявляем "строки"
const char str1[] PROGMEM = "Period";
const char str2[] PROGMEM = "Work";
const char str3[] PROGMEM = "Stop";

// объявляем таблицу ссылок
const char* const names[] = {
  str1, str2, str3,
};
const char* const names_p[] PROGMEM = {
  str1, str2, str3,
};

Тут есть важный момент - указатели на строки можно хранить как в оперативной памяти (массив names), так и в PROGMEM (массив names_p). Доступ к ним будет отличаться, т.к. во втором случае нужно сначала прочитать из прогмема адрес строки, а потом уже читать саму строку! Например:

Serial.println(FPSTR((PGM_P)pgm_read_ptr(&names_p[0])));
Serial.println(FPSTR(names[0]));

В первом случае сначала читается адрес строки, затем передаётся в макрос. Во втором - достаточно просто прочитать массив.

Локальная строка


Иногда бывает удобно объявлять и использовать PGM-строки локально внутри функции, например если строка используется только в этой функции и больше нигде. Для этого нужно обернуть текст в макрос PSTR(), который поместит текст в PROGMEM и вернёт на него указатель типа const char* (используем PGM_P, чтобы не перепутать с обычной строкой):

PGM_P strp = PSTR("Local pgm string");

Чтение строк из PROGMEM

Переписать в буфер


Можно скопировать строку из PROGMEM в оперативную память для решения следующих задач:

  • Изменение строки
  • Работа как с обычной си-строкой в оперативной памяти
  • Отправка в функцию, которая принимает тип char* (строка в оперативной памяти)

Для этого нужно:

  • Определить размер PGM-строки при помощи функции strlen_P() (вернёт длину строки без учёта завершающего символа)
  • Создать буфер - массив char такого же размера +1 символ (для завершающего нулевого символа строки)
  • Скопировать в буфер PGM-строку при помощи функции strcpy_P() (копирует строку вместе с завершающим символом)
// глобальная строка
const char pstr_g[] PROGMEM = "Global pgm string";

// глобальный массив строк
const char str1[] PROGMEM = "Period";
const char str2[] PROGMEM = "Work";
const char str3[] PROGMEM = "Stop";
const char* const str_list[] PROGMEM = {
  str1, str2, str3
};

void setup() {
  Serial.begin(9600);
  
  char buf_g[strlen_P(pstr_g) + 1];
  strcpy_P(buf_g, pstr_g); 
  Serial.println(buf_g);  // Global pgm string

  char buf_list[strlen_P(str_list[1]) + 1];
  strcpy_P(buf_list, str_list[1]); 
  Serial.println(buf_list);  // Work

  // локальная строка
  PGM_P pstr_l = PSTR("Local pgm string");
  char buf_l[strlen_P(pstr_l) + 1];
  strcpy_P(buf_l, pstr_l); 
  Serial.println(buf_l);  // Local pgm string
}

void loop(){}

Преобразовать к __FlashStringHelper*


Способ подходит для следующих задач:

  • Отправка PGM-строки в функцию, которая принимает тип __FlashStringHelper*
  • Прибавить PGM-строку к String-строке (String поддерживает работу с __FlashStringHelper)
  • "Напечатать" PGM-строку при помощи print()/println() в монитор порта/на дисплей/веб/любой объект стандартного класса Print

В Arduino-фреймворке есть очень удобный инструмент, позволяющий работать с PGM-строками, называется он __FlashStringHelper. Не углубляясь в подробности, будем считать что это просто ещё один тип строковых данных наряду с char* и String. Некоторые функции в библиотеках принимают этот тип данных (можно посмотреть в документации или заголовочном файле библиотеки), что позволяет передавать в них PGM-строки без лишних действий, нужно просто преобразовать переменную к  (const __FlashStringHelper*). Например:

Serial.println((const __FlashStringHelper*)str_pgm);  // str_pgm - PGM-строка

В "ядре" esp8266/esp32 для такого преобразования есть удобный макрос FPSTR(строка), непонятно почему его не сделали для AVR Arduino. Можно объявить макрос самостоятельно и поместить его в начале программы:

#define FPSTR(pstr) (const __FlashStringHelper*)(pstr)

И предыдущий код будет выглядеть более компактно:

Serial.println(FPSTR(str_pgm)); // str_pgm - PGM-строка

Полный пример:

// глобальная строка
const char pstr_g[] PROGMEM = "Global pgm string";

// глобальный массив строк
const char str1[] PROGMEM = "Period";
const char str2[] PROGMEM = "Work";
const char str3[] PROGMEM = "Stop";
const char* const str_list[] PROGMEM = {
  str1, str2, str3
};

void setup() {
  Serial.begin(9600);
  // локальная строка
  PGM_P pstr_l = PSTR("Local pgm string");

  // выведем через print()
  Serial.println(FPSTR(pstr_g));  // Global pgm string
  Serial.println(FPSTR(str_list[2])); // Stop
  Serial.println(FPSTR(pstr_l));  // Local pgm string

  // закинем в String
  String s;
  s += FPSTR(pstr_g);
  s += FPSTR(str_list[2]);
  s += FPSTR(pstr_l);
  Serial.println(s);
  // Global pgm stringStopLocal pgm string
}

void loop(){}

Передать в _P функцию


Способ подходит для следующих задач:

  • Передача PGM-строки в функцию, которая принимает тип PGM_P (в основном это функции с постфиксом _P)
  • Удобное помещение текста в PROGMEM и сразу передача в функцию, которая принимает тип PGM_P

Некоторые функции в библиотеках поддерживают работу напрямую с PGM строками: они принимают тип данных const char* или PGM_P, а в имени обязательно имеют постфикс _P. Например write_P(PGM_P buf) из библиотеки к esp8266 (можно посмотреть в документации или заголовочном файле библиотеки). Это означает, что в такую функцию можно передать PGM-строку без дополнительных преобразований:

// глобальная строка
const char pstr_g[] PROGMEM = "Global pgm string";

// глобальный массив строк
const char str1[] PROGMEM = "Period";
const char str2[] PROGMEM = "Work";
const char str3[] PROGMEM = "Stop";
const char* const str_list[] PROGMEM = {
  str1, str2, str3
};

void setup() {
  Serial.begin(9600);
  // локальная строка
  PGM_P pstr_l = PSTR("Local pgm string");

  client.write_P(pstr_g);
  client.write_P(str_list[2]);
  client.write_P(pstr_l);
  client.write_P(PSTR("Inline pgm string"));
}

void loop(){}

F() макро


Способ подходит для следующих задач:

  • Удобное помещение текста в PROGMEM и сразу передача в функцию, которая принимает тип __FlashStringHelper*
  • В том числе для сборки String-строк

Вы могли подумать, а зачем создавать PSTR() строку и PGM_P переменную ради одного вывода? Действительно, может просто можно сделать print((const __FlashStringHelper*)PSTR("Hello, World!"))? Да, можно! Причём всё уже придумано за нас и называется "F() macro", этот удобный макрос позволяет ещё проще хранить строки в программной памяти для отправки в функции, которые поддерживают __FlashStringHelper (можно посмотреть в документации или заголовочном файле библиотеки):

void setup() {
  Serial.begin(9600);
  Serial.println(F("Inline pgm string"));

  String s;
  s += F("Hello, ");
  s += F("World!");
  Serial.println(s);
}

void loop(){}

F() макро + __FlashStringHelper


Также F-макро позволяет создавать и хранить строки типа __FlashStringHelper*. F-строки не оптимизируются компилятором, то есть например здесь

Serial.print(F("Hello!"));
lcd.print(F("Hello!"));

Строки займут место в памяти программы как две строки, то есть компилятор не объединит две одинаковые строки, как это происходит при обычной работе со строками. Поэтому F-строку можно создать отдельно и передать в нужные функции или сложить со стрингой, но сделать это можно только локально:

void setup() {
  const __FlashStringHelper* str = F("Hello!");
  Serial.println(str);
  lcd.println(str);
  String s = str;
  s += str;
}

Полезные страницы


5/5 - (10 голосов)
5 1 голос
Рейтинг статьи
Подписаться
Уведомить о
guest

22 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии
Прокрутить вверх