PROGMEM или PGM (program memory, память программы) - собственно постоянная память, в которой хранится исполняемый код программы, он попадает туда во время прошивки и не меняется в процессе работы. В МК обычно представлен Flash памятью. В реальной программе часто бывает нужен некоторый набор данных, который не будет меняться в процессе работы - константа или массив констант: например какая-то матрица перехода координат, таблица посчитанных значений, изображения для дисплея в бинарном виде, шрифт для дисплея или просто набор строк - пунктов меню. Мы можем пометить эти данные как const
, но где они будут расположены?
const int data[] = {1, 2, 3};
const char* str = "hello";
Данный урок актуален для МК AVR (Arduino UNO, Nano, Mega и прочие), а также ESP8266, которая поддерживает такую работу с памятью
Где хранятся константы? #
Проблема некоторых семейств микроконтроллеров (в частности AVR) заключается в том, что любые const
данные и даже просто "строки"
(тип const char[]
) хранятся не только в памяти программы - при запуске они загружаются в оперативную память - RAM, т.е. начинают занимать место рядом с обычными переменными. Оперативной памяти в МК обычно сильно меньше, чем постоянной, так что программа получается очень неэффективной, а то и вовсе может не хватить памяти под задачи проекта! К счастью, выход есть.
SDK ESP32 актуальных версий автоматически размещает константы и "строки"
в программной памяти - вручную ничего делать не нужно!
Гарвардская архитектура #
МК AVR имеют гарвардскую архитектуру - в ней адресное пространство постоянной памяти лежит отдельно от оперативной памяти. В программе мы по умолчанию работаем именно с оперативной памятью, т.е. любые действия с адресами и указателями происходят в оперативной памяти. Чтобы прочитать данные по адресу в программной памяти, нужно сообщить процессору о своих намерениях. Но сначала эти данные туда нужно записать.
Это - большой костыль, современные процессоры делаются на другой архитектуре с совмещённым адресным пространством, что позволяет работать с константами полностью нативно и прозрачно
Атрибут PROGMEM #
Чтобы данные остались в программной памяти и не копировались в оперативку, нужно:
- Объявить/определить их глобально в программе или статически внутри функции
- Сделать константой
const
- Добавить слово
PROGMEM
перед оператором=
- Инициализировать значением
Для использования PROGMEM-инструментов нужно подключить библиотеку #include <avr/pgmspace.h>
- она уже подключена в Arduino.h
const uint8_t pgm_u8 PROGMEM = 123;
const uint8_t pgm_arr[] PROGMEM = {1, 2, 3};
В PROGMEM можно поместить любой тип данных, в том числе составной (массив, структуру, структуру со структурами и массивами внутри)
sizeof #
Оператор sizeof
работает как ожидается - возвращает размер данных в байтах. Никаких дополнительных действий не требуется - размер считается на этапе компиляции и просто подставляется числом в программу - процессору уже неважно, в какой области лежат эти данные.
Объявление и определение #
PROGMEM - это просто атрибут, он не меняет правил компиляции и линковки файлов, описанных в этом уроке. Если необходимо разделить объявление и определение на разные файлы, то нужно просто добавить слово PROGMEM
и там и там:
// === lib.h - объявления
extern const int some_data_p PROGMEM;
extern const uint8_t some_arr_p[] PROGMEM;
// === lib.cpp - определения
const int some_data_p = 123;
const uint8_t some_arr_p[] PROGMEM = {1, 2, 3};
Если константа помещается в одиночный заголовочный файл, то нужно сделать её static
, чтобы не было ошибки линковки. Но нужно помнить, что если такой заголовочный файл подключится в несколько исходных файлов - данные продублируются в памяти. Чтобы этого избежать - разбивайте на два файла как выше:
// === lib.h
static const int some_data_p = 123;
Одиночные константы #
Одиночные константы можно хранить в PROGMEM, но это не имеет особого смысла - проще использовать #define
или просто const
- компилятор скорее всего вырежет такую константу и заменит её числом:
const uint8_t pgm_u8 PROGMEM = 123;
const int16_t pgm_i16 PROGMEM = -1234;
Для чтения одиночных значений из PROGMEM существует отдельный набор функций (см. справочник), принимающих указатель (адрес) на данные в PROGMEM. Читать нужно в тот же тип, что записан, чтобы не потерялись данные или знак:
uint8_t val8 = pgm_read_byte(&pgm_u8); // val8 == 123
int16_t val16 = pgm_read_word(&pgm_i16); // val16 == -1234
// по указателю тоже можно
int16_t* i16_ptr = &pgm_i16; // указатель на данные в PGM
val16 = pgm_read_word(i16_ptr); // val16 == -1234
Хранение адреса #
Более того, можно сохранить в PROGMEM сам указатель на данные в PROGMEM, т.е. значение адреса. Это не имеет практического смысла, но важно для дальнейшего понимания урока:
const uint16_t pval PROGMEM = 1234; // 16-бит данные в PGM
const uint16_t* const pval_ptr PROGMEM = &pval; // адрес pval в PGM сохранили в PGM
uint16_t* p = (uint16_t*)pgm_read_ptr(&pval_ptr); // чтение адреса из PGM
uint16_t val16 = pgm_read_word(p); // чтение значения по этому адресу
// val16 == -1234
Одномерные массивы #
Здесь всё логично: можно читать данные по одному элементу по их адресу, как переменные в предыдущей главе:
const uint8_t pgm_arr[] PROGMEM = {1, 2, 3};
for (int i = 0; i < 3; i++) {
uint8_t read1 = pgm_read_byte(&pgm_arr[i]); // адрес i-го элемента
uint8_t read2 = pgm_read_byte(pgm_arr + i); // равносильно
// read1 == read2 == 1, 2, 3
}
Функции для массивов #
В библиотеке для PROGMEM есть набор функций для работы с памятью - это привычные функции для работы с памятью, но с суффиксом _P
на конце, они принимают в качестве исходного массива массив из PROGMEM. Например скопируем массив из PGM (из примера выше) в буфер:
// буфер размером с PGM массив
uint8_t arr[sizeof(pgm_arr)];
// копируем из PROGMEM в RAM
memcpy_P(arr, pgm_arr, sizeof(pgm_arr));
// arr == {1, 2, 3}
Многомерные массивы #
С многомерными массивами всё немного сложнее, т.к. атрибут PROGMEM
помещает в PROGMEM только самую младшую размерность массива, т.е. непосредственно сами данные. Остальная конструкция массива остаётся в оперативной памяти!
const uint8_t parr[][5] PROGMEM = {{1, 1, 1, 1, 1}, {2, 2, 2, 2, 2}, {3, 3, 3, 3, 3}};
for (int i = 0; i < 3; i++) { // 3 внутренних массива
// указатель на i-массив в PGM, читаем из оперативки
const uint8_t* arr = parr[i]; // #1
for (int j = 0; j < 5; j++) { // по 5 элементов
pgm_read_byte(&arr[j]); // читаем элементы
// 1, 1, 1, 1, 1
// 2, 2, 2, 2, 2
// 3, 3, 3, 3, 3
}
}
Список указателей #
Есть ещё один способ хранения многомерных массивов: сначала сохранить в PGM сами массивы с данными, а затем сделать массив указателей на эти массивы. Это позволяет хранить в PGM массивы разной длины, а не одинаковой, как в таблице. Чтобы указатели на массивы не занимали места в оперативке как глобальные переменные - их тоже можно сохранить в PROGMEM. Чтение будет отличаться в строке #1
- нам придётся прочитать адрес следующего массива из PROGMEM, а не просто из массива. В данном примере я оставил массивы одинаковой длины, чтобы не менять код. Важна сама суть, дальше это пригодится нам для строк:
// массивы с данными
const uint8_t parr1[5] PROGMEM = {1, 1, 1, 1, 1};
const uint8_t parr2[5] PROGMEM = {2, 2, 2, 2, 2};
const uint8_t parr3[5] PROGMEM = {3, 3, 3, 3, 3};
// массив указателей на них в PGM, в PGM
const uint8_t* const parr[] PROGMEM = {parr1, parr2, parr3};
for (int i = 0; i < 3; i++) { // 3 внутренних массива
// указатель на i-массив в PGM, читаем из PROGMEM
const uint8_t* arr = (const uint8_t*)pgm_read_ptr(&parr[i]); // #1
for (int j = 0; j < 5; j++) { // по 5 элементов
pgm_read_byte(&arr[j]); // читаем элементы
// 1, 1, 1, 1, 1
// 2, 2, 2, 2, 2
// 3, 3, 3, 3, 3
}
}
Строки в PROGMEM #
PGM строки могут использоваться для хранения частей текста для последующей сборки новой строки уже в оперативной памяти, для печати или вывода на дисплей (пункты меню). Строка - это такой же массив, просто с символами - типа char[]
. Массив можно инициализировать строковым литералом, т.е. в массив символов запишется указанный текст вместе с завершающим нулём:
// массив в PGM с текстом "pgm string"
const char pstr[] PROGMEM = "pgm string";
Здесь "pgm string"
не существует в программе - этот текст будет записан в массив pstr
, т.е. просто расположен в памяти по некому адресу.
PGM_P #
PGM-строка, как и обычный массив символов, может преобразовываться и передаваться как const char*
. Чтобы визуально в программе отделить указатели на RAM строки от PGM строк, используют тип PGM_P
- это просто синоним const char*
:
PGM_P p = pstr; // pstr из примера выше
Суффикс _P #
Строка в PROGMEM может передаваться в фукнции как const char*
, что может привести к сбою в программе: функция будет ожидать строку из оперативной памяти, а ей отправят из PROGMEM. Чтобы разрешить эту неоднозначность, договорились добавлять к именам функций, ожидающих данные из PROGMEM, суффикс _P
: например strlen
и strlen_P
. Во многих Arduino библиотеках также есть функции и методы с суффиксом _P
.
Для работы с PGM строками точно так же есть аналоги строковых функций, полный список см. в справочнике. Например измерим длину строки и запишем её в буфер в оперативной памяти:
// pstr из примера выше
char buf[strlen_P(pstr) + 1]; // буфер для строки
strcpy_P(buf, pstr); // копировать из PGM в RAM буфер
// buf == "pgm string"
Буфер для строки всегда должен быть на 1 длиннее, чем строка (для нулевого символа)!
Локальные и PSTR() строки #
PGM строку можно создать и локально, сделав её статической:
{
static const char pstr[] PROGMEM = "pgm string";
char buf[strlen_P(pstr) + 1];
strcpy_P(buf, pstr);
// buf == "pgm string"
}
Это типовая конструкция, для которой существует макрос PSTR(s)
- делает по сути то же самое и возвращает указатель на строку. Адрес строки нужно сохранить в указатель, чтобы им пользоваться:
PGM_P p = PSTR("pstr string");
strlen_P(p); // длина 11
strcmp_P("pstr string", p); // сравнили, результат 0
Макрос сделан так, что его можно и подавать напрямую в _P
функции:
char buf[20];
strcpy_P(buf, PSTR("pgm string"));
// buf == "pgm string"
Есть нюанс - PGM строки не оптимизируются компилятором, как обычные литералы, что логично - это же сразу данные в памяти. Одна и та же строка будет продублирована столько раз, сколько её вызовут с PROGMEM
или из PSTR()
:
static const char pstr[] PROGMEM = "pgm string"; // копия 1
char buf[strlen_P(PSTR("pgm string")) + 1]; // копия 2
strcpy_P(buf, PSTR("pgm string")); // копия 3
Так что если такая строка нужна несколько раз - объявляем глобально через PROGMEM
или локально с сохранением указателя в PGM_P
, как в примере выше.
PSTR()
строки могут быть созданы только локально внутри функции! Глобальный вызов приведёт к ошибке
PGM_P p1 = PSTR("str"); // ошибка компиляции
void foo() {
PGM_P p2 = PSTR("str"); // нужно так
}
Класс __FlashStringHelper #
В Arduino есть специальный класс-обёртка для PROGMEM строк - __FlashStringHelper
, позволяющий решить неоднозначность обычной перегрузкой без использования _P
функций. Ардуиновский же интерфейс Print
умеет печатать такие строки напрямую, без копирования их в оперативную память. Это позволяет печатать такие строки почти на все существующие дисплеи - библиотеки для них обычно поддерживают Print
. Arduino-строки String
также умеют создаваться из таких строк и прибавлять такие строки.
// Arduino Print.h
size_t print(const __FlashStringHelper *); // печать строк из PGM
size_t print(const char *); // печать строк из RAM
Так как это просто обёртка, достаточно преобразовать свою PGM строку к (const __FlashStringHelper*)
:
PGM_P pstr = PSTR("pstr string");
// это сломает программу! Чтение по адресу в другой области памяти
//Serial.println(pstr);
//String s = pstr;
Serial.println((const __FlashStringHelper*)pstr); // напечатает "pstr string"
String s = (const __FlashStringHelper*)pstr; // создаст строку с "pstr string"
// можно и так
const __FlashStringHelper* fstr = (const __FlashStringHelper*)PSTR("pstr string");
Serial.println(fstr); // напечатает "pstr string"
Макрос FPSTR() #
В ядре ESP8266 есть замечательный макрос FPSTR(s)
, который просто разворачивается в (const __FlashStringHelper*)s
, что сильно сокращает код. Почему такой макрос не завезли в официальный Arduino SDK - загадка. Но вы можете добавить его самостоятельно:
#define FPSTR(s) (const __FlashStringHelper*)(s)
const char pstr_g[] PROGMEM = "global pgm str";
void setup() {
Serial.begin(115200);
PGM_P pstr = PSTR("local pgm str");
Serial.println(FPSTR(pstr_g)); // напечатает "global pgm str"
Serial.println(FPSTR(pstr)); // напечатает "local pgm str"
Serial.println(FPSTR(PSTR("local pgm str")));
}
Макрос F() #
Также в Arduino есть удобный макрос F(s)
, который просто разворачивается в (const __FlashStringHelper*)PSTR(s)
- сразу закидывает строку в PGM и преобразует к обёртке для вывода. По сути как раз то, что мы писали руками в последней строке предыдущего примера. Это позволяет сразу выводить такие строки из PGM памяти туда, где они поддерживаются:
Serial.println(F("F-str"));
String s = F("fstring");
Всё верно, добавление всего лишь одной буквы помещает строку в программную память и она больше не занимает места в оперативной!
Если строка нужна несколько раз в рамках одного блока кода - можно сделать на неё указатель:
const __FlashStringHelper* fstr = F("fstring");
Serial.println(fstr);
String s(fstr);
s += fstr;
// s == "fstringfstring"
Область существования #
Несмотря на то, что F()
и PSTR()
строки можно создавать только локально внутри функции - текстовые данные существуют в памяти по тому же адресу даже при отключении питания МК. Это означает, что можно абсолютно безопасно возвращать F- и PSTR-строки из функций и использовать в другом месте программы. Например так:
const __FlashStringHelper* getFstr() {
return F("hello");
}
void foo() {
Serial.println(getFstr());
}
Массивы строк #
Ещё одно частое применение PROGMEM - массив строк: для текстового меню, списка с названиями режимов и так далее. Его можно организовать несколькими способами, давайте рассмотрим большинство из них.
Локальный массив #
Самый простой вариант - локальный массив указателей на F- или PSTR-строки, т.к. запись очень лаконичная, а строки могут быть разной длины:
// PSTR()
PGM_P pgm_pstr_arr[] = {
PSTR("pstr 0"),
PSTR("pstr 11"),
PSTR("pstr 222"),
};
// количество строк (по размеру указателя на платформе)
int len = sizeof(pgm_pstr_arr) / sizeof(void*);
for (int i = 0; i < len; i++) {
Serial.println(FPSTR(pgm_pstr_arr[i]));
Serial.println(strlen_P(pgm_pstr_arr[i]));
}
// F()
const __FlashStringHelper* fstr_arr[] = {
F("fstr 0"),
F("fstr 11"),
F("fstr 222"),
};
// количество строк (по размеру указателя на платформе)
int len = sizeof(fstr_arr) / sizeof(void*);
for (int i = 0; i < len; i++) {
Serial.println(fstr_arr[i]);
}
F
-строки удобны для вывода напрямую в print или работы с ArduinoString
PGM_P
-строки удобны для стандартных_P
-функций работы с PGM строками
Глобальный массив #
Тут есть два варианта, как с двухмерными массивами из предыдущей главы: сделать обычный двухмерный массив с фиксированным размером для строк, либо отдельные массивы строк, а затем массив указателей на них.
Двухмерный массив #
// обязательно указать размер самой большой строки +1
const char pstr_arr[][14] PROGMEM = {
"some string 0",
"string 1",
"kek",
};
sizeof(pstr_arr); // == 42, общий размер
sizeof(pstr_arr[0]); // == 14, буфер строки
int len = sizeof(pstr_arr) / sizeof(pstr_arr[0]);
// len == 3, кол-во строк
for (int i = 0; i < len; i++) {
Serial.println(FPSTR(pstr_arr[i])); // напечатаем
// перепишем в буфер и напечатаем
char buf[strlen_P(pstr_arr[i]) + 1];
strcpy_P(buf, pstr_arr[i]);
Serial.println(buf);
}
Плюсы:
- Компактная запись
- Удобный доступ
Минусы:
- Строки занимают одинаковый объём памяти, указанный в
[]
- независимо от своей длины
Массив указателей #
// сами строки
const char pstr0[] PROGMEM = "some string 0";
const char pstr1[] PROGMEM = "string 1";
const char pstr2[] PROGMEM = "kek";
// массив указателей тоже в PROGMEM, чтобы не занимали места
const char* const pstr_arr[] PROGMEM = {pstr0, pstr1, pstr2};
int len = sizeof(pstr_arr) / sizeof(void*);
// len == 3, кол-во строк
for (int i = 0; i < len; i++) {
// читаем адрес строки (указатель) из PROGMEM
PGM_P pstr = (PGM_P)pgm_read_ptr(&pstr_arr[i]);
//PGM_P pstr = (PGM_P)pgm_read_ptr(pstr_arr + i); // или так
Serial.println(FPSTR(pstr)); // напечатаем
// перепишем в буфер и напечатаем
char buf[strlen_P(pstr) + 1];
strcpy_P(buf, pstr);
Serial.println(buf);
}
Плюсы:
- Строки хранятся отдельно друг от друга и занимают каждая свой размер
Минусы:
- Довольно многословная запись, но можно обернуть в макросы
- Каждая строка занимает на 1 размер указателя (2/4 байта) больше места
Составные данные #
Атрибутом PROGMEM можно пометить любые данные, например структуру. Рассмотрим пример с записью и чтением:
struct Data {
int16_t val;
uint8_t arr[3];
char str[10];
};
const Data pdata PROGMEM = {
-123,
{1, 2, 3},
"Hello",
};
Serial.println((int16_t)pgm_read_word(&pdata.val)); // -123
Serial.println(pgm_read_byte(&pdata.arr[0])); // 1
Serial.println(pgm_read_byte(&pdata.arr[1])); // 2
Serial.println(pgm_read_byte(&pdata.arr[2])); // 3
Serial.println(FPSTR(pdata.str)); // Hello
Помимо pgm_read_xxx
функций и работы со строками можно использовать memcpy_P
для чтения данных из PGM в RAM, например можно просто взять и создать локальную копию экземпляра структуры из PGM:
Data data;
memcpy_P(&data, &pdata, sizeof(Data)); // копируем
Serial.println(data.val); // -123
Serial.println(data.arr[0]); // 1
Serial.println(data.arr[1]); // 2
Serial.println(data.arr[2]); // 3
Serial.println(data.str); // Hello
Точно так же можно создавать массивы структур и в целом комбинировать данные любым удобным образом.
Библиотека pgm_utils #
Использование PROGMEM и стандартной библиотеки на Си довольно многословно. Я написал свою "обёртку" для PROGMEM - библиотеку pgm_utils с использованием инструментов C++, она сильно сокращает код и делает его более читаемым. Вот некоторые примеры использования:
// одиночные данные
PGM_VAL(int, vali, 123);
PGM_VAL(float, valf, 3.14);
struct Test {
byte i;
char str[10];
};
PGM_STRUCT(Test, ptest, 10, "test");
void test() {
// чтение, авто-тип
Serial.println(pgm_read(&vali)); // 123
Serial.println(pgm_read(&valf)); // 3.14
// копирование структуры из PGM
Test t = pgm_read(&ptest);
Serial.println(t.i); // 10
Serial.println(t.str); // test
}
// массивы
PGM_ARRAY_OBJ(float, parr, 1.12, 2.34, 3.45);
void test() {
for (int i = 0; i < parr.length(); i++) {
Serial.println(parr[1]); // 1.12, 2.34, 3.45
}
}
// массивы строк
PGM_STR_LIST_OBJ(strlist, "str1", "str2", "str3");
void test() {
for (int i = 0; i < strlist.length(); i++) {
Serial.println(strlist[i]); // str1, str2, str3
}
}
Скорость работы #
За удобство и лёгкий вес приходится платить - согласно моим тестам (на AVR), чтение из PROGMEM через memcpy_P
медленнее, чем из RAM, причём чем больше объём памяти - тем меньше разница, но она стремится к 30%:
- 1 байт - 4 такта из RAM и 30 из PGM (750%)
- 10 байт - 74 такта из RAM и 111 из PGM (150%)
- 25 байт - 179 тактов из RAM и 245 из PGM (37%)
- 50 байт - 354 такта из RAM и 471 из PGM (33%)
- 100 байт - 704 такта из RAM и 921 из PGM (31%)
- 200 байт - 1404 такта из RAM и 1821 из PGM (30%)
1 такт в данном случае 0.0625 мкс (ATMega328p 16MHz)
В то же время, побайтное чтение массива через pgm_read_byte
оказалось на 17% быстрее из PROGMEM, чем из RAM.