5. Три уровня абстракции: CMSIS, HAL и LL
Одну и ту же строчку «зажечь светодиод» можно написать четырьмя способами. Спор о том, какой правильный, обычно ведётся на уровне вкусов — а решается он просто: посмотреть, во что каждый способ превращается компилятором. Этим и займёмся.
Как это называется в документации
Оригинал Перевод CMSIS (Cortex Microcontroller Software Interface Standard) стандарт имён и заголовков для ядер Cortex-M HAL (Hardware Abstraction Layer) библиотека уровня «одна функция — одно действие» LL (Low Layer) тонкие встраиваемые функции над регистрами SPL (Standard Peripheral Library) прежняя библиотека ST, до HAL code generator генератор кода (CubeMX) user code section пользовательский блок, который генератор сохраняет MSP (MCU support package) настройка тактов и выводов под блок weak symbol ( __weak)слабое определение: его заменяет обычное с тем же именем callback обратный вызов linker map карта компоновщика
5.1 Кто что придумал
| Слой | Кто автор | Что это |
|---|---|---|
| CMSIS | ARM | стандарт: описание ядра, регистров, единые имена. Не библиотека, а соглашение |
| CMSIS Device | ST | заголовки с картой регистров конкретного камня: stm32f10x.h, stm32f4xx.h |
| SPL | ST | старая библиотека периферии. Больше не развивается, встречается в старых проектах |
| HAL | ST | сегодняшняя библиотека: единый интерфейс поверх всех семейств |
| LL | ST | тонкие обёртки над регистрами, почти без накладных расходов |
Практически это выглядит так: CMSIS даёт имена (GPIOA, RCC_APB2ENR_IOPCEN),
LL даёт короткие функции над теми же регистрами, HAL даёт готовые процедуры
с проверками, таймаутами и обработкой ошибок.
Важно: CMSIS не альтернатива HAL. Заголовки CMSIS подключены всегда — HAL и LL сами на них опираются.
5.2 Макросы CMSIS и HAL
В заголовке семейства объявлен небольшой набор макросов для работы с регистрами. Никакой магии в них нет — вот их полные определения:
#define SET_BIT(REG, BIT) ((REG) |= (BIT))
#define CLEAR_BIT(REG, BIT) ((REG) &= ~(BIT))
#define READ_BIT(REG, BIT) ((REG) & (BIT))
#define CLEAR_REG(REG) ((REG) = (0x0))
#define WRITE_REG(REG, VAL) ((REG) = (VAL))
#define READ_REG(REG) ((REG))
#define MODIFY_REG(REG, CLEARMASK, SETMASK) \
WRITE_REG((REG), (((READ_REG(REG)) & (~(CLEARMASK))) | (SETMASK)))
Всё, что они делают, — избавляют от опечаток в битовых операциях и делают код
читаемым. MODIFY_REG полезнее остальных: он выполняет правильную
последовательность «очистить поле → положить значение», в которой легко ошибиться
руками.
/* одно и то же */
RCC->CFGR = (RCC->CFGR & ~RCC_CFGR_PLLMULL) | RCC_CFGR_PLLMULL9;
MODIFY_REG(RCC->CFGR, RCC_CFGR_PLLMULL, RCC_CFGR_PLLMULL9);
⚠️
READ_BITвозвращает маску, а не единицу — про это подробно в разделах 3 и 8. Сравнивайте результат с нулём.
Макросы HAL. У HAL набор свой, и он куда больше: в библиотеке для F4 объявлено
1410 макросов с приставкой __HAL_. Функциями они не являются — это те же
обращения к регистрам, спрятанные за понятным именем.
| Группа | Пример | Что внутри |
|---|---|---|
| включить такт блока | __HAL_RCC_GPIOA_CLK_ENABLE() |
бит в RCC->AHB1ENR; таких макросов 89 |
| прерывания и флаги блока | __HAL_UART_ENABLE_IT(&huart1, UART_IT_RXNE) |
бит в CR1, CR2 или CR3 |
| мелкая правка на ходу | __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, 500) |
запись в CCR1, минуя настройку канала |
Дальше — три места, где эти макросы ведут себя не так, как ждёт читающий.
Включение такта читает регистр обратно. Полное определение:
#define __HAL_RCC_GPIOA_CLK_ENABLE() do { \
__IO uint32_t tmpreg = 0x00U; \
SET_BIT(RCC->AHB1ENR, RCC_AHB1ENR_GPIOAEN); \
/* Delay after an RCC peripheral clock enabling */ \
tmpreg = READ_BIT(RCC->AHB1ENR, RCC_AHB1ENR_GPIOAEN); \
UNUSED(tmpreg); \
} while(0U)
Чтение здесь не для проверки. Запись в RCC уходит по шине и доходит до блока
не мгновенно; обращение к регистру самого порта, стоящее следующей командой,
может прийти раньше, чем такт включится, — и пропасть. Чтение того же бита
заставляет ядро дождаться ответа шины. Свой код вида «RCC->AHB1ENR |= …;
и сразу GPIOA->MODER = …» ловит именно это: порт настраивается через раз и
по-разному от уровня оптимизации. Лечение — такое же чтение обратно.
Макрос умеет вернуть из функции. __HAL_LOCK — защёлка, с которой
начинается почти каждая функция HAL:
#define __HAL_LOCK(__HANDLE__) \
do { \
if ((__HANDLE__)->Lock == HAL_LOCKED) \
{ \
return HAL_BUSY; \
} \
else \
{ \
(__HANDLE__)->Lock = HAL_LOCKED; \
} \
} while (0U)
return стоит внутри макроса. В своей функции, которая возвращает void,
строку __HAL_LOCK(&huart1); armclang не соберёт, а GCC 12 соберёт с одним
предупреждением, и функция молча выйдет. В функции с другим типом возврата
она соберётся и вернёт не то. Макрос рассчитан только на функции HAL,
возвращающие HAL_StatusTypeDef, и мьютексом не является: проверка и
установка Lock — две отдельные операции, между ними может прийти прерывание.
Аргумент раскрывается несколько раз. __HAL_UART_ENABLE_IT — цепочка
условных выражений: верхние четыре бита константы UART_IT_* хранят номер
регистра, и макрос по нему выбирает между CR1, CR2 и CR3. Параметр
подставляется в текст трижды, поэтому выражение с побочным эффектом —
__HAL_UART_ENABLE_IT(&h, mask[i++]) — сработает не один раз. Правило общее
для любых макросов: в аргументы — только простые значения.
5.3 Атомарные версии
В заголовках большинства семейств рядом объявлены атомарные варианты. У F4 они такие:
#define ATOMIC_SET_BIT(REG, BIT) \
do { \
uint32_t val; \
do { \
val = __LDREXW((__IO uint32_t *)&(REG)) | (BIT); \
} while ((__STREXW(val,(__IO uint32_t *)&(REG))) != 0U); \
} while(0)
Работает это на паре инструкций LDREX/STREX: первая читает значение
и ставит «метку», вторая записывает только если метка цела. Снимают её
любое исключение, CLREX и другой STREX; если снята — цикл повторяется.
Так получается изменение регистра, которое не потеряется из-за прерывания
посередине. А вот запись от DMA или бит, который сама периферия поменяла
между чтением и записью, метку не снимают: от них эти команды не защищают.
У семейств на Cortex-M0/M0+ (F0, G0, C0, L0) команд LDREX/STREX нет, и те
же макросы там сделаны иначе: на время записи они закрывают прерывания
через PRIMASK.
Есть ATOMIC_SET_BIT, ATOMIC_CLEAR_BIT, ATOMIC_MODIFY_REG и их 16-битные
варианты.
⚠️ В старых заголовках F1 атомарных макросов нет. Если разделяемый регистр меняется и из основного кода, и из прерывания, на F1 придётся либо закрывать критическую секцию, либо пользоваться регистрами с раздельной установкой и сбросом — такими как
BSRRу GPIO, которым гонка не страшна по устройству.
Опыт в QEMU (_verify/rmw5/): основной код ставит бит 5, прерывание — бит 3, и прерывание
поднято ровно между чтением и записью. Обычное |= оставляет 0x20 — бит прерывания
затёрт; LDREX/STREX со второй попытки даёт 0x28. По руководству ядра метку
LDREX снимает любое исключение — поэтому первый STREX после прерывания не пишет.
5.4 «= или |=?»
Самый частый вопрос новичка. Правильный ответ зависит от регистра, и цена ошибки разная.
|= (чтение-изменение-запись) — когда в регистре живут чужие биты, которые
надо сохранить. Так настраивают почти всё: RCC->APB2ENR, GPIOx->CRL, TIMx->CR1.
RCC->APB2ENR |= RCC_APB2ENR_IOPCEN; /* включили порт C, не тронув остальные */
RCC->APB2ENR = RCC_APB2ENR_IOPCEN; /* включили порт C, ВЫКЛЮЧИЛИ всё прочее */
Вторая строка — классическая причина «работал UART, добавил порт — UART отвалился».
= (просто запись) — когда регистр не хранит состояния между записями.
Главный пример — BSRR у GPIO: он только для записи, чтение возвращает нули,
и каждая запись самостоятельна.
GPIOC->BSRR = GPIO_BSRR_BS13; /* правильно */
GPIOC->BSRR |= GPIO_BSRR_BS13; /* работает, но делает лишнее чтение шины */
Насколько «лишнее» — видно в следующем разделе, там это померено.
Правило. Если регистр хранит конфигурацию — читайте, меняйте, пишите
(|=, &=, MODIFY_REG). Если регистр чисто командный, без хранимых
настроек (BSRR, BRR, EXTI->PR, DMA1->IFCR), — пишите маску напрямую.
Бит запуска, который лежит рядом с настройками, ставят через |=: SWSTART
в ADC_CR2 у F1 и F4 соседствует с ADON, CONT и DMA, и
ADC1->CR2 = ADC_CR2_SWSTART выключит АЦП. Флаги, которые сбрасываются
записью нуля (TIMx->SR), снимают записью инверсии маски:
TIM2->SR = ~TIM_SR_UIF; — ноль только в свой бит, единицы в остальные
ничего не меняют. Операция &= здесь может стереть флаг, который железо
поднимет между чтением и записью.
5.5 Во что превращается каждый уровень
Возьмём одну задачу — установить вывод PA5 — и напишем её четырьмя способами.
Компилируем с -O2 под Cortex-M4 и смотрим, что получилось.
Регистр напрямую:
GPIOA->BSRR = GPIO_BSRR_BS5;
movs r0, #24
movt r0, #16386 @ адрес GPIOA->BSRR
movs r1, #32 @ маска бита 5
str r1, [r0] @ одна запись
bx lr
Макрос CMSIS:
SET_BIT(GPIOA->BSRR, GPIO_BSRR_BS5);
movs r0, #24
movt r0, #16386
ldr r1, [r0] @ ЛИШНЕЕ чтение
orr r1, r1, #32
str r1, [r0]
bx lr
LL:
LL_GPIO_SetOutputPin(GPIOA, LL_GPIO_PIN_5);
movs r0, #24
movt r0, #16386
movs r1, #32
str r1, [r0] @ ровно то же, что и напрямую
bx lr
HAL:
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET);
movs r0, #0
movt r0, #16386 @ адрес GPIOA
movs r1, #32 @ номер вывода
movs r2, #1 @ состояние
b HAL_GPIO_WritePin @ переход в функцию, которая ещё разбирает аргументы
Выводы, которые из этого следуют:
- LL не стоит ничего. Его функции объявлены
static inlineи после оптимизации дают тот же код, что прямая запись в регистр. Читаемость бесплатна. SET_BITнаBSRRхуже прямой записи. Макрос разворачивается в|=, а значит добавляет чтение регистра, который читать бессмысленно. Для конфигурационных регистровSET_BITуместен, для командных — нет.- HAL — это настоящий вызов функции, а внутри неё ещё проверка аргументов и ветвление. Для мигания светодиодом разница неощутима, но в обработчике прерывания, который срабатывает десятки тысяч раз в секунду, она становится заметной.
5.6 Что выбирать
| Задача | Уровень |
|---|---|
| учиться, понимать железо | регистры |
| горячий код: обработчики, узкие места | регистры или LL |
| обычная периферия в рабочем проекте | LL или HAL |
| USB, файловая система, Ethernet, графика | HAL и готовые стеки — писать это с нуля нерентабельно |
| перенос проекта между семействами STM32 | HAL |
Смешивать уровни можно и нормально: настроить периферию через CubeMX
и HAL, а внутри критичного прерывания дёрнуть BSRR напрямую. Это не «грязный
код», а обычная инженерная практика.
Небольшая оговорка про переносимость HAL. Единый интерфейс действительно позволяет перенести код между F4 и L4 малой кровью, но обещание работает не до конца: у семейств разный набор периферии, и функции вроде
HAL_GPIO_Initпринимают структуры с разными полями (у F1, например, нет поляAlternate). Перенос упрощается, но не становится бесплатным.
5.7 Проект, сгенерированный CubeMX
CubeMX — отдельная программа, в CubeIDE версий 1.x он был встроен, — по
схеме выводов и настройкам
генерирует готовый проект. Разберём настоящий: пример для платы на
STM32F407, который лежит в паке Keil. Все его файлы собираются armclang.
| Файл | Что в нём |
|---|---|
*.ioc |
все настройки CubeMX текстом «ключ=значение»: камень, выводы, NVIC, параметры проекта |
Src/main.c |
main, SystemClock_Config, функции MX_*_Init, Error_Handler |
Src/stm32f4xx_it.c |
обработчики прерываний и исключений |
Src/stm32f4xx_hal_msp.c |
HAL_MspInit и HAL_<блок>_MspInit: такты и выводы под периферию |
Inc/stm32f4xx_hal_conf.h |
какие модули HAL включены, частота кварца HSE_VALUE |
startup_*.s, system_stm32f4xx.c |
таблица векторов, код до main, SystemCoreClock (раздел 11.2) |
Блоки USER CODE. При каждой генерации файл переписывается целиком,
кроме участков между /* USER CODE BEGIN … */ и /* USER CODE END … */.
В main.c проекта таких блоков 17 — от Header до Error_Handler_Debug.
Строка вне блока исчезнет при следующей генерации без предупреждения.
Сохранность блоков включает настройка ProjectManager.KeepUserCode=true в
.ioc. Своё удобнее держать в отдельных файлах, а в блоках main.c
оставлять только вызовы.
Порядок запуска в main: HAL_Init → SystemClock_Config →
MX_GPIO_Init → бесконечный цикл.
HAL_Initвключает предвыборку и оба кэша Flash. Она ставит группы приоритетов NVIC — 4, все биты на вытеснение, — и запускает SysTick на 1 мс, пока от HSI. В конце вызываетHAL_MspInit. Задержку Flash она не трогает.SystemClock_Configнастраивает PLL: 25 МГц / 25 × 336 / 2 = 168 МГц, APB1 — 42 МГц, APB2 — 84 МГц. Она же ставит задержку FlashFLASH_LATENCY_5черезHAL_RCC_ClockConfig— «скорость Flash» настраивается именно здесь (раздел 6).MX_*_Init— периферия по схеме выводов; приставкаMXозначает, что функцию написал генератор.Error_Handler— сюда HAL приходит, если настройка не удалась. По умолчанию там вечный цикл; его заменяют своей реакцией — записью в журнал, сбросом через сторожевой таймер (раздел 12).
В stm32f4xx_it.c этого проекта только шесть исключений ядра:
SysTick, SVC и PendSV здесь забрала операционная система. В проекте
без неё тут же лежит SysTick_Handler с вызовом HAL_IncTick.
HAL_MspInit включает такт SYSCFG и PWR и ставит приоритеты SVCall 14 и
PendSV 15.
Слабые функции и обратные вызовы. HAL объявляет обработчики событий
слабыми (__weak) и пустыми: HAL_GPIO_EXTI_Callback,
HAL_UART_RxCpltCallback и другие. Чтобы получить событие, в своём файле
пишут функцию с тем же именем и той же сигнатурой, и компоновщик
предпочтёт её. Сам проект так заменяет HAL_GetTick на свою — в блоке
USER CODE 0, чтобы генерация её не стёрла.
static volatile uint32_t pressed;
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
if (GPIO_Pin == GPIO_PIN_2)
pressed = 1;
}
void handle_press(void);
/* Из главного цикла */
void poll_press(void)
{
if (pressed) {
pressed = 0; /* сначала сбросить, потом обработать */
handle_press();
}
}
/* Цепочка из вектора: EXTI2_IRQHandler → HAL → обратный вызов */
void EXTI2_IRQHandler(void)
{
HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_2);
}
HAL_GPIO_EXTI_IRQHandler сбрасывает флаг в EXTI и только потом зовёт
обратный вызов. Флаг программы сбрасывают до обработки, а не после:
иначе событие, пришедшее во время обработки, сотрётся. Вывод сравнивают
с маской GPIO_PIN_2 (0x0004), а не с номером вывода 2.
Опыт компоновкой. stm32f4xx_hal_gpio.c из пака скомпонован armlink
с тремя вариантами обработчика, результат прочитан по карте компоновщика:
| Что в своём файле | Что попало в образ |
|---|---|
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) |
наш обработчик; пустая слабая функция HAL выброшена |
void HAL_GPIO_EXTI_CallBack(uint16_t GPIO_Pin) — одна буква не та |
пустая слабая функция HAL, 2 байта — одна команда возврата; наш обработчик выброшен как неиспользуемый |
HAL_GPIO_EXTI_Callback(uint16_t pin) — без void |
не собирается: неявный int запрещён с C99, а тип спорит с объявлением в HAL |
⚠️ Опечатка в имени обратного вызова — самая тихая ошибка HAL. Ни ошибки, ни предупреждения: прерывание приходит, флаг в EXTI сбрасывается, а ваш код не вызывается никогда. Проверка — точка останова в обработчике или поиск своего имени в карте компоновщика. Ловит такую опечатку и ключ
-Wmissing-prototypes: у правильного имени есть объявление в заголовке HAL, у ошибочного — нет, и компилятор предупредит.
5.8 Грабли
READ_BIT(...) == 1 ложно почти всегда. Макрос возвращает маску — сравнивать с нулём (5.2).
Бит в общем регистре теряется при прерывании. В заголовках F1 нет атомарных макросов: критическая секция
или регистры с раздельной установкой и сбросом вроде BSRR (5.3).
Обратный вызов HAL не вызывается, ошибок нет. Опечатка в имени: HAL вызывает слабую заглушку. Проверка — точка останова в обработчике или своё имя в карте компоновщика (5.7).
5.9 Итоги
- CMSIS — не библиотека, а соглашение об именах; его заголовки подключены всегда, на них опираются и HAL, и LL.
- Макросы CMSIS — простые обёртки над битовыми операциями.
MODIFY_REGделает правильную последовательность «очистить → записать». - Атомарные версии макросов на
LDREX/STREX(у F4 и других) спасают от прерываний, но не от DMA; у F0, G0, C0 и L0 они сделаны черезPRIMASK, в старых заголовках F1 их нет. |=для конфигурационных регистров,=для командных. Запись=вместо|=вRCC->APB2ENRвыключает всю остальную периферию.- LL после оптимизации даёт тот же машинный код, что прямая запись в регистр, — читаемость достаётся бесплатно.
SET_BITнаBSRRдобавляет бессмысленное чтение: там нужна прямая запись.- HAL — вызов функции с проверками; для инициализации это незаметно, для горячего цикла — существенно.
- Уровни можно смешивать в одном проекте.
- Проект CubeMX:
.ioc— настройки,main.c— порядок запуска,*_it.c— обработчики,*_msp.c— такты и выводы периферии. Свой код — только в блокахUSER CODE. HAL_Initне настраивает задержку Flash — это делаетSystemClock_Config. Обратные вызовы HAL — слабые функции, и опечатка в имени молча оставляет пустую.
Дальше: раздел 5а «HAL в работе» — дескриптор и его состояния, три режима обмена, HAL_BUSY, перезапуск приёма в обратном вызове и часы HAL_GetTick.