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,Serialvariant описание конкретной платы: таблица выводов, такты 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();
}
Все сборки прошли без предупреждений: три скетча — по выводу
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 внутри
По исходникам ядра цепочка короткая, но с тремя таблицами:
digitalWrite(pin, val)(wiring_digital.c) — переводит номер Arduino в имя вывода STM32 макросомdigitalPinToPinName(pins_arduino.h): по таблицеdigitalPin[]варианта платы.digitalWriteFast(pn, val)(digital_io.h) — из имени вывода достаёт порт макросомget_GPIO_Port(таблицаGPIOPort_list) и маску вывода макросомSTM_LL_GPIO_PIN(таблицаpin_map_ll).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, а одно узкое место — регистром.
Правила смешивания:
- настройку вывода оставить ядру (
pinMode), а быстрым путём только писать и читать — иначе настройки ядра и свои будут спорить; - таймеры и прерывания, которыми пользуется ядро (
HardwareTimer,SysTickдляmillis), не перенастраивать регистрами за его спиной; - если нужен быстрый вывод по номеру Arduino —
digitalWriteFastс именем вывода (PA_1): 7 команд вместо 27–36.
Когда какой уровень:
| Уровень | Сильная сторона | Цена |
|---|---|---|
| скетч | прибор за вечер, перенос на другую плату сменой -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 Итоги
- Один прибор: скетч — 15 072 байта флеш-памяти, HAL — 4436, LL — 1400, регистры — 1064; ОЗУ: 256 + 2744 у скетча, 8 + 36 у регистров.
- Тот же скетч для F407 Discovery — 18 432 байта: перенос сменой платы.
- В скетче 45 функций
HAL_…— больше, чем в HAL-версии (20): ядро Arduino стоит на HAL и LL ST; самые большие —HAL_RCC_OscConfig(824 байта) иHAL_UART_IRQHandler(660). digitalWrite— номер Arduino → таблицаdigitalPin[]→ порт и маска из двух таблиц → запись вBSRR/BRR; одна функция на 116 байт.- Запись «вывод = 1» в QEMU:
digitalWrite(PA1)— 36 команд,digitalWrite(PB12)— 27,digitalWriteFastи HAL — по 7, LL и регистр — по 4. - LL и регистры можно писать прямо в скетче — тот же код; настройку выводов и таймеры ядра при этом не трогать.
Полные исходники
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: машинное обучение на микроконтроллере».