Главная chevron_right Уроки chevron_right Программирование chevron_right Руководство по памяти Arduino

Руководство по памяти Arduino

Подробный разбор типов памяти в Arduino: Flash, SRAM и EEPROM — как устроены, как измерить использование и как оптимизировать код под ограниченные ресурсы.

Микроконтроллер (MCU) — это интегральная схема, созданная для выполнения конкретных задач. Он собирает данные из окружающей среды, обрабатывает их и формирует нужный результат. Сегодня микроконтроллеры встречаются буквально везде: в умных часах, электромобилях и даже на поверхности Марса.

Одна из ключевых частей любого микроконтроллера — это память. Память хранит информацию временно или постоянно и используется для самых разных целей. В этом руководстве мы разберём организацию памяти в микроконтроллерах Arduino, научимся измерять её использование и применять практические приёмы оптимизации.

Что такое память?

Блоки памяти — неотъемлемая часть современных встраиваемых систем. Блоки памяти — это полупроводниковые устройства, которые хранят и выдают информацию; центральный процессор (CPU) использует данные из памяти для выполнения конкретных задач.

Как показано на схемах ниже, память в микроконтроллерах обычно представляют в виде массивов. Массив памяти разбит на ячейки, каждая из которых имеет уникальный адрес. Информация хранится в виде двоичных разрядов (бит), как правило организованных в байты (8 бит).

Память в вычислительных системах бывает энергозависимой и энергонезависимой. Энергозависимая память хранит данные только пока система работает — при отключении питания всё стирается. Энергонезависимая память сохраняет данные даже после отключения питания.

Архитектуры памяти: краткий обзор

Архитектура компьютера — обширная тема. Здесь мы рассмотрим только то, что нужно для понимания организации памяти в микроконтроллерах Arduino.

В начале эпохи вычислительных машин сложились две основные архитектуры: фон Неймана и Гарвардская.

Архитектура фон Неймана

Архитектура фон Неймана, названная в честь математика и физика Джона фон Неймана, появилась в середине 1940-х годов. Её также называют Принстонской архитектурой. Главная особенность: данные и программные инструкции хранятся в одном общем блоке памяти.

Von Neumann architecture.

Архитектура фон Неймана: CPU обращается к единой памяти через одну шину данных.

Оба типа данных (программный код и переменные) доступны CPU через одну и ту же шину. Почти все цифровые компьютеры исторически строились именно по этому принципу.

Гарвардская архитектура

Гарвардская архитектура, названная в честь компьютера Harvard Mark I, также появилась в середине 1940-х. Её ключевая особенность — два отдельных блока памяти: один для хранения программных инструкций, другой для данных. CPU обращается к ним по разным шинам.

Harvard architecture.

Гарвардская архитектура: память команд и память данных разделены и доступны через независимые шины.

Современные архитектуры: гибридный подход

Современные вычислительные системы используют гибридные архитектуры, сочетающие достоинства обоих подходов.

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

Архитектуры плат Arduino

Платы Arduino строятся преимущественно на двух семействах микроконтроллеров: AVR® и ARM®. Микроконтроллеры AVR® основаны на Гарвардской архитектуре, тогда как ARM® может использовать как фон-неймановскую, так и Гарвардскую модели. В таблице ниже приведены архитектуры микроконтроллеров различных плат Arduino:

BoardMicrocontrollerFamilyArchitecture
UNO MiniATmega328PAVRHarvard
UNO Rev3ATmega328PAVRHarvard
UNO WiFi Rev2ATmega4809AVRHarvard
UNO Rev3 SMDATmega328PAVRHarvard
LeonardoATmega32u4AVRHarvard
Mega 2560 Rev3ATmega2560AVRHarvard
MicroATmega32u4AVRHarvard
ZeroATSAMD21G18Arm® Cortex®-M0+Von Neumann
Portenta H7STM32H747Arm® Cortex®-M4/M7Harvard
Nicla Sense MEnRF52832Arm® Cortex®-M4Harvard
Nano RP2040 ConnectRP2040Arm® Cortex®-M0+Von Neumann
MKR FOX 1200ATSAMD21G18Arm® Cortex®-M0+Von Neumann
MKR NB 1500ATSAMD21G18Arm® Cortex®-M0+Von Neumann
MKR Vidor 4000ATSAMD21G18Arm® Cortex®-M0+Von Neumann
MKR WiFi 1010ATSAMD21G18Arm® Cortex®-M0+Von Neumann
MKR ZeroATSAMD21G18Arm® Cortex®-M0+Von Neumann
MKR1000 WIFIATSAMW25H18Arm® Cortex®-M0+Von Neumann
MKR WAN 1300ATSAMD21G18Arm® Cortex®-M0+Von Neumann
MKR WAN 1310ATSAMD21G18Arm® Cortex®-M0+Von Neumann
NanoATmega328PAVRHarvard
Nano EveryATmega4809AVRHarvard
Nano 33 IoTATSAMD21G18Arm® Cortex®-M0+Von Neumann
Nano 33 BLEnRF52840Arm® Cortex®-M4Harvard
Nano 33 BLE SensenRF52840Arm® Cortex®-M4Harvard

Типы памяти

Вся память внутри микроконтроллера делится на два основных типа: RAM и ROM. RAM (Random-Access Memory) — энергозависимая память для хранения временных данных, например переменных программы. ROM (Read-Only Memory) — энергонезависимая память для постоянных данных, например прошивки.

В микроконтроллерах RAM и ROM организованы в три основные категории:

  • Flash
  • RAM
  • EEPROM

Flash

Flash-память — часть ROM микроконтроллера. Именно здесь хранится прошивка устройства. Возьмём для примера знаменитый скетч Blink.ino: при компиляции создаётся бинарный файл, который записывается во Flash-память Arduino. При включении платы скетч выполняется из Flash.

RAM

RAM в микроконтроллере — место хранения временных данных времени выполнения: переменных функций, буферов и т.д. Чаще всего в микроконтроллерах используется SRAM — статическая RAM на основе триггеров, хранящих по одному биту. Реже встречается DRAM.

EEPROM

EEPROM (Erasable Programmable Read-Only Memory) — также часть ROM. Технически Flash — это разновидность EEPROM. Главное отличие: EEPROM управляется на уровне байта (запись и стирание побайтово), тогда как Flash — на уровне блока.

Организация памяти в платах Arduino

Поскольку Arduino использует и AVR®, и ARM®, организация памяти в этих архитектурах различается. В AVR-архитектуре на базе Гарвардской модели память организована так, как показано на схеме ниже:

AVR memory map.

Карта памяти AVR: Flash, EEPROM и SRAM с разбивкой на секции.

SRAM в AVR-платах Arduino разделена на несколько секций:

  • Text
  • Data
  • BSS
  • Stack
  • Heap

Секция text содержит инструкции, загруженные во Flash; секция data — инициализированные переменные скетча; секция BSS — неинициализированные данные; секция stack — данные функций и прерываний; секция heap — переменные, создаваемые во время выполнения программы.

В гибридных ARM-архитектурах реализуется карта памяти с конфигурацией адресного пространства 32-, 36- или 40-бит в зависимости от требований SoC (SoC) с дополнительной DRAM. Карта памяти обеспечивает интерфейс с дизайном SoC, сохраняя основное управление системой на уровне высокоуровневого кода. Инструкции доступа к памяти позволяют управлять модулями прерываний и встроенной периферией из высокоуровневого кода. Всем этим управляет блок управления памятью (MMU).

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

Пример организации памяти в ARM-микроконтроллере (виртуальная и физическая) показан ниже:

Memory organization in ARM-based microcontrollers.

Организация памяти в ARM-микроконтроллерах: виртуальное и физическое адресные пространства.

Память ARM-микроконтроллера разделена на следующие секции:

  • Виртуальный адрес:
  • Kernel code and data
  • Application code and data
  • Физический адрес:
  • ROM
  • RAM
  • Flash
  • Peripherals

В таблице ниже приведена организация памяти для конкретных плат Arduino:

BoardMicrocontrollerFamilyArchitectureFlashSRAMEEPROM
UNO MiniATmega328PAVRHarvard32kB2kB1kB
UNO Rev3ATmega328PAVRHarvard32kB2kB1kB
UNO WiFi Rev2ATmega4809AVRHarvard48kB6kB256B
UNO Rev3 SMDATmega328PAVRHarvard32kB2kB1kB
LeonardoATmega32u4AVRHarvard32kB2.5kB1kB
Mega 2560 Rev3ATmega2560AVRHarvard256kB8kB4kB
MicroATmega32u4AVRHarvard32kB2.5kB1kB
ZeroATSAMD21G18Arm® Cortex®-M0+Von Neumann256kB32kB-
Portenta H7 (basic configuration)STM32H747Arm® Cortex®-M4/M7Harvard16MB8MB-
Nicla Sense MEnRF52832Arm® Cortex®-M4Harvard512kB64kB-
Nano RP2040 ConnectRP2040Arm® Cortex®-M0+Von Neumann-264kB-
MKR FOX 1200ATSAMD21G18Arm® Cortex®-M0+Von Neumann256kB32kB-
MKR NB 1500ATSAMD21G18Arm® Cortex®-M0+Von Neumann256kB32kB-
MKR Vidor 4000ATSAMD21G18Arm® Cortex®-M0+Von Neumann256kB32kB-
MKR WiFi 1010ATSAMD21G18Arm® Cortex®-M0+Von Neumann256kB32kB-
MKR ZeroATSAMD21G18Arm® Cortex®-M0+Von Neumann256kB32kB-
MKR1000 WIFIATSAMW25H18Arm® Cortex®-M0+Von Neumann256kB32kB-
MKR WAN 1300ATSAMD21G18Arm® Cortex®-M0+Von Neumann256kB32kB-
MKR WAN 1310ATSAMD21G18Arm® Cortex®-M0+Von Neumann256kB32kB-
NanoATmega328PAVRHarvard32kB2kB1kB
Nano EveryATmega4809AVRHarvard48kB6kB256B
Nano 33 IoTATSAMD21G18Arm® Cortex®-M0+Von Neumann256kB32kB-
Nano 33 BLEnRF52840Arm® Cortex®-M4Harvard1MB256kB-
Nano 33 BLE SensenRF52840Arm® Cortex®-M4Harvard1MB256kB-

Объём SDRAM и Flash-памяти в Pro-платах можно гибко настраивать. Подробности — в Pro site.

Измерение использования памяти в Arduino

Статистика использования памяти помогает понять, насколько эффективно организован код. Загрузка памяти — важный показатель при разработке, потому что ресурсы микроконтроллера конечны: программа должна работать без выхода на предельную загрузку памяти, иначе возникнут непредсказуемые сбои. Отслеживать можно как доступную RAM, так и оставшееся место во Flash.

Чтобы избежать проблем во время выполнения, система на микроконтроллере никогда не должна достигать максимального заполнения памяти.

Измерение Flash-памяти

Flash-память Arduino можно измерить прямо в Arduino IDE. Поскольку Flash хранит код приложения, IDE сообщает об использовании Flash через консоль компилятора — это позволяет разработчику видеть, сколько Flash уже занято.

Ниже показан вывод консоли компилятора для AVR-платы Arduino Nano:

Flash memory memory measurement in an AVR-based Arduino® board

Измерение Flash-памяти на плате Arduino Nano (AVR): IDE показывает занятый и доступный объём.

Вывод для ARM-платы Arduino MKR WAN 1310:

Flash memory memory measurement in an ARM-based Arduino® board

Измерение Flash-памяти на плате Arduino MKR WAN 1310 (ARM).

Вывод для ARM-платы Arduino Portenta H7:

Flash memory memory measurement in an ARM-based Arduino® board

Измерение Flash-памяти на плате Arduino Portenta H7 (ARM).

Обратите внимание: формат вывода компилятора отличается для AVR- и ARM-плат.

Измерение SRAM

Иногда код успешно компилируется и загружается, но плата зависает в произвольный момент. Чаще всего причина — нехватка SRAM или её чрезмерное потребление. Чтобы найти проблему, нужно понять, в каком месте кода память расходуется сверх нормы. Следующий пример позволяет измерить использование SRAM на AVR-платах Arduino:

void display_freeram() {
  Serial.print(F("- SRAM left: "));
  Serial.println(freeRam());
}

int freeRam() {
  extern int __heap_start,*__brkval;
  int v;
  return (int)&v - (__brkval == 0  
    ? (int)&__heap_start : (int) __brkval);  
}

Напомним, что секция heap хранит переменные, создаваемые во время выполнения. В коде используются:

  • __heap_start: начало секции heap.
  • __brkval: последний адрес памяти, используемый heap.

Для ARM-плат Arduino используется другой код:

extern "C" char* sbrk(int incr);

void display_freeram(){
  Serial.print(F("- SRAM left: "));
  Serial.println(freeRam());
}

int freeRam() {
  char top;
  return &top - reinterpret_cast<char*>(sbrk(0));
}

Приведённый выше код взят из библиотеки Arduino-MemoryFree Майкла П. Флага.

Измерение EEPROM

Для работы с EEPROM в Arduino IDE уже встроена стандартная библиотека. Библиотека EEPROM позволяет читать, записывать и стирать данные EEPROM. Пример записи и чтения одного байта с помощью функций write и read:

#include <EEPROM.h>

void setup() {
}

void loop {
  // Запись данных по указанному адресу памяти EEPROM 
  EEPROM.write(address, value);

  // Чтение данных по указанному адресу памяти EEPROM 
  EEPROM.read(address);
}

Чтобы полностью очистить EEPROM (записать нули во все ячейки):

#include <EEPROM.h>

void setup() {
}

void loop {
  for (int i = 0 ; i < EEPROM.length() ; i++) {
    // Очистка памяти EEPROM 
    EEPROM.write(i, 0);

}

Подробнее об управлении EEPROM — в this guide.

Оптимизация использования памяти в Arduino

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

Рассмотрим конкретные приёмы оптимизации.

Оптимизация Flash-памяти

Flash-память — самый очевидный кандидат на оптимизацию. Объём скомпилированного кода можно существенно сократить, обратив внимание на несколько деталей.

Удалите неиспользуемые ресурсы

Удалите неиспользуемые библиотеки и остатки кода: функции, которые больше не нужны, и «висящие» переменные. Это заметно уменьшит размер скомпилированного кода и упростит сборку.

Модульные задачи

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

Оптимизация SRAM

SRAM — вероятно, самый критичный ресурс в микроконтроллере. Нехватка SRAM — самая распространённая проблема с памятью в Arduino-проектах.

Обёртка String с помощью F()

Функции Serial.print() и Serial.println() по умолчанию используют пространство SRAM для хранения строковых литералов. Чтобы этого избежать, оборачивайте строки в обёртку F(). Например:

Serial.println(F("Something"));

Оборачивание строки Something в F() перемещает её во Flash-память, освобождая SRAM. Flash значительно объёмнее SRAM, поэтому хранить строки там выгоднее. Однако не злоупотребляйте Serial.print() и Serial.println() — Flash тоже не безгранична. Используйте обёртку там, где это действительно важно.

PROGMEM

SRAM занимают не только строки, но и глобальные переменные. Глобальные и статические переменные загружаются в SRAM и «выдавливают» секцию heap в сторону секции stack. Чем больше таких переменных — тем меньше свободной SRAM.

PROGMEM (Program Memory) позволяет хранить данные переменных во Flash-памяти — аналогично обёртке F(), но с одним недостатком: скорость чтения данных из Flash ниже, чем из RAM. Поэтому важно заранее решить, какие переменные критичны по скорости, а какие можно вынести во Flash.

Пример использования PROGMEM на AVR-плате Arduino:

#include <avr/pgmspace.h>

// Базовая структура PROGMEM 
const PROGMEM DataType Variable_Name[] = {var0, var1, var2 ...};

// Хранение беззнакового 16-битного целого числа
const PROGMEM uint16_t NumSet[] = {0, 1, 1, 2, 3, 5, 8 ...};

// Хранение char в PROGMEM
const char greetMessage[] PROGMEM = {"Something"};

Подробнее о PROGMEM — в Arduino Language Reference.

Для ARM-плат Arduino аналогичное решение реализуется с помощью static const над переменными:

static const int Variable = Data;

Особенности использования static на разных уровнях:

  • Уровень пространства имён (Namespace Level)
  • — Переменная явно или неявно объявляется статической. Если static указан явно — это явная статическая декларация; без него — неявная.

  • Уровень функции (Function Level)
  • — Если объявлено внутри static, данные любого применимого типа сохраняются между вызовами функции.

  • Уровень класса (Class Level)
  • — Объявление static означает, что данные разделяются между всеми экземплярами класса.

Отказ от динамического выделения памяти

Динамическое выделение памяти удобно, когда RAM достаточно. Но для микроконтроллеров, где каждый байт на счету, это опасная практика.

Динамическое выделение памяти вызывает фрагментацию heap. Фрагментированные области RAM не могут быть повторно использованы — это «мёртвые байты». Кроме того, освобождение памяти через free() не всегда уменьшает размер heap. Чтобы минимизировать фрагментацию, придерживайтесь следующих правил:

  • Отдавайте предпочтение stack, а не heap:
  • Память stack не фрагментируется и полностью освобождается при выходе из функции. Heap может не освободить память даже после явного указания. Используйте локальные переменные и избегайте вызовов malloc, calloc, realloc.
  • Сокращайте глобальные и статические данные (по возможности):
  • Пока программа работает, память под эти данные не освобождается. Константные данные занимают место постоянно.
  • Используйте короткие строки и литералы:
  • Один символ char занимает один байт SRAM. Чем короче строка — тем лучше. Используйте строки только там, где они действительно нужны.
  • Массивы тоже должны быть минимального размера. Если нужно изменить размер массива — переопределите его в коде. Это трудоёмко, но оправдано при малом числе небольших массивов. Для сложных случаев рассмотрите массив с ограниченным максимальным размером.

Функция reserve()

Если код работает со строками, размер которых меняется в процессе работы, используйте reserve(). Эта функция резервирует буфер и заранее выделяет память под строковую переменную, предотвращая фрагментацию памяти при изменении размера строки.

Пример использования reserve():

// String_Variable — переменная типа String
// Alloc_Size — объём предварительно выделяемой памяти в байтах, тип unsigned int
String_Variable.reserve(Alloc_Size);

Подробнее о функции reserve() — в Arduino Language Reference.

Управление размером буфера

Фоновые процессы тоже требуют памяти для работы. Размер буфера можно задавать вручную — это позволяет уменьшить потребление памяти. Представьте массив, под который выделено в три раза больше места, чем реально используется — это расточительство.

Рассмотрим пример: последовательная связь в Arduino. Serial — один из самых часто используемых сервисов в Arduino-проектах; работает через встроенную библиотеку Serial (внешние библиотеки могут эмулировать последовательную связь программно). Для работы последовательная связь выделяет буфер фиксированного размера. Если высокая скорость передачи данных не требуется, размер буфера можно уменьшить — это сэкономит SRAM. Для этого измените следующую строку в файле HardwareSerial.h, который находится в папке установки Arduino IDE:

#define SERIAL_TX_BUFFER_SIZE 64

#define SERIAL_RX_BUFFER_SIZE 64

Внешние библиотеки, как правило, тоже позволяют настраивать размеры буферов для оптимизации памяти.

Правильный выбор типов данных

Грамотное использование типов данных — основа хорошей архитектуры кода. Соблазнительно выбирать самый удобный тип, но важно учитывать, сколько памяти он занимает.

Типы данных существуют для корректного представления и обработки данных. Не злоупотребляйте «широкими» типами там, где достаточно «узких». Проектируйте и выделяйте память под реальные требования — это оставит запас для будущих задач.

Основные типы данных в Arduino:

TypeByte LengthRange of Values
boolean1Limited to logic true and false
char1-128 to 127
unsigned char10 to 255
byte10 to 255
int2-32,768 to 32,767
unsigned int20 to 65,535
word20 to 65,535
long4-2,147,483,648 to 2,147,483,647
unsigned long40 to 4,294,967,295
float4-3.4028235E+38 to 3.4028235E+38
double4-3.4028235E+38 to 3.4028235E+38

Оптимизация EEPROM

Оптимизация EEPROM обычно не требуется: данные, хранящиеся в EEPROM, не нуждаются во Flash как источнике. Важно помнить: не стоит разгружать SRAM за счёт EEPROM. Данные SRAM по природе своей энергозависимы, а перенос их в EEPROM (энергонезависимую память) означает, что они там «останутся навсегда».

Главное ограничение EEPROM — конечное число циклов записи. Операция read (чтение) не ограничена, но операция write (запись) обычно ограничена ~100 000 циклами. Поэтому:

  • Записывайте в EEPROM только действительно важные параметры, которые редко меняются.
  • Никогда не вызывайте write внутри цикла loop() — это быстро исчерпает ресурс записи.

Эмуляция EEPROM с помощью Flash

Flash-память тоже имеет ограниченный ресурс записи, как и EEPROM. Оба типа со временем теряют способность удерживать данные. EEPROM основана на NOR-памяти, Flash — на NAND, поэтому EEPROM дороже. EEPROM работает побайтово, Flash — блоками.

Иногда разработчику нужно использовать EEPROM как альтернативное хранилище, но из-за её ограничений это неудобно. Решение — эмулировать EEPROM с помощью Flash. Библиотека FlashStorage от Christian Maglie позволяет это сделать.

Библиотека FlashStorage позволяет использовать Flash как замену EEPROM. Но помните об ограничениях: Flash тоже имеет конечный ресурс write циклов. Функцию EEPROM.commit() нельзя вызывать внутри цикла — это быстро исчерпает ресурс Flash и приведёт к потере данных.

Типичные ошибки и практические замечания

  • Зависание без видимых причин — почти всегда признак нехватки SRAM. Измерьте свободную память с помощью приведённых выше примеров.
  • Строки в Serial.print() без F() — один из самых частых источников утечки SRAM. Всегда оборачивайте строковые литералы в F().
  • Избыток глобальных переменных — они занимают SRAM постоянно, даже когда не нужны. Переносите данные в локальные переменные или во Flash с помощью PROGMEM.
  • Запись в EEPROM в loop() — быстро убивает ресурс EEPROM. Записывайте только при реальном изменении данных.
  • Динамическое выделение памяти (malloc, new) — на микроконтроллерах ведёт к фрагментации heap. По возможности используйте статическое выделение.

Дополнительные материалы

Организация памяти в микроконтроллерных системах — обширная тема. Для углублённого изучения рекомендуем:

  • Документация по ядру 8-bit AVR® в Microchip® Developer help site — подробная информация о CPU AVR.
  • Документация по архитектуре ARM в documentation site — технические руководства по Cortex-M0+, Cortex-M4 и другим процессорам.

Список литературы

[1] S. F. Barrett and D. J. Pack, Microchip AVR® Microcontroller Primer: Programming and Interfacing, Third Edition, Morgan & Claypool, 2019.

[2] J. Y. Yiu, The Definitive Guide to Arm® Cortex®-M0 and Cortex-M0+ Processors, Second ed., Newnes, 2015.

[3] J. Yiu, The Definitive Guide to ARM® Cortex®-M3 and Cortex®-M4 Processors, Third ed., Newnes, 2014.