Ложная дихотомия

Встраиваемое сообщество часто представляет 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 нс

Переключение 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 и регистрами принадлежит внутри слоя драйверов. Всё выше этого слоя видит только интерфейсы.

Что дальше