Ложная дихотомия
Встраиваемое сообщество часто представляет HAL vs регистры как религиозный спор. Сторонники HAL указывают на переносимость и сопровождаемость; сторонники доступа к регистрам — на размер кода и скорость. Оба правы в разных контекстах.
Полезный вопрос не «что лучше?», а «в каком слое моей прошивки должен жить каждый?»
Что даёт HAL
HAL STM32 (Hardware Abstraction Layer) оборачивает регистры периферии в C-функции с обработкой ошибок, логикой таймаутов и поддержкой DMA:
1// HAL: инициализация SPI и передача 8 байт
2HAL_SPI_Transmit(&hspi1, buffer, 8, HAL_MAX_DELAY);
Преимущества:
- Генерируется CubeMX — быстрая настройка
- Обрабатывает неочевидное: последовательность включения тактирования, маппинг альтернативной функции GPIO, приоритет прерываний
- Переносимость между семействами STM32 (в теории)
- Встроенные коды ошибок и обработка таймаутов
Стоимость:
- Размер кода: HAL добавляет 10–50 кБ flash в зависимости от используемых периферий
- Накладные расходы: функции HAL имеют валидацию параметров, проверки состояния и диспетчеризацию callback
- Блокирует по умолчанию:
HAL_SPI_TransmitсHAL_MAX_DELAYкрутится до завершения - Сгенерированный код многословен и труден для чтения
Что даёт прямой доступ к регистрам
Прямой доступ к регистрам работает с отображёнными в память регистрами периферии, определёнными в заголовках CMSIS:
1// Bare-metal: передача одного байта через SPI1 — неблокирующий
2while (!(SPI1->SR & SPI_SR_TXE)); // ждать пока TX-буфер пуст
3SPI1->DR = byte;
4while (!(SPI1->SR & SPI_SR_RXNE)); // ждать RX (очищается после чтения)
5(void)SPI1->DR; // прочитать и отбросить
Преимущества:
- Полный контроль: точное тактирование, нет скрытого состояния, нет callback-ов
- Нулевые накладные расходы — обращение к регистру компилируется в инструкции
LDR/STR - Небольшой размер кода
- Предсказуемо: что написали — то и выполняется
Стоимость:
- Больше кода для написания и сопровождения
- Обработка ошибок — вручную
- Перенос на другой МК означает переписывание доступа к регистрам
- Легко ошибиться: неправильный бит, неправильная последовательность, неправильный порядок
Проверка реальности производительности
Накладные расходы HAL реальны, но часто преувеличены. На Cortex-M4 на 168 МГц:
| Операция | HAL | Bare-metal | Разница |
|---|---|---|---|
| Переключение GPIO | ~400 нс | ~6 нс | 70× |
| Передача байта SPI (блокирующий) | ~2 мкс накладных | ~0 нс | — |
| Запуск АЦП + опрос | ~1 мкс накладных | ~200 нс | 5× |
Переключение GPIO через HAL действительно медленное — HAL_GPIO_TogglePin читает ODR,
применяет XOR к биту, записывает обратно, с накладными расходами вызова функции. Плотная
реализация bit-bang SPI не может использовать HAL.
Для передачи данных через периферию (SPI DMA, I2C DMA, UART DMA), накладные расходы приходятся на вызов инициализации — сама передача идентична, так как DMA работает независимо. Накладные расходы HAL несущественны, когда передача запущена.
Когда использовать HAL
Код инициализации — всегда. Используйте CubeMX для генерации MX_SPI1_Init(),
MX_DMA_Init(), MX_GPIO_Init(). Они вызываются один раз при старте.
Стоимость инициализации HAL нулевая во время работы.
Передачи через DMA — HAL подходит. Накладные расходы приходятся на запуск передачи;
сама передача — аппаратная. HAL_SPI_Transmit_DMA + callback завершения чище, чем
ручная настройка регистров DMA.
Некритичные по времени периферии — логирование UART, I2C-настройка сенсора при запуске, одиночные чтения АЦП. Накладные расходы в десятки микросекунд не важны.
Быстрое прототипирование — всегда. Сначала заставьте работать с HAL. Оптимизируйте позже по данным профилировщика.
Когда переходить на bare-metal
Bit-bang протоколы — если переключаете GPIO быстрее ~1 мкс,
HAL_GPIO_WritePin слишком медленный. Используйте GPIOA->BSRR напрямую:
1// Быстрый GPIO — однотактовый на Cortex-M4
2inline void pinHigh(GPIO_TypeDef* port, uint16_t pin) {
3 port->BSRR = pin; // установить
4}
5inline void pinLow(GPIO_TypeDef* port, uint16_t pin) {
6 port->BSRR = (uint32_t)pin << 16; // сбросить
7}
Плотный код ISR — обработчики прерываний работают за счёт другой работы. ISR АЦП или таймера, который должен завершиться менее чем за 1 мкс, не должен вызывать функции HAL. Читайте регистр напрямую:
1void TIM2_IRQHandler() {
2 TIM2->SR &= ~TIM_SR_UIF; // очистить флаг прерывания обновления — одна запись регистра
3 stepMotor();
4}
5// vs. HAL_TIM_IRQHandler(&htim2) который диспетчеризует через несколько callback-ов
Критичные для производительности пути — если профилировщик показывает, что HAL является узким местом, замените его доступом к регистрам. Не раньше.
Многоуровневый подход
Не выбирайте одно или другое глобально. Разложите по слоям:
┌─────────────────────────────────────┐
│ Приложение / бизнес-логика │ ← ничего не знает о железе
├─────────────────────────────────────┤
│ Слой драйверов (ваша абстракция) │ ← интерфейсы ISensor, IStorage, IDisplay
├─────────────────────────────────────┤
│ Обёртки HAL │ доступ к регистрам │ ← смешано по мере надобности
├─────────────────────────────────────┤
│ STM32 HAL / CMSIS │ ← сгенерировано, не редактируется вручную
└─────────────────────────────────────┘
Слой драйверов владеет решением. Bmp280Driver использует I2C DMA-передачу HAL
внутри — приложение никогда не знает. StepperDriver использует bare-metal GPIO,
потому что нужна субмикросекундная точность — приложение никогда не знает.
1// Драйвер, использующий HAL для DMA — приложение не видит HAL
2class Bmp280Driver : public ISensor {
3public:
4 float read() override {
5 HAL_I2C_Mem_Read(&hi2c1, BMP280_ADDR, REG_TEMP, 1, rawBuf_, 6, 10);
6 return compensate(rawBuf_);
7 }
8};
9
10// Драйвер, использующий регистры для тактирования — приложение не видит регистры
11class WS2812Driver {
12public:
13 void send(uint32_t color) {
14 for (int i = 23; i >= 0; --i) {
15 if ((color >> i) & 1) sendOne();
16 else sendZero();
17 }
18 }
19private:
20 void sendOne() {
21 DIN_PORT->BSRR = DIN_PIN; // высокий — 800 нс
22 asm volatile("nop\n" /* × N */);
23 DIN_PORT->BSRR = DIN_PIN << 16; // низкий — 450 нс
24 }
25};
Оба реализуют один и тот же IDisplay или доменный интерфейс. Бизнес-логика не видит
ни HAL, ни регистры.
Типичные ловушки HAL
Забыть включить тактирование перед использованием периферии:
1// Функции инициализации HAL вызывают __HAL_RCC_GPIOA_CLK_ENABLE() за вас
2// Но если обращаетесь к GPIO до MX_GPIO_Init() — тихий сбой
Использование HAL_MAX_DELAY в производстве:
1HAL_I2C_Mem_Read(&hi2c1, addr, reg, 1, buf, 1, HAL_MAX_DELAY);
2// Если устройство отсутствует, это блокирует навсегда — сторожевой таймер перезагружает систему
3// Используйте реальный таймаут: 10 мс обычно достаточно для I2C
Вызов функций HAL из ISR: Большинство функций HAL небезопасны для ISR. Используйте DMA с callback-ами вместо опрашивающих функций HAL в контексте прерывания.
Редактирование сгенерированного CubeMX кода вне пользовательских секций:
CubeMX регенерирует main.c, stm32f4xx_hal_msp.c и т.д. — перезапишет ручные изменения.
Всегда помещайте свой код в секции /* USER CODE BEGIN */ или в отдельные файлы.
Практическое правило
Инициализация, одиночные передачи, некритичный I/O → HAL
ISR-обработчики, плотные циклы, субмикросекундное тактирование → регистры
Всё остальное → сначала HAL, профилируйте, затем решайте
Граница между HAL и регистрами принадлежит внутри слоя драйверов. Всё выше этого слоя видит только интерфейсы.
Что дальше
- Многоуровневая архитектура прошивки — HAL как нижний слой, а не единственный
- АЦП + DMA на STM32 — смешивание DMA-настройки HAL с прямым доступом к регистрам в callback-ах
- Проектирование на основе интерфейсов — скрытие HAL и регистров за ISensor/IStorage