Как всё устроено "внутрях".
Данный опус меня побудило написать приобретение новых друзей, которые хотят изучать STM. Есть много разных хороших книг, но многие из них сразу начинают рассказывать, как всё это программируется, и не касаются внутренней структуры самого МК. Можно найти отдельных авторов, которые небольшими статьями пытаются объяснить, как все устроено. Но приходится всё это систематизировать и проверять - что недоступно новичку. Встречаются откровенные бракоделы, которые работают над статьями только ради просмотров и лайков. Не буду здесь их называть, но многие попадали на сайт, где в 20-минутном ролике 15 минут идёт пустая болтовня, а затем 5 минут информации, которая уже не воспринимается, и практически 90% кода толком не работает.
Я не являюсь крутым программистом. Нас натаскивали на железо и на то, как это всё устроено, поэтому постараюсь без заумных слов объяснять всё на пальцах. В этой заметке мы разберёмся с адресацией памяти и адресацией периферии.
Некоторая терминология, которая мне привычнее, да и пишется короче без переключения языка ввода:
- Flash - ПЗУ или память программ (правильнее было бы Электрически Стираемое Постоянное Запоминающее Устройство, но это к технологам, мы же будем просто программировать). При отключении питания данные сохраняются.
- SRAM - оперативная память ОЗУ, где хранятся переменные и всякая лабуда. При отключении питания данные теряются.
На данный момент существуют два основных подхода к архитектуре (остальные нас пока не интересуют):
- Гарвардская.
- Фон Неймана.
Гарвардская архитектура.
Характерна тем, что адресное пространство ПЗУ для программы, ОЗУ для данных, периферийные устройства имеют разные адресные пространства и адресные шины. Чтобы обратиться к ним, нужно указывать (в специальном регистре, либо это может происходить автоматически), к чему в данный момент вы собираетесь обратиться. На схеме структура МК Atmel:
Видно, что у CPU для работы с ОЗУ, ПЗУ и периферией имеются разные адресные шины, даже, вроде, шина данных у них не общая. На этой схеме структура 8-разрядных PIC контроллеров:
Как видно по схеме, ALU не имеет прямого доступа практически ни к чему. При общей шине данных доступ в адресное пространство ОЗУ идёт через регистр SFR. Доступ к адресам периферии на блок-схеме почему-то не показан, но там ещё тот винегрет. Доступ к адресации Flash идёт через регистр PC, стек вообще отдельный кусок памяти. Я с Atmel на уровне регистров не развлекался, так как, увидев их ассемблер, пришёл в ужас и решил не связываться. Вот для PIC я много писал на ассемблере, поэтому знаю всю эту кухню. Не на 146% конечно, но достаточно.
Адресация памяти всегда начинается с нуля. Сама периферия, ПЗУ или ОЗУ в структуре памяти могут находиться где угодно. Это зависит от производителя, посмотрим распределение памяти у PIC:
Так-то, по уму, принято память рисовать снизу вверх, но здесь почему то сверху.
Что мы можем здесь видеть? Память у нас разбита на несколько страниц, полная адресация памяти в данном случае от 0х000 до 0х7FF. В данном случае ПЗУ у нас 2 килобайта. Память программ разбивается на страницы по 512 байт, поэтому при вызове подпрограмм и при переходах на метки, расположенные в другой странице памяти, необходимо предварительно модифицировать регистр PCLATH. Перед каждой командой типа "call" или "goto" трогать PCLATH не обязательно, а вот перед вызовом табличного чтения об этом нужно позаботиться. Таблицы констант можно размещать в любом участке памяти, лишь бы они не пересекали границу в 0xNff.
Самый прикол идёт дальше. У нас есть стек - сделан на отдельных регистрах и программисту недоступен. На этом МК он... двухуровневый. ДВУХУРОВНЕВЫЙ, Карл. Т. е. вложенность команды перехода на подпрограмму равна двум. Попытаешься из второй подпрограммы вызвать третью, всё, сливай воду. Так как язык С на стеке хранит адрес возврата и переменные, вы можете забыть на этом контроллере про C и C++. Только АСМ, только хардкор, правда С для таких слабых контроллеров устроен по-другому и позволяет при восьмиуровневом стеке писать довольно складно, но не будем углубляться в эту тему, нам не до этого.
Теперь посмотрим распределение памяти под периферию:
Как мы можем видеть, здесь адресация опять же с нуля. Но мало того, она ещё и разбита на 4 банка:
И то, к какому периферийному устройству вы обратитесь, зависит от адреса и регистра FSR. У этого чипа периферия довольно бедная, но представим себе, если по адресу 0х01 в первом банке находится таймер TMR0, а во втором банке по этому же адресу находится TMR1. Вам придётся для обращения к TMR0 в регистр FSR записать 0х00 и обратиться по адресу 0х01. А для обращения к TMR1 в SFR записать 0х01 и опять же обратиться по адресу 0х01 или 0х21. Если в SFR записано 0х00, вы будете находится в нулевом банке, и обращение по адресу 0х21 не приведёт к обращению к TMR1, хоть тресни. Зачем так сделано - не знаю.
Получается, обращаясь по адресу 0х01 мы можем обратиться к ПЗУ, ОЗУ или периферии. Куда мы на самом деле попадём, зависит от специальных регистров.
Архитектура фон Неймана.
Вот в данной архитектуре ОЗУ, ПЗУ и периферия находятся в едином адресном пространстве, и нам не нужно отвлекаться и щёлкать специальными регистрами. А стек хранится в ОЗУ, там, где пожелает программист, а из-за того, что стек растёт от старшего адреса к младшему, мы можем его набивать пока он не встретится с данными, которые набиваются снизу вверх. Но это ещё умудриться нужно такое сотворить. Выходит вложенность вызова подпрограмм практически не ограничена. Это у нас распределение памяти у STM:
Взял самый дохлый, так как распределение памяти не забито лишним, и проще понять. Адресация у него идёт от 0х00000000 до 0хFFFFFFFF. Выше адреса 0х60000000 я ничего не показал, так там нет ничего интересного.
С адреса 0х00000000 по 0х1FFFFFFF находится блок CODE. С адреса 0х20000000 по 0х3FFFFFFF находится ОЗУ. С адреса 0х40000000 находится периферия. Я тут распинаюсь - по такому адресу находится ПЗУ, по такому ОЗУ, периферия там. А что это вообще значит? Представьте себе, что всё это находится не на кристалле, а у вас на столе, в виде микросхем и блоков.
ПЗУ - микросхема. Вы её проводочками присоединили к своему МК. МК имеет шину адреса и шину данных, аж по 32 вывода каждый. При низкоуровневом программировании вы на шину данных выставляете данные, адрес и с помощью специальных выводов говорите, что собираетесь делать, читать или писать. Но ПЗУ у нас, допустим, имеет только 16 выводов адреса. И, таким образом, если МК обратится по адресу 0х000010A5, то попадёт в ПЗУ, а если по адресу 0х012010A5, то опять в ПЗУ и в ту же ячейку, и мы не сможем адресовать другие устройства. Чтобы этого не происходило, у вас на столе лежит ещё одна железка - называется дешифратор адреса. Работает всё это следующим образом... Пусть у нас все устройства для простоты имеют по 16 адресных выводов от А0 до А15. Мы все их соединяем параллельно и подключаем к нашему МК. Кроме этого у каждого устройства есть вход CS (выбор кристалла, не правда ли на SPI похоже), нам осталось на адресные выводы А16 - А31 подключить дешифратор, и выходы дешифратора подключить к CS наших устройств. Теперь, несмотря на то, что А0-А15 объединены, обратившись по адресу 0х000010A5 мы попадём в ПЗУ, а по адресу 0х012010A5, допустим, в ОЗУ. При этом никаких регистров SFR, FSR и прочего у нас нет и не нужно их отслеживать.
Это легче понять тем, кто в своё время собирал Радио-РК86. Там это очень наглядно было. Было бы неплохо очень многим программистам, работающими с микроконтроллерами, собрать его самому ручками и написать пару-тройку программ на ассемблере.
Мало этого, периферия - это кусок железа, который тоже имеет свой адрес. Обратившись по определённому адресу, мы попадаем в специальные регистры периферии. Каждое периферийное устройство имеет свой адрес, и каждый регистр периферийного устройства тоже имеет свой адрес.
Продолжим про распределение памяти по вышеприведённой картинке. В даташите на этот МК есть более развёрнутая адресация, посмотрим блок CODE:
Если я ничего не путаю, этот МК при подаче питания стартует с адреса 0х00000000. Там находится программа, которая в зависимости от состояния входов BOOT микроконтроллера и некоторых других условий может запустить МК в нескольких режимах. Те, о которых я знаю:
- Старт программы с адреса 0х08000000, где у нас находится ПЗУ и наша прошивка;
- Загрузка по линии SW, куда мы подключаем наш программатор;
- Загрузка по JTAG;
- Загрузка по SPI;
- Загрузка по UART;
- Возможно что-то ещё...
По адресу 0х08000000 находится ПЗУ, по адресу 0х1FFFxx00 находится системная память (в зависимости от МК адрес может варьироваться), и с адреса 0х1FFFF800 находятся байты опций, которые обычно никто не трогает, если не хочется большего. Это типа фьюзов в Atmel, но в отличие от Atmel с ними проблем не бывает, если туда не лазить. Далее с адреса 0х20000000 находится ОЗУ, а выше 0х40000000 находится адресное пространство периферии.
ПЗУ и ОЗУ всегда находятся по этим адресам, по крайней мере у тех МК, с которыми я работал, они всегда там. А с периферией немного интересней. Она практически всегда находится в адресном пространстве, начинающемся с 0х40000000. Но на некоторых МК может быть в другом месте или может иметь дополнительные адреса.
Адресация периферии.
Рассмотрим это дело на примере GPIO - порта ввода/вывода как наиболее простого модуля:
Здесь у нас схема одного вывода практически любого порта. Мы видим кучу управляющих выводов, но на картинке нет самого главного - регистров порта. Что же это за зверь страшный? Да ничего особенного. Это 32-разрядная (иногда 16-ти или 8-ми) защёлка и больше ничего, она сидит и следит за нами по определённому адресу. Обратившись по этому адресу, мы просто записываем данные в эту защёлку, а она на соответствующем управляющем выводе выставит 0 или 1, включив или отключив какую-либо функцию. Например, запись в PUPDR значения 0х01 включит подтяжку выхода к питанию с помощью узла Pull Up (см. картинку). И так далее для всех других управляющих выводов. Рассмотрим на примере порта GPIOA у STM32F40x (для других МК базовый адрес может быть другим).
GPIOA имеет базовый адрес 0х40020000, т. е. обратившись по этому адресу мы попадём в порт А и никуда более. Порты GPIO имеют несколько регистров (рассматриваем не STM32F10x так как он имеет немного другую структуру):
Регистр |
Смещение |
Описание |
MODER |
0x00 |
Установка режима выхода: вход/выход, альтернативная функция, аналоговый вход. |
OTYPER |
0x04 |
Обычный выход или открытый. |
OSPEEDR |
0x08 |
Скорость нарастания сигнала на выходе или скорость порта. |
PUPDR |
0x0C |
Подтяжка порта к питанию или земле. |
IDR |
0x10 |
Читаем отсюда, в каком состоянии входы. |
ODR |
0x14 |
Пишем сюда, что хотим вывести. |
BSRR |
0x18 |
Регистр позволяет каждый выход установить в 1 или 0. |
LCKR |
0x1C |
Блокировка перенастройки выхода. |
AFRL |
0x20 |
Альтернативная функция PIN_0 - PIN_7. |
AFRH |
0x24 |
Альтернативная функция PIN_8 - PIN_15. |
BRR |
0x28 |
Сброс отдельного вывода (есть не у всех). |
Расписывать, что для чего - не буду, до меня многие разжёвывали, у нас разговор про адресацию. Итак, базовый адрес порта А 0х40020000, и смещения регистров этого порта отсчитываются относительно этого адреса. Что это означает? В CMSIS все эти адреса и смещения прописаны в виде define
(по мне - очень удобно). Мы в своей программе пишем:
GPIOA->OSPEEDR |= GPIO_OSPEEDER_OSPEEDR11;
Для программиста это структура GPIOA
, у элемента OSPEEDR
которой, мы устанавливаем в единицу те биты, которые выставлены в GPIO_OSPEEDER_OSPEEDR11
. Для железячника мы будем иметь в итоге такую последовательность:
- Читаем регистр периферии по адресу (0х40020000 + 0x08 = 0х40020008).
- Делаем операцию ИЛИ со считанными данными и со значением
GPIO_OSPEEDER_OSPEEDR11 (0х03 << 22)
, изменяя таким образом один бит. - Пишем полученные данные обратно в регистр.
При этом происходит следующее: у порта А в регистр OSPEEDR
, в ячейку, принадлежащую выводу PIN_11, запишется значение 0b10, что переведёт скорость этого вывода в режим FastSpeed. В принципе, вот и вся премудрость, для остальных регистров что-то подобное. Остаётся непонятным, почему именно такая запись? Всё просто, следущие две строки в принципе равнозначны:
GPIOA->OSPEEDR |= GPIO_OSPEEDER_OSPEEDR11;
GPIOA->OSPEEDR |= (0x03 << GPIO_OSPEEDR_OSPEED11_Pos);
- GPIOA - это как бы имя устройства.
- OSPEEDR - название регистра.
- Далее идёт операция, которую мы хотим выполнить.
- GPIO_OSPEEDER_OSPEEDR11 - в данном случае может принимать значения:
- GPIO_OSPEEDER_OSPEEDR11 = 0x03 << 22
- GPIO_OSPEEDER_OSPEEDR11_0 = 0x01 << 22
- GPIO_OSPEEDER_OSPEEDR11_1 = 0x02 << 22
Записи идентичны, потому что GPIO_OSPEEDER_OSPEEDR11_Pos = 22.
Почему именно так, а не иначе? Получается очень удобно, мы хотим обратиться к периферии, открываем reference manual на нужный нам МК. В заглавии имеем список периферии и присвоенные им имена: RCC - тактовый генератор, SPI - интерфейс SPI, USART - интерфейс USART и так далее. Затем пишем имя регистра и через какую-либо операцию говорим, что исправить в этом регистре. А повторение в операции над регистром имени периферии и регистра не позволит случайно ошибиться и присвоить, допустим, порту ввода-вывода изменение битов, характерных для тактового генератора, даже если они и одинаковы. Плюс к этому есть удобство при работе в CubeIDE. Если набрать "GPIOA->" и нажать "CTRL+Пробел", IDE выдаст список регистров для этой периферии. А набрав "GPIO_OSPEEDR_" и нажав "Ctrl+Пробел", мы получим список полей регистра, которые мы можем поменять. А далее компилятор из CMSIS возьмёт все описания регистров и поменяет их на конкретные адреса. И вам не нужно будет знать адреса регистров, периферии и остального. Вам достаточно разбираться в мнемонике описаний этих регистров.