ARM Without Magic. Урок 1.0 Самое начало.

Небольшое предисловие.
Этот урок, как и последующие, написан прежде всего для новичков в прошивко-строении и не совсем новичков программировании. Для людей, которые хотят разобраться, как все устроено и почему работает именно так. После вдумчивого прочтения этого курса не должно остаться никаких вопросов, о том, что делает та или иная строка в любом файле, какие команды для сборки и отладки прошивки вызываются, для чего они нужны и как работают. Я постараюсь избавиться от ассемблерных вставок (в паре мест они будут, но не более), все что нужно знать – это язык программирования C на каком-то начальном уровне, сложные места постараюсь разъяснять. Ну и конечно для чтения документации потребуется хоть немного знать английский язык, к документации мы будем обращаться часто.
Я не буду особо заострять внимание на работу с блоками периферии, во-первых, потому что они все время разные, а во-вторых, по этой теме написаны тысячи статей и отснято тысячи бестолковых видеоуроков, что я вряд ли смогу сказать что-то новое и полезное. Я склоняюсь к тому, что лучший учебник по управлению светодиодом и отправке несколько байт через USART — это документация на соответствующий процессор. Она правильная и полная. В общем этакий экскурс в BareMetal для прикладных программистов. И основное отличие прошивки МК от прикладной программы в том, что все приходится делать самому. Инициализировать переменные, раскидывать секции по правильным адресам. Обычно это скрыто под капотом всяких IDE и их шаблонов, да так хорошо спрятано, что многие относятся к этим процессам как к магии: поставил волшебных галочек или произнес страшное заклятие — и прошивка собралась. Само по себе это не плохо, я тоже уже не первый год пользуюсь шаблоном для создания новых проектов, но мне нравится знать как оно работает и где подкрутить, чтобы получить требуемый результат.

Мы не будем привязываться к какой-то особенной среде разработки, мы не будем пользоваться генераторами проектов типа STM32 CubeMX, мы на первом этапе вообще прошивку руками соберем, без всяких систем сборок, и отлаживать будем тоже руками.

Ориентироваться я буду на процессоры STM32, так как с ними мне приходится работать чуть ли не каждый день, они дешевые и доступные. Из приборов для начала потребуется отладочная плата на процессоре STM32F103C8 (например Blue-pill) и отладчик ST-Link v2. Все это доступно для покупки на AliExpress за сущие копейки. Но все что будет написано довольно легко адаптировать к любому ARM Cortex-M процессору. Собственно цель курса в том, чтобы, используя полученные знания можно было без труда использовать любой доступный процессор.

Из софтварной части сегодня нам потребуется:
Компилятор и набор утилит arm-none-eabi-gcc
И отладчик Open On-Chip debugger
Так же, для его работы совместно с ST-Link v2 под OS Windows, а так же для удобной загрузки и просмотра прошивок потребуется установить STM32 ST-Link Utility

В дальнейшем потребуется система сборки cmake и сборщик make (подойдет из комплекта GnuWin32)
Все консольные утилиты (arm-none-eabi-gcc, cmake, make, openocd) для удобства нужно прописать в переменную PATH, так же в моем тулчейне cmake используется переменная среды ARM_TOOLCHAIN_PATH с путем до корневой директории arm-none-eabi-gcc

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

Итак, приступим.
В этом уроке мы напишем прошивку, которая:
* во-первых: соберется
* во-вторых: будет работать
* в-третьих: во время работы будет моргать светодиодом подключенным к PC13 на плате blue-pill

Для этого прежде всего нужно знать, как вообще работает Cortex-M процессор и как он читает прошивку, и из чего эта прошивка состоит.
Начнем по порядку, микроконтроллеры на Cortex-M состоят из (как ни странно) ядра Cortex-M, кучи периферийных блоков и нескольких регионов памяти. Между собой это связывается несколькими шинами команд и данных. Открываем RM0008 (STM32F1XX Reference Manual) на странице Memory and Bus architecture и выясняем обязательные для Cortex-M это:
* ICode – шина инструкций, по ней процессор получает команды.
* DCode – шина данных, по ней процессор получает данные.
* System – системная шина, выполняет всякий арбитраж доступа (устанавливает, кто сейчас главнее ядро, DMA или может быть какой-нибудь блок FMC) и переключает BusMatrix
А далее BusMatrix связывается с несколькими шинами в зависимости от типа чипа, для нашего чипа прежде всего интересны:
* AHB — Advanced High-performance Bus, как ясно из названия высокоскоростная шина
* APB1/APB2 — Advanced Peripheral Bus, шины которые подключены к периферийным блокам.
Вообще, пока эти знания нужны номинально, никаких действий от программиста для работы с ними не требуется, ядро само все доступы разрулит, но знать нужно. На некоторых чипах есть всякие оптимизации, которые иногда вызывают конфликты. А еще бывает, что часть памяти не подключена к DMA или ICode — это накладывает соответствующие ограничения. Об этом стоит знать заранее и не пугаться страшных диаграмм.

Посмотреть, что куда подключено всегда можно в листе данных (Datasheet), на странице Block diagram. На микроконтроллерах STM32 это довольно важная часть, поскольку тактирование (т.е. по сути включение) каждого периферийного блока включается отдельно. Сложного ничего в этом нет, но вот иметь ввиду нужно – по умолчанию абсолютно все тактовые сигналы выключены и работает только ядро.

Еще нам потребуется взглянуть на картинку Memory Mapping в datasheet на процессор, там указаны адреса разных сегментов памяти. Сегодня нам потребуется только регион FLASH с прошивкой, остальное пока не очень важно, но опять же для понимания магии смотрим на правую часть картинки и видим там адреса периферийных блоков, именно из них рождаются «магические цифры» вроде

#define GPIOC_BASE            (APB2PERIPH_BASE + 0x00001000U)

Чуть позже обратим на них более пристальное внимание.

Для загрузки ядру требуется только вектор прерываний, это самая главная структура данных и без неё ну никак не обойтись. По сути, это просто таблица, в которой лежат адреса функций вызываемых контроллером прерываний при соответствующем событии. Описание таблицы находится в разделе 10.1.2 Interrupt and exception vectors. Правда Reference Manual, как это принято в ST немного лукавит, по адресу 0x00 находится не Reserved поле, а очень даже конкретная и необходимая штука – адрес вершины стека. Ну и далее по списку адрес Reset, куда переходит процессор при каждой загрузке, NMI – немаскируемое (обязательное) прерывание, HardFault и еще целый воз адресов. Они понадобятся, но немного позже.

Важное замечание, его нужно запомнить: при возникновении какого-то события и, если включено соответствующее прерывание, контроллер прерываний читает адрес из этой таблицы и перескакивает на него. И все, никакой магии. В остальном просто кусок памяти, мы вольны делать с ним что хотим. Но есть особенность: на микроконтроллерах этот регион памяти 0x00-0x07ffffff является отражением (alias) на какой-то другой кусок памяти, который подключается в зависимости от конфигурации запуска.

Разберем и эту особенность, выясняем как загружается микроконтроллер, для этого открываем раздел Boot configuration в RM0008. В зависимости от пинов boot0 и boot1 есть несколько вариантов, нас сегодня (да и почти всегда) интересует загрузка с flash-памяти, для этого нога boot0 должна быть прижата к земле. При этом адреса флеш-памяти (0x800000) отражаются в начало адресного пространства (0x00).

Итак, если мы прижмем boot0 к земле и запустим процессор, что произойдет:
Flash-память отразится в начало адресного пространства
Ядро прочитает слово (4 байта) в соответствующий регистр указателя на вершину стека.
NVIC (контроллер прерываний) начнет отрабатывать вектор RESET, загрузив в регистр PC (Program Counter) адрес вектора Reset, который находится по адресу 0x00000004 и отдаст управление ядру
Ядро прочитает команду по адресу, на который указывает регистр PC и выполнит её, после чего следующую, следующую и так до бесконечности.

Будем контролировать этот процесс. По адресу 0x00 положим адрес конца памяти (стек растет вниз, поэтому мы туда запишем самый-самый дальний из возможных адресов), в 0x04 запишем адрес функции, которую процессор начнет бодро исполнять при загрузке. Для этого нам потребуется структура, с этими адресами и сама функция. Поскольку, мы договорились, то не будем использовать никакой магии, но подключать лишние заголовки мы пока не хотим, найдем нужные адреса, для моргания светодиодом нам потребуется адрес регистра RCC_APB2ENR – в нем мы будем включать тактирование порта GPIOC, а так же адрес регистра GPIOC_CRH – он ответственный за настройку пина 13 (на котором висит светодиод), и адрес регистра GPIOC_ODR через который можно управлять пином, при после настройки его как выход.
Такие вычисления мы будем проводить один раз, чтобы показать как это делается, в дальнейшем мы подключим заголовочные файлы CMSIS в которых все регистры и все биты этих регистров уже разложены по полочкам.

Ищем адрес регистра RCC_APB2ENR:
В разделе Memory Map листа данных находим адреса блока RCC: 0x4002_1000, далее идем в RM0008 в раздел RCC Registers и находим смещение регистра APB2ENR – 0x18, складываем получаем 0x4002_1018
Таким же образом ищем адреса регистров GPIOC_CRH: 0x4001_1000 + 0x04 = 0x4001_1004 и GPIOC_ODR = 0x4001_1000 + 0x0C = 0x4001_100C

Так же, в RM0008 подсмотрим биты, нужны нам для настройки и управления: за включение тактирования GPIOC отвечает бит 4 (IOPC), если выставлен – тактирование включено.

За конфигурацию порта C как выход отвечают биты 21 и 20 регистра CRH, для выхода Push-Pull на максимальной частоте нужно выставить оба этих бит.

Ну и с GPIOC_ODR – самый простой, бит 13 отвечает за состояние пина 13.
Все готово к написанию кода. Пишем.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <stdint.h>
 
#define APB2ENR   (*(uint32_t*)(0x40021000+0x18))
#define APB2ENR_ENIOC (1u<<4u)
 
#define GPIOC_CRH (*(uint32_t*)(0x40011000+0x04))
#define xGPIO_CRH_MODE13_0 (1u <<20u)
#define xGPIO_CRH_MODE13_1 (1u <<21u)
 
#define GPIOC_ODR (*(uint32_t*)(0x40011000+0x0c))
#define GPIO_ODR_PIN_13 (1u<<13u)
 
__attribute__((noreturn))
void Reset_Handler(){
    //Разрешаем тактировать GPIOC на шине APB2
    APB2ENR |= APB2ENR_ENIOC;
    // Настраиваем GPIO Pin 13 как выход Push-Pull на максимальной частоте
    GPIOC_CRH |= xGPIO_CRH_MODE13_0 | xGPIO_CRH_MODE13_1;
    while(1){
        // Переключаем пин 13 на порте C
        GPIOC_ODR ^= GPIO_ODR_PIN_13;
    }
}
 
 
// Объявим тип - указатель на прерывание
typedef void (*isr_routine)(void);
 
// Опишем структуру таблицы векторов прерываний
typedef struct  {
    const uint32_t * stack_top;
    const isr_routine reset;
} ISR_VECTOR_t;
 
//Получим адрес указателья на стек из скрипта линковки
extern const uint32_t __StackTop;
 
//Укажем линковщику, что эту константу нужно положить в секцию .isr_vector
__attribute__((section(".isr_vector"), __unused__))
const ISR_VECTOR_t  isr_vector = {
       .stack_top = &__StackTop,
       .reset  = &Reset_Handler,
};

Тут все согласно любому учебнику по C, за исключением хитрых атрибутов у таблицы векторов, и непонятно откуда взявшемуся extern const uint32_t __StackTop. Разберемся.
В GCC конструкции вида __atribute__(()) позволяют сообщить линковщику некоторую специфику, которую мы он него ходим, например __attribute__((section(".isr_vector")) требует от линовщика, чтобы он положил объект (функцию, переменную или константу) именно в секцию .isr_vector, а не туда, куда он складывает все по-умолчанию. А extern uint32_t __StackTop – это просто переменная, объявленная в каком-то другом месте, линковщик разберется где она объявлена или выдаст ошибку, если на найдет. Компилятор же просто с ней работает, не заботясь о создании, выделении памяти итд, в общем обычный extern, если бы не одно но: у нас больше нет никаких файлов с исходниками, так откуда же возмется символ? Ответ такой: по скольку мы ничего в этом коде не знаем про распределение памяти, будет правильно поручить создать эту переменную тому, кто ответственный за распределение и чуть ниже попросим, чтобы линковщик сам создал эту переменную и разместил в нужном месте.

Теперь можно скомпилировать файл.
arm-none-eabi-gcc -c -g -Wall -mcpu=cortex-m3 -mthumb -std=gnu99 -o main.o ..\src\main.c
Мы используем много ключей компиляции, чтобы сообщить что:
* Нужно только компилировать, линковщик вызывать нам не требуется, ключ -c
* Нужно включить отладочную информацию, ключ -g
* Нужно показывать все предупреждения, ключ -Wall
* Нужно собрать код под ядро cortex-m3 с набором инструкций thumb, ключи -mcpu=cortex-m3 -mthumb
* Нужно использовать стандарт gnu99
* Нужно назвать выходной файл main.o

Если все написано без ошибок, после компиляции мы получим объектный файл main.o в котором уже есть машинные инструкции для всех функций, но нет никаких адресов, адресацией занимается линковщик и ей мы займемся чуть-чуть позже. А сейчас посмотрим, что у нас вошло в файл main.o, для этого в комплекте gcc есть утилита NM: вызываем arm-none-eabi-nm –print-size main.o

         U __StackTop
00000000 00000008 R isr_vector
00000000 00000038 T Reset_Handler

Ничего удивительного не видим, все как мы хотели:
Reset_Handler символ находится в секции кода ( T )
Isr_vector находится в секции констант ( R )
__StackTop – есть, но непонятно где находится ( U )

Подробнее о NM и что какая буква обозначает можно посмотреть например тут

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

Для начала объявим, что формат вывода у нас elf32-littlearm и архитектура соответственно arm:

OUTPUT_FORMAT("elf32-littlearm", "elf32-littlearm", "elf32-littlearm")
OUTPUT_ARCH(arm)

Далее объявим регионы памяти,

MEMORY
{
    ROM  (rx) : ORIGIN = 0x08000000, LENGTH = 64K
    RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 20K
}

ROM – это FLASH, атрибуты RX – позволяют читать и выполнять данные из этой памяти, ORIGIN – адрес начала (помните адрес в Memory Map?), ну и размер региона 64K, циферка тоже из даташита.
RAM — это оперативная память, из нее мы можем не только читать и выполнять код, но еще и записывать что-то, атрибуты rwx, адрес начала сегмента опять же из Memory Map и размер региона из даташита на процессор.

Теперь опишем секции:

SECTIONS
{
    .isr_vector :
    {
            KEEP(*(.isr_vector))
 
    } > ROM
   .text :
   {
       *(.text*)
   } > ROM
   PROVIDE(__StackTop = ORIGIN(RAM) + LENGTH(RAM));
}

Здесь описываются две секции .isr_vector и .text и обе кладутся в регион флеш-памяти (инструкция в конце секции >ROM). Поскольку специальных инструкций про адреса мы не сообщаем, обе секции будут разложены в нужный регион в порядке описания, т.е. сначала .isr_vector, после .text
Секция .text – это стандартная секция, куда попадает по умолчанию весь выполняемый код. Есть еще несколько таких специальных секций, мы рассмотрим их подробнее, когда они нам потребуется. Пока нам нужны только эти две. Секция .isr_vector объявлена отдельно, чтобы вектор прерываний гарантировано попал в начало региона. Обратите внимание, инструкция KEEP тут очень важная. isr_vector нигде не используется, поэтому скорее всего линковщик решит выкинуть ненужное, с целью оптимизации размера. Ключевой слово KEEP не позволит это сделать.

Есть еще несколько способов сделать такое, но пока не будем их касаться. Этот самый простой и понятный.
Ну и последняя инструкция – это предоставить символ __StackTop поместив его по вычисляемому по не хитрой и понятной формуле. Именно этот адрес мы используем при заполнении таблицы векторов.

После написания этого не хитрого скрипта можно приступить к линковке прошивки:
arm-none-eabi-ld -g -Tscript.ld -o main.elf main.o
где ключ -T – имя файла со скриптом линковки (должен лежать в текущей директории), а ключ -o говорит, что на выходе мы ходим видеть файл main.elf
Так же посмотрим, что теперь в main.elf, выполнить команду arm-none-eabi-nm --print-size main.elf

20005000 T __StackTop
08000000 00000008 R isr_vector
08000008 00000038 T Reset_Handler

Видим, что:
* __StackTop лежит по правильному адресу (хотя и попал в секцию кода, но это сегодня не важно).
* isr_vector лежит в правильном месте, и занимает 0x08 байт (4 байта указатель стека, 4 байта указатель на Reset_Handler)
* Функция Reset_Handler лежит следом за вектором и занимает 0x38 байт.

Важное-важное примечание: Сегодня у нас функция Reset_Handler лежит в области памяти, в котором должны лежать адреса других прерываний, в том числе NMI и HardFault. Вообще очень важно их определять, равно забивать нулями все Reserved области этой таблицы. Сейчас, если что-то случится – микроконтроллеру сорвет крышу, и он ускачет в неизвестность. Но поскольку у нас очень маленькая программа, и никаких прерываний не планируется: мы будем таким неправильным образом экономить память.

Прошивка готова, можно запускать. Подключаем st-link и цепляемся проводами к плате, не забываем про питание. Запускаем Open On-chip debugger. Ему требуется указать какой отладчик и какой процессор мы будем использовать: openocd -f interface/stlink.cfg -f target/stm32f1x.cfg

После запуска, если все прошло хорошо (нашелся st-link, увиделся чип) OOCD сообщит, что ждет соединения GDB на порту 3333
Запустим GDB и соединимся: команда arm-none-eabi-gdb main.elf – запустит отладчик и сообщит ему, что мы будем отлаживать программу, которая находится в файле main.elf, после приветствия (gdb) нужно указать подключиться к GDB-серверу, в роли которого выступает запущенный уже openocd:
target remote 127.0.0.1:3333, это полная форма команды, и в ней еще указан IP-адрес сервера, не трудно догадаться, что не обязательно подключаться к локальной машине, openocd может быть запущен хоть на другом конце мира, лишь бы была связь. Такой вот бонус.

Выполним остановку процессора
monitor halt
Все команды, начинающиеся со слова monitor GDB передаст своему серверу без изменения, поэтому на самом деле, этой командой мы говорим openocd выполнить
halt
— остановку чипа.

Теперь командой
load
выполним загрузку файла main.elf (который мы указали при запуске arm-none-eabi-gdb), на что gdb весело сообщит, что загрузил секции по нужным адресам.

Далее нужно инициализировать чип (сбросить регистры тд), команда
monitor reset init

В ответ GDB сообщит нам значение PC (должно быть равное адресу Reset_Handler) и указатель стека. Теперь можно выполнять программу. Но мы пока убедимся, что все правильно.
gdb отличная штука, он не только умеет запускать, останавливать, прошагивать программу, но и показывать значения переменных, области памяти, дизассемблировать код и еще многое другое. Попросим его показать нам дамп памяти по адресу 0x8000000, 20 слов нам хватит. Для этого служит команда
x/20wux 0x800000
где x команда, а через дробь указано показать 20 слов (w) беззнаково (u) в HEX (x). Ну и адрес еще.
На выходе еще и адреса будут помечены символами.

Теперь сравним с таким же по размеру дампом области начала памяти
x/20wux 0x00
и убедимся, что прижатая к земле нога boot0 действительно отразила адреса из региона FLASH-памяти в самое начало.

Может возникнуть вопрос, а почему же PC=0x8000008. Давайте разберемся.

При старте ядро загрузило в регистр стека значение из нулевой ячейки памяти, а в регистр PC такое значение, чтобы следующая выполненная команда была по адресу находящемуся в ячейке 0x04.
Посмотрим (другим способом) это значение: p/x *(uint32_t*) 0x04 (команда p – печать переменной, через дробь параметр x – отобразить в HEX, а дальше обычный синтаксис C)
Нам покажут: что-то вроде
$1 = 0x8000009
Сравним с адресом Reset_Handler: p/x &Reset_Handler
$2 = 0x8000008
О-па, отличается. В чем же дело, идем в описание регистра PC на инфоцентр arm.com и вычитываем, что это не баг, а фича такая. Нулевой бит этого регистра попадает в EPSR T-bit и всегда должен выставлен. Компилятор об этом знает, все хорошо.

Теперь можно выполнять программу, например по шагам. Для этого служат команды GDB step выполнение до следующей строки исходника и stepi выполнение до следующей машинной инструкции. Есть еще команда continue которая запускает программу пока она не будет прервана вручную или точной останова. Если прошагать до цикла командой step то после строчки GPIOC_ODR ^=… может показаться что у отладчика сорвало крышу и он побежал сам (только довольно медленно), но это не так, это особенность поведения команды, команда step выполняет все машинные инструкции до следующей строки исходника, а поскольку у нас бесконечный цикл из одной строки – следующая инструкция не будет выполнена никогда. Тут на помощь придет остановка выполнения вручную (комбинация клавиш Ctrl+C) и выполнение программы с помощью команды stepi
Да, для удобства, чтобы не набирать все время одни и те же команды, GDB поддерживает повторение последней команды просто по нажатию Enter.

Пошагали, помигали. Следующий этап – получить бинарный файл прошивки. Для этого служит утилита OBJCOPY из комплекта arm-none-eabi-gcc.
Формат команды, для получения бинарного файла:
arm-none-eabi-objcopy -Obinary main.elf main.bin

Этот файл уже можно заряжать в ST-Link Utility или любой другой прошивальщик.

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