[Перевод] Низкоуровневое программирование STM32: от включения питания до «Hello, World»

В этом материале я хочу рассказать о том, как писать программы для микроконтроллеров (Microcontroller Unit, MCU) Cortex-M, вроде STM32, используя лишь набор инструментов ARM и документацию, подготовленную STMicroelectronics. У некоторых читателей может появиться вопрос о том, почему кому-то это может понадобиться. Если вам эта идея, на первый взгляд, не показалась очень уж страшной, то, возможно, вам будет интересно то, о чём пойдёт речь в этом материале. И, кстати, подумаем о том, кому и зачем это может пригодиться.

Конечно, разрабатывать программы для MCU STM32 можно с помощью существующих фреймворков. Это может быть ST HAL, обычный CMSIS, или даже что-то, более близкое к Arduino. Но… что тут увлекательного? Ведь, в итоге, тот, кто пользуется каким-то фреймворком, полностью зависим от документации к нему и от его разработчиков. И, с другой стороны, если документация к STM32 кажется кому-то, работающему с этой платформой, так сказать, бредом сивой кобылы, то можно ли говорить о том, что этот человек по-настоящему понимает данную платформу?

pa22eudug3rtxx0-dmbef7nfas0.jpeg

Поэтому давайте поговорим о низкоуровневом программировании STM32 и доберёмся от включения питания STM32 до «Hello, World».

STM32 очень похож на компьютер


На низком уровне микроконтроллер не особенно сильно отличается от полнофункционального компьютера, основанного на процессоре от Intel или AMD. Тут имеется как минимум одно процессорное ядро, инициализирующееся после подачи и стабилизации внешнего питания. В этот момент производится считывание загрузчика, код которого находится по адресу, заранее известному микроконтроллеру. А в обычных компьютерах подобную роль играет BIOS. В случае с MCU это — код, находящийся по определённому смещению в (обычно) интегрированной памяти, предназначенной только для чтения. То, что происходит потом, полностью зависит от этого кода.

В целом, этот код решает основные задачи по подготовке системы к работе. Например, задаёт таблицу векторов прерываний и записывает определённые данные в некие регистры. Очень важной задачей, кроме того, является инициализация указателя стека (Stack Pointer, SP). В начале работы системы некоторые данные из ROM копируются в RAM. В итоге вызывается функция main(), что похоже на запуск операционной системы обычного компьютера, выполняемый после завершения подготовки системы к работе средствами BIOS.

Пример Pushy


Возможно, аналогом «Hello, World» для STM32 можно назвать пример из моего фреймворка для STM32 Nodate, который я ласково называю Pushy. Он ещё проще, чем традиционный пример Blinky, так как он использует лишь регистры управления тактированием и сбросом (Reset & Clock Control, RCC) и базовые возможности интерфейса ввода/вывода общего назначения (General-Purpose Input/Output, GPIO). Код этого примера считывает входной регистр GPIO-пина и подстраивает значение на выходном пине в соответствии с входным. Благодаря этому можно, с помощью кнопки, включать и выключать светодиод. Вот код этого примера:

#include 

int main () {
  //const uint8_t led_pin = 3; // Nucleo-f042k6: Port B, pin 3.
  //const GPIO_ports led_port = GPIO_PORT_B;
  //const uint8_t led_pin = 13; // STM32F4-Discovery: Port D, pin 13 (оранжевый)
  //const GPIO_ports led_port = GPIO_PORT_D;
  //const uint8_t led_pin = 7; // Nucleo-F746ZG: Port B, pin 7 (синий)
  //const GPIO_ports led_port = GPIO_PORT_B;
  const uint8_t led_pin = 13; // Blue Pill: Port C, pin 13.
  const GPIO_ports led_port = GPIO_PORT_C;
  
  //const uint8_t button_pin = 1; // Nucleo-f042k6 (PB1)
  //const GPIO_ports button_port = GPIO_PORT_B;
  //const uint8_t button_pin = 0; // STM32F4-Discovery (PA0)
  //const GPIO_ports button_port = GPIO_PORT_A;
  //const uint8_t button_pin = 13; // Nucleo-F746ZG (PC13)
  //const GPIO_ports button_port = GPIO_PORT_C;
  const uint8_t button_pin = 10; // Blue Pill
  const GPIO_ports button_port = GPIO_PORT_B;
  
  // Установить режим вывода на пине, к которому подключён светодиод
  GPIO::set_output(led_port, led_pin, GPIO_PULL_UP);
  GPIO::write(led_port, led_pin, GPIO_LEVEL_LOW);
  
  // Установить режим ввода на пине, к которому подключена кнопка
  GPIO::set_input(button_port, button_pin, GPIO_FLOATING);
  
  // Если кнопка нажата (переход от высокого состояния к низкому), то 'button_down' будет в низком состоянии в том случае, если кнопка будет нажата.
  // Если кнопка не нажата (переход от низкого состояния к высокому, к Vdd), то 'button_down' будет в высоком состоянии в том случае, если кнопка будет нажата.
  uint8_t button_down;
  while (1) {
    button_down = GPIO::read(button_port, button_pin);
    if (button_down == 1) {
      GPIO::write(led_port, led_pin, GPIO_LEVEL_HIGH);
    }
    else {
      GPIO::write(led_port, led_pin, GPIO_LEVEL_LOW);
    }
  }
  
  return 0;
}


Тут можно сразу обратить внимание на два самых заметных элемента. Первый — это функция main(), которую вызывает система. Второй — это подключения модуля GPIO. Этот модуль содержит статический C++-класс, возможности которого используются для записи данных в GPIO-выход, к которому подключён светодиод. Его же возможности применяются и при чтении данных с входа, к которому подключена кнопка. Тут можно видеть ещё и упоминание имён пинов платы Blue Pill (STM32F103C8), но в примере имеются предустановки и для других плат, которые можно активировать, раскомментировав соответствующие строки.

Где именно в этом примере используются регистры группы RCC? В названии этих регистров содержится намёк на то, что они позволяют управлять тактовой частотой MCU. Их можно сравнить с переключателями, которые могут пребывать в двух состояниях — «включено» или «выключено», включая и отключая соответствующие возможности MCU. Если посмотреть, например, на описание регистра RCC_AHBENR в разделе 6.4 руководства по STM32F0xx, то мы увидим бит, маркированный как IOPAEN (Input/Output Port A ENable, включение порта ввода/вывода A), который управляет частотой для периферии, подключённой к GPIO A. То же касается и других портов.

9dd1c78305ffd62f7f9b5f6f45653d40.png


Раздел 6.4.6 руководства по STM32F0xx, описание регистра RCC_AHBENR

Как можно видеть на вышеприведённой иллюстрации, RCC_AHBENR — это регистр, отвечающий за включение AHB. Это — одна из шин внутри MCU, к которой подключены процессорное ядро, SRAM, ROM и периферийные устройства.

Шины AHB (Advanced High-performance Bus) и APB (Advanced Peripheral Bus) описаны в спецификации AMBA фирмы Arm.

fcc7c21e06f5de76650dd8d905b02183.png


Раздел 2.1 руководства по STM32F0xx, архитектура STM32F0xx

В целом можно отметить, что AHB — это более быстрая шина, соединяющая процессорное ядро со SRAM, ROM и с высокоскоростной периферией. Более медленная периферия подключается к более медленной шине APB. Между AHB и APB имеется мост, позволяющий устройствам, подключённым к ним, взаимодействовать друг с другом.

Низкоуровневое программирование


Как уже было сказано, первым при включении STM32 запускается код загрузчика. В случае с MCU STM32F042×6 универсальный код загрузчика, написанный на ассемблере Thumb, можно найти здесь. Это — обычный код, предоставляемый STMicroelectronics (например, для STM32F0xx) вместе с CMSIS-пакетом. Он инициализирует MCU и вызывает функцию SystemInit(), объявленную в низкоуровневом C-коде CMSIS (вот — пример для STM32F0xx).

Функция SystemInit() сбрасывает системные регистры, отвечающие за частоту, что приводит к использованию стандартной частоты HSI (High Speed Internal oscillator, высокоскоростной внутренний генератор). После выполнения процедур настройки libc (в данном случае используется Newlib — вспомогательная C/C++-библиотека) она, наконец, вызывает функцию main() следующей командой:

bl main


Эта инструкция, название которой расшифровывается как Branch with Link (переход с сохранением адреса возврата), приводит к переходу к заданной метке. В этот момент мы оказываемся в функции main() нашего примера Pushy. После этого в дело вступают возможности класса GPIO.

Класс GPIO


Первый вызываемый нами метод класса — это GPIO::set_output(). Он позволяет сделать указанный пин (с подключённым к нему повышающим резистором) выходным. Именно здесь мы встречаемся с первым различием между семействами MCU STM32. Дело в том, что более старые MCU, основанные на Cortex-M3 F1, имеют GPIO-периферию, очень сильно отличающуюся от той, которая используется в их более новых собратьях семейств F0, F4 и F7. Это выражается в том, что при работе с пинами STM32F1xx нужно записывать в единственный регистр множество опций:

  // Управление вводом/выводом распределено между двумя комбинированными регистрами (CRL, CRH).
  if (pin < 8) {
    // Установим регистр CRL (CNF & MODE).
    uint8_t pinmode = pin * 4;
    uint8_t pincnf = pinmode + 2;
    
    if (speed == GPIO_LOW) {    instance.regs->CRL |= (0x2 << pinmode); }
    else if (speed == GPIO_MID) {  instance.regs->CRL |= (0x1 << pinmode);  }
    else if (speed == GPIO_HIGH) {  instance.regs->CRL |= (0x3 << pinmode);  }
  
    if (type == GPIO_PUSH_PULL) {    instance.regs->CRL &= ~(0x1 << pincnf);  }
    else if (type == GPIO_OPEN_DRAIN) {  instance.regs->CRL |= (0x1 << pincnf);  }
  }
  else {
    // Установим регистр CRH.
    uint8_t pinmode = (pin - 8) * 4;
    uint8_t pincnf = pinmode + 2;
    
    if (speed == GPIO_LOW) {    instance.regs->CRH |= (0x2 << pinmode); }
    else if (speed == GPIO_MID) {  instance.regs->CRH |= (0x1 << pinmode);  }
    else if (speed == GPIO_HIGH) {  instance.regs->CRH |= (0x3 << pinmode);  }
  
    if (type == GPIO_PUSH_PULL) {    instance.regs->CRH &= ~(0x1 << pincnf);  }
    else if (type == GPIO_OPEN_DRAIN) {  instance.regs->CRH |= (0x1 << pincnf);  }
  }


А в других упомянутых семействах MCU имеются отдельные регистры для каждой опции (режим, скорость, повышающий или понижающий резистор, тип):

  uint8_t pin2 = pin * 2;
  instance.regs->MODER &= ~(0x3 << pin2);
  instance.regs->MODER |= (0x1 << pin2);
  
  instance.regs->PUPDR &= ~(0x3 << pin2);
  if (pupd == GPIO_PULL_UP) {
    instance.regs->PUPDR |= (0x1 << pin2);
  }
  else if (pupd == GPIO_PULL_DOWN) {
    instance.regs->PUPDR |= (0x2 << pin2);
  }
  
  if (type == GPIO_PUSH_PULL) {
    instance.regs->OTYPER &= ~(0x1 << pin);
  }
  else if (type == GPIO_OPEN_DRAIN) {
    instance.regs->OTYPER |= (0x1 << pin);
  }
  
  if (speed == GPIO_LOW) {
    instance.regs->OSPEEDR &= ~(0x3 << pin2);
  }
  else if (speed == GPIO_MID) {
    instance.regs->OSPEEDR &= ~(0x3 << pin2);
    instance.regs->OSPEEDR |= (0x1 << pin2);
  }
  else if (speed == GPIO_HIGH) {
    instance.regs->OSPEEDR &= ~(0x3 << pin2);
    instance.regs->OSPEEDR |= (0x3 << pin2);
  }


Запись опций в регистры выполняется с помощью побитовых операций, используемых для записи значений с применением битовых масок. Имена регистров обычно хорошо описывают их предназначение. Например — PUPDR расшифровывается как Pull-Up Pull-Down Register — «регистр управления подтяжкой к плюсу питания или к земле».

То, в каком именно стиле работать, зависит от программиста. Однако, в случае с настройкой входных пинов, я предпочитаю более современный способ настройки GPIO. В результате получается компактный и аккуратный код, напоминающий следующий, а не тот ужас, который характерен для STM32F1xx:

  uint8_t pin2 = pin * 2;
  instance.regs->MODER &= ~(0x3 << pin2);
  instance.regs->PUPDR &= ~(0x3 << pin2);
  if (pupd == GPIO_PULL_UP) {
    instance.regs->PUPDR |= (0x1 << pin2);
  }
  else {
    instance.regs->PUPDR |= (0x2 << pin2);
  }


Для чтения данных с входного пина мы пользуемся IDR (Input Data Register, регистр входных данных) для банка GPIO, с которым работаем:

  uint32_t idr = instance.regs->IDR;
  out = (idr >> pin) & 1U;  // Прочитать нужный бит.


Аналогично выглядит и использование ODR (Output Data Register, регистр выходных данных), с помощью которого осуществляется вывод данных на пин:

  if (level == GPIO_LEVEL_LOW) {
    instance.regs->ODR &= ~(0x1 << pin);
  }
  else if (level == GPIO_LEVEL_HIGH) {
    instance.regs->ODR |= (0x1 << pin);
  }


И, наконец, в вышеприведённом коде имеется сущность instance, которая представляет собой ссылку на запись в структуре std::vector. Она была статически создана в ходе запуска MCU. В ней зарегистрированы свойства периферии:

std::vector* GPIO_instances() {
  GPIO_instance instance;
  static std::vector* instancesStatic = new std::vector(12, instance);
  
#if defined RCC_AHBENR_GPIOAEN || defined RCC_AHB1ENR_GPIOAEN || defined RCC_APB2ENR_IOPAEN
  ((*instancesStatic))[GPIO_PORT_A].regs = GPIOA;
#endif

#if defined RCC_AHBENR_GPIOBEN || defined RCC_AHB1ENR_GPIOBEN || defined RCC_APB2ENR_IOPBEN
  ((*instancesStatic))[GPIO_PORT_B].regs = GPIOB;
#endif
  
  [..]
  
  return instancesStatic;
}

static std::vector* instancesStatic = GPIO_instances();


Если периферийное устройство существует (то есть — имеется в CMSIS-заголовке для конкретного MCU, например, для STM32F042), то в структуре GPIO_instance создаётся запись, указывающая на память, соответствующую регистрам этого устройства (regs). К этим записям, равно как и к мета-информации, содержащейся в них, потом можно обращаться. Например, можно узнать о состоянии устройства:

  GPIO_instance &instance = (*instancesStatic)[port];
  
  // Проверяем, является ли порт активным. Если это не так -  активируем его.
  if (!instance.active) {
    if (Rcc::enablePort((RccPort) port)) {
      instance.active = true;
    }
    else {
      return false;
    }
  }


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

Класс RCC


Класс RCC тоже интересуется тем, существует ли то или иное периферийное устройство. Делается это для того чтобы избежать разного рода сюрпризов. При этом используются те же определения препроцессора CMSIS. После проверки существования устройства включить его довольно просто:

bool Rcc::enable(RccPeripheral peripheral) {
  uint8_t perNum = (uint8_t) peripheral;
  RccPeripheralHandle &ph = (*perHandlesStatic)[perNum];
  
  if (ph.exists == false) {
    return false;
  }
  
  // Проверка состояния текущего периферийного устройства.
  if (ph.count > 0) {
    if (ph.count >= handle_max) {
      return false;
    }
    
    // Увеличение количества обработчиков на 1.
    ph.count++;
  }
  else {
    // Активация периферийного устройства.
    ph.count = 1;
    *(ph.enr) |= (1 << ph.enable);
  }
  
  return true;
}


В дополнение к изменению значения соответствующего бита (ph.enable) мы подсчитываем ссылки. Это делается для того чтобы случайно не выключить периферийное устройство, которое используется в другом месте кода.

Запуск примера


После того, как мы разобрались с вышеприведённым материалом, у нас должно появиться некоторое понимание того, как пример Pushy работает на низком уровне. Теперь мы можем его собрать и запустить. Для этого нам понадобится, как уже было сказано, набор инструментов ARM и фреймворк Nodate. Первый можно установить с помощью используемого вами менеджера пакетов (речь идёт о пакете arm-none-eabi-gcc) или — загрузив его с сайта Arm. Фреймворк Nodate можно установить с GitHub. После этого путь к корневой папке фреймворка нужно записать в глобальную системную переменную NODATE_HOME.

После того, как эти задачи решены, нужно перейти в папку Nodate, а потом — в подпапку examples/stm32/pushy. В ней надо открыть файл Makefile и указать предустановки, рассчитанные на используемую плату (там сейчас есть предустановки для Blue Pill, Nucleo-F042K6, STM32F4-Discovery, Nucleo-746ZG). Далее, надо открыть файл src/pushy.cpp и раскомментировать строки, имеющие отношение к целевой плате.

Далее, в папке, в которой находится Makefile, нужно собрать проект командой make. Целевая плата должна быть подключена к компьютеру с использованием ST-Link, на компьютере должна быть установлена программа OpenOCD. Если это так — MCU можно прошить командой make flash. После этого соответствующий образ будет записан в память устройства.

Когда кнопка подключена к используемому в коде пину и к Vdd, нажатие на эту кнопку должно зажигать соответствующим образом подключенный к плате светодиод.

Здесь продемонстрирован простой пример низкоуровневого программирования STM32. Освоив его, вы, фактически, находитесь лишь немного «ниже» уровня примера Blinky.

Надеюсь, я смогла показать то, что низкоуровневое программирование STM32 — это совсем несложно.

Какие инструменты вы используете при написании программ для STM32?

oug5kh6sjydt9llengsiebnp40w.png

3piw1j3wd_cgmzq9sefgferaumu.png

© Habrahabr.ru