The false dichotomy

The embedded community often frames HAL vs registers as a religious debate. HAL advocates point to portability and maintenance; register-access advocates point to code size and speed. Both are right in different contexts.

The useful question is not “which is better?” but “which layer of my firmware should each one live in?”


What HAL gives you

STM32 HAL (Hardware Abstraction Layer) wraps the peripheral registers in C functions with error handling, timeout logic, and DMA plumbing:

1// HAL: initialise SPI and transmit 8 bytes
2HAL_SPI_Transmit(&hspi1, buffer, 8, HAL_MAX_DELAY);

Advantages:

  • Generated by CubeMX — fast to configure
  • Handles the non-obvious: clock enable sequence, GPIO alternate function mapping, interrupt priority
  • Portable across STM32 families (in theory)
  • Error codes and timeout handling built-in

Cost:

  • Code size: HAL adds 10–50 kB of flash depending on peripherals used
  • Runtime overhead: HAL functions have parameter validation, state checks, and callback dispatch
  • Blocking by default: HAL_SPI_Transmit with HAL_MAX_DELAY spins until done
  • Generated code is verbose and hard to read

What bare-metal register access gives you

Direct register access operates on the memory-mapped peripheral registers defined in the CMSIS headers:

1// Bare-metal: transmit one byte via SPI1 — non-blocking
2while (!(SPI1->SR & SPI_SR_TXE));   // wait for TX buffer empty
3SPI1->DR = byte;
4while (!(SPI1->SR & SPI_SR_RXNE));  // wait for RX (clears after read)
5(void)SPI1->DR;                     // read and discard

Advantages:

  • Full control: exact timing, no hidden state, no callbacks
  • Zero overhead — the register access compiles to LDR/STR instructions
  • Small code size
  • Predictable: what you write is what runs

Cost:

  • More code to write and maintain
  • Error handling is manual
  • Porting to another MCU means rewriting the register access
  • Easy to get wrong: wrong bit, wrong sequence, wrong order

Performance reality check

HAL overhead is real but often overstated. On a 168 MHz Cortex-M4:

Operation HAL Bare-metal Difference
GPIO toggle ~400 ns ~6 ns 70×
SPI byte transfer (blocking) ~2 µs overhead ~0 ns
ADC start + poll ~1 µs overhead ~200 ns

GPIO toggle through HAL is genuinely slow — HAL_GPIO_TogglePin reads the ODR, XORs the bit, writes it back, with a function call overhead. A tight bit-bang SPI implementation cannot use HAL.

For peripheral transfers (SPI DMA, I2C DMA, UART DMA), the overhead is the initialisation call — the transfer itself is identical since the DMA runs independently. HAL overhead is irrelevant once the transfer is in flight.


When to use HAL

Initialisation code — always. Use CubeMX to generate the MX_SPI1_Init(), MX_DMA_Init(), MX_GPIO_Init() functions. These are called once at startup. The startup cost of HAL is zero at runtime.

DMA-based transfers — HAL is fine. The overhead is at the start of the transfer; the transfer itself is hardware. HAL_SPI_Transmit_DMA + completion callback is cleaner than setting up DMA registers manually.

Non-time-critical peripherals — UART logging, I2C configuration of a sensor at startup, one-shot ADC reads. The tens-of-microseconds overhead doesn’t matter.

Rapid prototyping — always. Get it working with HAL first. Optimise later with profiler data.


When to go bare-metal

Bit-bang protocols — if you’re toggling GPIOs faster than ~1 µs, HAL_GPIO_WritePin is too slow. Use GPIOA->BSRR directly:

1// Fast GPIO — single-cycle on Cortex-M4
2inline void pinHigh(GPIO_TypeDef* port, uint16_t pin) {
3    port->BSRR = pin;          // set
4}
5inline void pinLow(GPIO_TypeDef* port, uint16_t pin) {
6    port->BSRR = (uint32_t)pin << 16;  // reset
7}

Tight ISR code — interrupt handlers run at the expense of other work. An ADC or timer ISR that must complete in < 1 µs should not call HAL functions. Read the register directly:

1void TIM2_IRQHandler() {
2    TIM2->SR &= ~TIM_SR_UIF;   // clear update interrupt flag — one register write
3    stepMotor();
4}
5// vs. HAL_TIM_IRQHandler(&htim2) which dispatches through multiple callbacks

Performance-critical paths — if a profiler shows HAL is the bottleneck, replace it with register access. Not before.


The layered approach

Don’t choose one or the other globally. Layer them:

┌─────────────────────────────────────┐
│  Application / business logic       │  ← knows nothing about hardware
├─────────────────────────────────────┤
│  Driver layer (your abstraction)    │  ← ISensor, IStorage, IDisplay interfaces
├─────────────────────────────────────┤
│  HAL wrappers  │  register access   │  ← mixed as needed per peripheral
├─────────────────────────────────────┤
│  STM32 HAL / CMSIS                  │  ← generated, not hand-edited
└─────────────────────────────────────┘

The driver layer owns the decision. Bmp280Driver uses HAL’s I2C DMA transfer internally — the application never knows. StepperDriver uses bare-metal GPIO because it needs sub-microsecond timing — the application never knows.

 1// Driver using HAL for DMA — application doesn't see 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// Driver using registers for timing — application doesn't see registers
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;          // high — 800 ns
22        asm volatile("nop\n" /* × N */);
23        DIN_PORT->BSRR = DIN_PIN << 16;    // low — 450 ns
24    }
25};

Both implement the same IDisplay or domain interface. The business logic sees neither HAL nor registers.


Common HAL pitfalls

Forgetting to enable clocks before using peripherals:

1// HAL init functions call __HAL_RCC_GPIOA_CLK_ENABLE() for you
2// But if you access GPIO before MX_GPIO_Init() — silent failure

Using HAL_MAX_DELAY in production:

1HAL_I2C_Mem_Read(&hi2c1, addr, reg, 1, buf, 1, HAL_MAX_DELAY);
2// If device is absent, this blocks forever — watchdog resets the system
3// Use a real timeout: 10 ms is usually enough for I2C

Calling HAL functions from ISRs: Most HAL functions are not ISR-safe. Use DMA with callbacks instead of polling HAL functions in interrupt context.

Editing CubeMX-generated code outside user sections: CubeMX regenerates main.c, stm32f4xx_hal_msp.c etc. — it will overwrite manual changes. Always put your code in /* USER CODE BEGIN */ sections or in separate files.


Practical rule

Initialisation, one-shot transfers, non-critical I/O  →  HAL
ISR handlers, tight loops, sub-microsecond timing     →  registers
Everything else                                        →  HAL first, profile, then decide

The boundary between HAL and registers belongs inside the driver layer. Everything above that layer sees only interfaces.

What’s next