Найти в Дзене

Память контроллера

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

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

Обзор

В целом, самое главное, что должно быть у любого уважающего себя контроллера — это память программ. То место, куда записаны инструкции, которые он должен исполнять и какие-то, может, дополнительные данные: таблицы, картинки, тексты. Память эта обычно записывается программатором и в процессе выполнения не меняется. В любой момент времени там всегда одни и те же данные.

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

У очень маленьких контроллеров (вроде attiny10) её может и не быть вовсе.

Иногда встречается память для хранения настроек (обычно типа EEPROM), очень небольшого размера. Все значения, которые туда записаны, сохраняются и без питания. Обычно запись может идти по одному байту, без необходимости стирания целого блока. В контроллерах серии AVR, например, она почти всегда есть, а в STM32 её нет. Ресурс перезаписей ограничен.

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

Это вся память, которая доступна ядру напрямую без каких-то особых ухищрений. В серьёзных контроллерах встречается ещё контроллер памяти, с помощью которого можно подключать внешние микросхемы разных типов: параллельный NOR, параллельный NAND, последовательный spi nand, QuadSPI, SRAM, SDRAM и дальше в зависимости от крутости контроллера.

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

И ещё один момент: память MMC/SD, для которой, бывает, предусматривают специальный модуль с аппаратной реализацией протокола SDIO.

Архитектура контроллера

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

1. Архитектура фон Неймана. Память программ и данных находятся в одном адресном пространстве и имеют идентичные методы доступа к ним. То есть прочитать какую-нибудь таблицу из памяти программ и из оперативной памяти можно одним и тем же кодом, просто поменяв там адрес. Это все контроллеры ARM, stm8, MSP430 и др.

2. Гарвардская архитектура. Память программ и память данных имеют свои адресные пространства и свои методы доступа к ним. Нельзя одним и тем же кодом прочитать данные и из памяти программ, и из оперативной памяти. Требуется их вычитывать с помощью специальных инструкций и механизмов ядра. Это контроллеры AVR, PIC, 8051 и др.

Второй важный момент: разрядность ядра и памяти. Восьмибитные контроллеры (AVR, PIC) для оперативной памяти имеют 8-битную шину. То есть чтобы вычитать 32-битное число, нужно четыре операции чтения. В том же арме на это уйдёт всего лишь одна операция.

Память программ

Память программ может быть внешней, может быть и внутренней. Внешняя — это или для мамонтов тридцати-сорокалетней давности, или для контроллеров и процессоров, где память должна быть огромной, какая-нибудь параллельная NOR/NAND на десятки-сотни мегабайт или даже гигабайты, как во всяких мобильных процессорах. Впрочем, процессоры могут запускаться и со всяких экзотических устройств типа MMC/SD-карт.

Внутренняя память более обычное дело. Объём небольшой: от какого-нибудь килобайта до пары мегабайт.

У современных контроллеров бывает встроенный механизм её программной перезаписи изнутри. Всякие армы, авры и прочие похожие могут обновлять свою программу или какие-то данные в ней самостоятельно. В таких контроллерах, бывает, предусматривают дополнительный блок памяти или регион памяти для специальной программы-бутлоадера, которая может запустится вместо основной и записать основную программу по командам снаружи, без необходимости использовать программатор. И это даже может быть выполнено через USB, если, конечно, этот протокол поддерживается =) Бутлоадер, в общем-то, от обычной программы ничем не отличается, кроме разве что специального назначения и возможности запуститься вместо обычной.

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

Это механизм можно использовать и для записи настроек, хотя это и не очень удобно: память программ обычно стирается большими блоками, размером в несколько килобайт, и чтобы поменять один байт надо сохранить и перезаписать весь блок. А ресурс перезаписей тратится у всего блока! Да ещё и может записаться не так. Всегда надо проверять, то ли записалось, что хотелось.

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

Ресурс обычно 10-100 тысяч перезаписей, хотя в дешёвых сериях встречается и ресурс всего в сотню гарантированных перезаписей.

Для контроллеров с архитектурой фон Неймана чтение будет выглядеть так:

uint8_t from_pgm = *(uint8_t)pointer_to_program_data;
...
const char text[] = "Hello world";
puts(&text); // это сработает!

В фон-нейманоской архитектуре константа text будет записана, скорее всего, в память программ и прочитана оттуда же.

Для гарвардской архитектуры такой же код сработает, но это будет трюком компилятора. Он просто подгрузит при запуске этот текст из памяти программ в оперативную память. А если памяти мало и надо экономить... то тогда используем специальные функции, которые берут данные напрямую из памяти программ. Например, в AVR это будет выглядеть так:

#include <avr/pgmspace.h>
PROGMEM
const char text[] = "Hello world";
puts_P(&text); // Это сработает!
puts(&text); // А это нет!

Внутри же там, если делать самостоятельно, будет что-то вроде:

void puts_P(PROGMEM const char * text) {
char C = pgm_read_byte(text++); // специальная функция из
avr/pgmspace.h, которая умеет читать память программ
while(C) {
putc(C);
C = pgm_read_byte(text++);
}
}

Это очень неудобно с точки зрения программиста, но приходится выкручиваться.

Запись в эту память происходит очень по-разному. В STM32 надо в специальном модуле настроить режим записи: по байту ли, по два ли байта, или по четыре сразу, скорость доступа и разрешить запись. а дальше запись идёт так же, как в оперативку, пока не будет снят флаг разрешения записи. А модуль будет перехватывать команды записи и менять содержимое памяти. При этом, при записи, можно записывать нули (как если бы запись была такой: *olddata = *olddata & newdata). Записать единицу не получится. Это обычно для flash-памяти. Потому сначала производят стирание блока: все ячейки будут инициализированы единицами (0xFF). В каком-нибудь LPC22хх будет специальный кусок кода, зашитый намертво где-то внутри, и дающий программное апи для работы с памятью: то есть там заполняется специальная структура: команда на стирание, чтение, запись, проверку и т.д. и диапазон адресов откуда и куда. И он выполняет и отчитывается, получилось или нет.

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

Оперативная память

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

Если у контроллера фон неймановская архитектура, то может существовать возможность выполнения кода и из оперативной памяти. Например, если требуется максимальная производительность или проводится обновление ПО. Компиляторы, кстати, поддерживают эту фишку и могут скомпилировать код так, чтобы загрузчик разместил его часть в оперативке при инициализации оперативной памяти. Впрочем, это можно сделать и вручную.

У контроллеров AVR первые байты памяти соотносятся с регистрами общего назначения.

Блоков памяти может быть несколько. У контроллеров STM32 из производительных серий, например, имеется два блока оперативной памяти, одна побыстрее, но поменьше, другая побольше, но помедленнее. Да ещё через контроллер памяти можно подключать и внешние микросхемы, образуя третий-четвёртый-пятый регионы по необходимости.

Работа с этой памятью вполне себе обычна для C и C++. Указатели, все дела.

Память настроек

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

Очень удобно, если она есть. Если нет, придётся что-то выдумывать.

Ресурс обычно 100-1000 тысяч перезаписей.

В каком-нибудь AVR работа с памятью будет выглядеть как:

#include <avr/eeprom.h> // Там объявлены функции для работы с памятью настроек
uint8_t data = eeprom_read_byte((uint8_t*)0x12); // читаем байт из адреса 12 памяти настроек
eeprom_write_byte((uint8_t*)0x12, 0x55); // обновим

Внешняя память по интерфейсам памяти

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

Объём обычно велик, десятки и сотни мегабайт, иначе нет смысла заморачиваться с этим, скорость большая, но всё же до скорости ядра обычно не дотягивает. Выполнение программ из этой памяти будет не самым быстрым действом.

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

Она не доступна с момента запуска и требуется настраивать контроллер памяти, чтобы этот доступ всё же появился: какой тип памяти, ширина линий адреса/данных, отдельные ли они, как синхронизировать и т.д.

Работа с динамической памятью (SRAM/SDRAM) после подключения не отличается от работы с оперативной. Работа со статической памятью (NAND/NOR) не отличается на чтение от обычной памяти программ, но с записью надо соблюсти ритуал, прописанный в даташите на микросхему памяти. Например, запись в NOR (условно):

#define BASE_NOR_ADDRESS 0x60000000
// Перед этим память стёрли
// Сначала пляски с бубном
*(
BASE_NOR_ADDRESS + 0xAAA) = 0xAA;
*(BASE_NOR_ADDRESS + 0x555) = 0x55;
*(BASE_NOR_ADDRESS + 0xAAA) = 0xA0;
// А потом пишем!

for(size_t i = 0; i < size; i++) {
*(BASE_NOR_ADDRESS + address + i) = data[i];
}

Внешняя память по стандартным интерфейсам

Обычно микросхемы памяти для настроек имеют интерфейс или SPI, или I2C. В микросхемах SPI общение выглядит как "команда-данные (чтение или запись)", протокол надо уточнять в даташите, то в I2C это просто чтение или запись штатными средствами протокола. То есть регистры памяти замаплены на регистры I2C. Из различий может быть только размер адреса, который записывается перед операцией чтения/записи: один, два или три байта.

Довольно часто используется как память настроек в контроллерах, где нет встроенной памяти.

Обычно такая память выполняется по технологии EEPROM, то есть имеет все те же недостатки: ресурс 100-1000 записей, низкую скорость записи, блочное стирание. В еепром-микросхемах с индивидуальной перезаписью байт, запись одного байта может занимать несколько миллисекунд. Жуть как долго. В SPI это может быть и побыстрее, за это время можно успеть записать целый блок, но и стирать там надо тоже блоками, нельзя взять и перезаписать один байт. Хотя подробности лучше смотреть для каждой микросхемы отдельно. В общем, медленно и печально, но зато стоит копейки. То есть можно найти микросхемы за пару-тройку рублей.

Есть серия памяти по технологии FRAM, там ресурс в несколько миллиардов перезаписей, высокая надёжность, индивидуальная перезапись каждого байта и быстрота. Правда, объём и цена там такие себе.

Естественно, такая память ядру не видна, код из неё не может выполняться, да и указатель на такую память не получишь. Надо байт за байтом её копировать с помощью приёмопередатчика в память. Или обратно.

Пример записи данных в SPI-память (условно):

void write_memory(uint32_t address, const uint8_t * data, uint32_t length) {
MemCS.Low(); // Опустим линию выбора микросхемы
spi_write(MEM_CODE_WRITE);
spi_write(address & 0xFF); // младший байт адреса
spi_write((address >> 8) &0xFF); // средний байт адреса
spi_write((address >> 16) & 0xFF); // старший байт адреса
for(size_t i = 0; i < length; i++) { // а теперь данные
spi_write(*data++);
}
MemCS.High(); // Сеанс окончен, линию выбора микросхемы в высокий уровень
}

Регистры периферии

Раз уж затронули тему памяти, то рассмотрим и ещё один момент: обычно все модули, какие есть в процессоре или контроллере, имеют выход в память, через которую они и управляются. То есть каждый модуль может иметь несколько десятков байт в адресном пространстве, где можно прочитать его состояние и записать какие-то команды.

Например, запись в регистр данных приёмопередатчика инициирует процесс отправки этого байта по протоколу с заданными настройками. Или же данные появятся в приёмном регистре после получения посылки, откуда их можно прочитать.

Например, условный модуль контроля выводов микросхемы:

Регистр GPDIR, адрес 0x41000000, 16 бит, отвечает за направление работы выводов номерами с 0 до 15. Бит в состоянии 0 означает вход, состояние 1 означает выход.
Регистр GPSET, адрес 0x41000002, 16 бит, отвечает за работу выводов в режиме выхода. Состояние 0 означает низкий уровень на ножке, состояние 1 высокий. Для выводов в режиме входа значение игнорируется.
Регистр GPGET, адрес 0x41000004, 16 бит, отвечает за работу выводов в режиме входа, показывает логический уровень на ножке. Установлен в 0, если на ножке низкий уровень, установлен в 1, если высокий. Запись игнорирует. Для ножек в режиме вывода дублирует значение из регистра GPSET.

И примерный код для такого описания:

// Перевели в режим выхода вывод номер 7.
*((volatile uint16_t*)0x41000000) |= 1 << 7;
// Установили его состояние в низкий уровень
*((volatile uint16_t*)0x41000002) &= ~(1 << 7);
// Прочитали состояние вывода номер 6.
bool pin6_state = (*((volatile uint16_t*)0x41000004) & (1 << 6)) != 0;

Обратите внимание на использование ключевого слова volatile при работе с регистрами периферии. Оно запрещает буферизацию значения, так как значение может поменяться в любой момент.

Модуль прямой работы с памятью

В контроллерах посерьёзнее часто включается специальный модуль, имеющий часто свой выделенный доступ к разным шинам памяти, который умеет память копировать. Из одного места в другое. Он может копировать тупо от забора и до обеда, может копировать по сигналу: пришёл байт, отсчитал таймер нужное время, и т.д. И тем самым может освобождать ядро от кучи задач, особенно, если на них стоит реагировать сразу и выдерживать точное время. Например, хотим мы измерить напряжение в сети через делитель. Говорим модулю копирования: доставай данные из модуля АЦП и складывай их по сигналу таймера (с шагом 10 мкс) в участок памяти такой-то с такой-то длиной. И всё, что остаётся сделать после запуска модуля — дождаться завершения, занимаясь своими делами. Данные будут лежать и ждать обработки.

Заключение

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

Постарайтесь понять особенности работы с каждой доступной памятью вашего микроконтроллера, это недолго, и сбережёт вам много времени и нервных клеток в перспективе.