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_TransmitwithHAL_MAX_DELAYspins 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/STRinstructions - 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 | 5× |
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
- Layered firmware architecture — HAL as the lowest layer, not the only layer
- ADC + DMA on STM32 — mixing HAL DMA setup with direct register access in callbacks
- Interface-based design — hiding HAL and registers behind ISensor/IStorage