Embedded HandbookПриложения
Все разделы › X. Проектирование, сети и IoT
STM32F103, STM32F407 · уровень средний · 12 мин чтения
Открыть в интерактивном справочнике

84а. От скетча к регистрам: один прибор на четырёх уровнях

Разделы 80–84 собирали прототип на Arduino: скетч, digitalWrite, millis(), прерывания. Раздел 5 разобрал три уровня библиотек ST — CMSIS, HAL и LL. Здесь они встречаются: один и тот же прибор написан четыре раза — скетчем Arduino, на HAL, на LL и на голых регистрах, — и все четыре собраны и измерены. Кнопка PA0 с антидребезгом зажигает светодиод PA1, встроенный светодиод PC13 мигает по таймеру TIM2 раз в секунду, и раз в секунду в USART1 уходит строка ticks N presses M.

Сборка — в Docker: скетч — arduino-cli 1.2.2 с ядром STM32duino 3.0.0 (STMicroelectronics:stm32) для BluePill F103C8 и для F407 Discovery; HAL, LL и регистры — тем же компилятором arm-none-eabi-gcc 14.2.1 из ядра, с библиотеками STM32CubeF1 1.8.7 с диска. Размеры — size, состав — nm, код — objdump. Сколько команд стоит одна запись в порт, посчитано в qemu-system-arm -M stm32vldiscovery (Cortex-M3, семейство F1): QEMU с -singlestep -d exec пишет адрес каждой исполненной команды.

Опоры: исходники ядра STM32duino в образе stm-arduino:1 (wiring_digital.c, digital_io.h, pins_arduino.h, PortNames.h, pinmap.h, License.md); STM32CubeF1 1.8.7 — HAL, LL, стартовый код; раздел 5 — уровни CMSIS/HAL/LL; раздел 8 — регистр BSRR. Проверка — _verify/sketch84b_check.py.

Как это называется в документации

Оригинал Перевод
sketch скетч: программа Arduino с setup() и loop()
core (Arduino core) ядро Arduino для семейства плат: digitalWrite, millis, Serial
variant описание конкретной платы: таблица выводов, такты
pin number / PinName номер вывода Arduino / имя вывода STM32 (PA_1)
HAL (hardware abstraction layer) слой абстракции: структуры, функции с проверками
LL (low-layer) тонкие встраиваемые функции над регистрами
BSRR (bit set/reset register) регистр «установить/сбросить биты» порта
weak symbol слабый символ: определение, которое можно заменить своим
inline встраивание функции в место вызова
tail call хвостовой вызов: переход вместо вызова и возврата

84а.1 Один прибор — четыре программы

Уровень Файл Чем настроен вывод Чем мигает Чем пишет в порт
скетч l1_arduino.ino pinMode HardwareTimer и attachInterrupt Serial.print
HAL main_hal.c HAL_GPIO_Init HAL_TIM_Base_Start_IT и обратный вызов HAL_UART_Transmit
LL main_ll.c LL_GPIO_SetPinMode LL_TIM_EnableIT_UPDATE LL_USART_TransmitData8
регистры main_regs.c GPIOA->CRL TIM2->DIER USART1->DR

Логика прибора — антидребезг и сборка строки — у уровней HAL, LL и регистров общая (app84.h), чтобы сравнивались только обращения к железу. Скетч пишет то же самое средствами Arduino:

void loop()
{
  static uint32_t changed, last_print, presses;
  static int stable = HIGH, seen = HIGH;
  int now = digitalRead(PA0);
  if (now != seen) {                            // уровень сменился — ждём, пока успокоится
    seen = now;
    changed = millis();
  }

Столбцы: флеш-память F103 у скетча Arduino 15072 байта, у HAL 4436, у LL 1400, у регистров 1064; команды на одну запись «вывод = 1» в QEMU: digitalWrite(PA1) — 36, digitalWrite(PB12) — 27, digitalWriteFast(PA_1) — 7, HAL_GPIO_WritePin — 7, LL_GPIO_SetOutputPin — 4, GPIOA->BSRR — 4

Все сборки прошли без предупреждений: три скетча — по выводу arduino-cli, свои файлы HAL, LL и регистров — с -Wall -Wextra -Werror.


84а.2 Размер: флеш-память и ОЗУ

Сборка Флеш (text) data bss
скетч, BluePill F103C8 15 072 256 2744
тот же скетч, F407 Discovery 18 432 272 2808
HAL, F103 4436 20 180
LL, F103 1400 12 36
регистры, F103 1064 8 36

Скетч в 14 раз больше регистровой версии и в 3,4 раза — HAL-версии. Переход с F103 на F407 скетчу стоил одной строки: другая плата в -b — и он собрался для другого камня. Регистровую версию так не перенести: у F4 порты на другой шине и по другим адресам, вместо CRL — MODER, другие такты.

Из 1064 байт регистровой версии своего кода (main, два обработчика и put_uint) — 538 байт, половина. Остальное — таблица векторов из стартового файла Cube для F103xB (268 байт), обработчики-заглушки и начальная инициализация библиотеки C.


84а.3 Что тянет за собой Arduino

Двенадцать самых больших символов скетча по nm --size-sort:

Символ Байт Где Что это
HAL_RCC_OscConfig 824 флеш настройка генераторов — HAL внутри ядра
HAL_UART_IRQHandler 660 флеш прерывание UART для Serial
PinMap_TIM 516 флеш таблица «вывод — канал таймера» варианта платы
pin_SetF1AFPin 504 флеш переназначение выводов F1
pin_function 356 флеш настройка вывода по таблицам
uart_init 332 флеш обёртка ядра над HAL UART
gpio_irq_conf 320 ОЗУ таблица обработчиков attachInterrupt
HAL_RCC_ClockConfig 312 флеш переключение тактов
__sf 312 ОЗУ потоки стандартной библиотеки C
HAL_TIM_IRQHandler 296 флеш прерывание таймера для HardwareTimer
Serial1 280 ОЗУ объект порта с буферами
_malloc_r 256 флеш динамическая память библиотеки C

Скетч держит внутри 45 функций HAL_… — больше, чем HAL-версия прибора (20): ядро Arduino само построено на HAL и LL ST. Плюс таблицы выводов на все случаи (PinMap_TIM), таблица обработчиков внешних прерываний, объект Serial1 с буферами и malloc. Это цена того, что pinMode и Serial.begin работают с любым выводом любой платы из списка.


84а.4 Что делает digitalWrite внутри

По исходникам ядра цепочка короткая, но с тремя таблицами:

  1. digitalWrite(pin, val) (wiring_digital.c) — переводит номер Arduino в имя вывода STM32 макросом digitalPinToPinName (pins_arduino.h): по таблице digitalPin[] варианта платы.
  2. digitalWriteFast(pn, val) (digital_io.h) — из имени вывода достаёт порт макросом get_GPIO_Port (таблица GPIOPort_list) и маску вывода макросом STM_LL_GPIO_PIN (таблица pin_map_ll).
  3. digital_io_write — LL_GPIO_SetOutputPin или LL_GPIO_ResetOutputPin: одна запись в BSRR или BRR.

В objdump всё это — одна функция digitalWrite на 116 байт: вызовы встроены компилятором. Запись в порт — str r3, [r2, #16] (смещение 0x10 — BSRR) для HIGH и str r3, [r2, #20] (0x14 — BRR) для LOW. Остальное — проверки номера и три чтения таблиц; адрес таблицы портов в коде стенда — 0x20000004, то есть она лежит в ОЗУ.

Номер PA1 — «аналоговый». В ядре PA1 определён как PIN_A1, а это PNUM_ANALOG_BASE + 1 = 0xC1: номер аналогового входа. digitalWrite сначала сверяет его с числом цифровых выводов (35), не проходит и идёт по второй ветке — через таблицу analogInputPin[]. Поэтому один и тот же вызов стоит по-разному для разных выводов (84а.5).

Ядро STM32duino распространяется по нескольким лицензиям: по его License.md файлы cores/arduino — LGPL-2.1, HAL — BSD-3-Clause, библиотеки USB — SLA0044 ST. Что это значит для прошивки с таким ядром — раздел 205б.


84а.5 Сколько команд стоит одна запись в порт

Два стенда собраны так же, как приборы, и прогнаны в QEMU stm32vldiscovery — макете платы с F100 того же семейства F1; запись в порт по адресу 0x40010800 он принимает без ошибки. Стенд-скетч подменяет слабую функцию ядра init() пустой — настройку тактов QEMU не пройдёт, а для записи в порт она не нужна:

extern "C" void init(void) {}

__attribute__((noinline)) void bench_pa1(void) { digitalWrite(PA1, HIGH); }         // номер PA1 — «аналоговый»
__attribute__((noinline)) void bench_pb12(void) { digitalWrite(PB12, HIGH); }       // обычный цифровой номер
__attribute__((noinline)) void bench_fast(void) { digitalWriteFast(PA_1, HIGH); }   // по имени вывода, без таблицы
__attribute__((noinline)) void bench_ll(void) { LL_GPIO_SetOutputPin(GPIOA, LL_GPIO_PIN_1); }   // LL прямо в скетче
__attribute__((noinline)) void bench_bsrr(void) { GPIOA->BSRR = GPIO_BSRR_BS1; }                // регистр прямо в скетче

Счёт — от первой команды функции-стенда до возврата в вызывающую:

Как записано Команд всего Из них внутри функции библиотеки
digitalWrite(PA1, HIGH) 36 33 в digitalWrite
digitalWrite(PB12, HIGH) 27 24 в digitalWrite
digitalWriteFast(PA_1, HIGH) 7 — (встроена)
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_SET) 7 3 в HAL_GPIO_WritePin
LL_GPIO_SetOutputPin(GPIOA, LL_GPIO_PIN_1) — в скетче и в LL-версии 4 — (встроена)
GPIOA->BSRR = GPIO_BSRR_BS1 — в скетче и в регистровой версии 4 —

Четыре команды LL и регистра — одни и те же: movs r2, #2, ldr r3, [pc, …] (адрес порта 0x40010800), str r2, [r3, #16], bx lr. LL-функция встроилась без следа. HAL_GPIO_WritePin — тоже всего три команды внутри: проверка «установить или сбросить» и запись. digitalWrite в 7–9 раз длиннее из-за перевода номера Arduino в порт и маску.

Это счёт команд, а не тактов: QEMU не моделирует время исполнения. Такты на плате здесь не измерялись.


84а.6 Как смешивать уровни и когда какой выбрать

Стенд-скетч показывает главное: в скетче можно писать LL и регистры напрямую — ядро уже включает заголовки CMSIS и LL, и строки LL_GPIO_SetOutputPin(...) и GPIOA->BSRR = ... собрались без единого #include и дали тот же код, что в чистой LL- и регистровой версиях. Так делают на прототипе: всё — pinMode, Serial, millis, а одно узкое место — регистром.

Правила смешивания:

Когда какой уровень:

Уровень Сильная сторона Цена
скетч прибор за вечер, перенос на другую плату сменой -b 15 КБ вместо 1 КБ, скрытые таймеры и буферы, до 36 команд на запись
HAL вся периферия единообразно, генерация CubeMX 4 КБ, структуры и обратные вызовы, проверки в каждом вызове
LL код как у регистров, но читаемые имена каждое поле настраивается вручную
регистры полный контроль, минимальный размер справочное руководство под рукой, перенос между семействами — заново

84а.7 Грабли

digitalWrite на «аналоговом» выводе дороже. PA1 — это номер PIN_A1 (0xC1), и запись идёт через лишнюю таблицу: 36 команд против 27 у PB12. Для быстрого пути — digitalWriteFast(PA_1, …).

Скетч не помещается в маленький камень. Пустой по смыслу прибор — 15 072 байта флеш-памяти и 2744 байта bss. На камне с 16 КБ флеш- памяти это почти всё. Считать надо по size, а не по числу строк скетча.

Регистровая версия «не переносится». Скетч собрался для F407 сменой платы; регистровый код F103 (CRL, APB2ENR) на F407 — это другие регистры (MODER, AHB1ENR) и другие адреса.

Свой обработчик прерывания против ядра. Ядро само определяет обработчики таймеров — TIM2_IRQHandler лежит в HardwareTimer.cpp. Своё определение того же имени в скетче столкнётся с ним; таймер, которым пользуется ядро, лучше брать через HardwareTimer.

Подмена init() — только для стенда. Пустая init() выбрасывает настройку тактов и всё, что ядро делает до setup(). Так сделано только для счёта команд в QEMU; в приборе init() ядра нужна.

Счёт команд — не время. QEMU не знает ожиданий флеш-памяти и шины: 4 команды против 36 — это соотношение объёма работы, а не микросекунд на плате.


84а.8 Итоги

Полные исходники

l1_arduino.ino — уровень 1 — скетч Arduino: кнопка, светодиоды, строка в Serial.

// Раздел 84а, уровень 1 — скетч Arduino (ядро STM32duino). Прибор: кнопка PA0 → светодиод PA1 с антидребезгом,
// встроенный светодиод мигает по таймеру раз в секунду, раз в секунду строка в Serial (USART1, PA9).
#include <Arduino.h>

const uint32_t DEBOUNCE_MS = 20;
HardwareTimer blink(TIM2);
volatile uint32_t ticks;

void onTimer()
{
  digitalToggle(LED_BUILTIN);
  ticks++;
}

void setup()
{
  pinMode(LED_BUILTIN, OUTPUT);
  pinMode(PA1, OUTPUT);
  pinMode(PA0, INPUT_PULLUP);
  Serial.begin(115200);
  blink.setOverflow(2, HERTZ_FORMAT);           // два переключения в секунду — мигание 1 Гц
  blink.attachInterrupt(onTimer);
  blink.resume();
}

void loop()
{
  static uint32_t changed, last_print, presses;
  static int stable = HIGH, seen = HIGH;
  int now = digitalRead(PA0);
  if (now != seen) {                            // уровень сменился — ждём, пока успокоится
    seen = now;
    changed = millis();
  }
  if (now != stable && millis() - changed >= DEBOUNCE_MS) {
    stable = now;
    digitalWrite(PA1, stable == LOW ? HIGH : LOW);
    if (stable == LOW)
      presses++;
  }
  if (millis() - last_print >= 1000) {
    last_print += 1000;
    Serial.print("ticks ");
    Serial.print(ticks);
    Serial.print(" presses ");
    Serial.println(presses);
  }
}

bench_arduino.ino — стенд для QEMU: сколько команд стоит «PA1 = 1» разными путями.

// Раздел 84а: стенд для QEMU stm32vldiscovery — сколько команд стоит «PA1 = 1» из скетча разными путями.
// init() ядра подменён пустым: без настройки тактов, которой QEMU не умеет. Выход — через semihosting.
#include <Arduino.h>

extern "C" void init(void) {}

__attribute__((noinline)) void bench_pa1(void) { digitalWrite(PA1, HIGH); }         // номер PA1 — «аналоговый»
__attribute__((noinline)) void bench_pb12(void) { digitalWrite(PB12, HIGH); }       // обычный цифровой номер
__attribute__((noinline)) void bench_fast(void) { digitalWriteFast(PA_1, HIGH); }   // по имени вывода, без таблицы
__attribute__((noinline)) void bench_ll(void) { LL_GPIO_SetOutputPin(GPIOA, LL_GPIO_PIN_1); }   // LL прямо в скетче
__attribute__((noinline)) void bench_bsrr(void) { GPIOA->BSRR = GPIO_BSRR_BS1; }                // регистр прямо в скетче

static void semi_exit(void)
{
  register int r0 __asm__("r0") = 0x18;
  register int r1 __asm__("r1") = 0x20026;
  __asm__ volatile("bkpt 0xAB" : "+r"(r0) : "r"(r1) : "memory");
}

void setup()
{
  bench_pa1();
  bench_pb12();
  bench_fast();
  bench_ll();
  bench_bsrr();
  semi_exit();
}

void loop() {}

Дальше: раздел 85 «TinyML: машинное обучение на микроконтроллере».

Открыть в интерактивном справочнике