Every embedded engineer has lived through this moment: your company’s chip supplier emails you that the MCU you designed around is end-of-life, or lead times just jumped to 52 weeks. You stare at 40,000 lines of firmware that call HAL_SPI_Transmit() in 200 places, and you realize your “abstraction” was just ST’s abstraction. You’re locked in.

I’ve spent years writing the mid-layer software that sits between customer applications and firmware — the abstraction that’s supposed to make hardware swappable. I’ve seen what works, what doesn’t, and what looks elegant in a conference talk but falls apart when you actually need to port to a different silicon vendor. This post is the guide I wish I’d had five years ago.

The Problem Statement

A hardware abstraction layer has one job: let the code above it not care which chip is below it. That sounds simple. It isn’t. The tension is between three competing goals:

  1. Portability — the whole point. Write once, run on STM32, nRF, ESP32, RP2040.
  2. Performance — embedded systems have hard timing constraints. Every layer of indirection costs cycles.
  3. Expressiveness — different MCUs have different capabilities. Your abstraction either exposes them (and leaks hardware details) or hides them (and loses functionality).

Pick two. The art is in knowing which two matter for your project.

The Landscape: How Everyone Else Does It

Before building something custom, let’s look at what exists and where each approach breaks down.

CMSIS: The Lowest Common Denominator

ARM’s Cortex Microcontroller Software Interface Standard gives you register-level access with consistent naming. It’s not really an abstraction — it’s a naming convention for registers.

// CMSIS-style GPIO toggle on STM32
GPIOA->ODR ^= GPIO_PIN_5;

// Same idea on an LPC
LPC_GPIO0->FIOPIN ^= (1 << 22);

CMSIS standardizes the Cortex-M core peripherals (NVIC, SysTick, MPU) beautifully. But GPIO? SPI? UART? Those are vendor peripherals, and CMSIS doesn’t touch them. Every vendor’s register map is different, and CMSIS doesn’t help you bridge that gap.

Verdict: CMSIS is a foundation, not a HAL. You still need something on top.

STM32 HAL: The Golden Handcuffs

ST’s HAL is the most widely used abstraction in the embedded world, mostly because STM32 is the most widely used MCU family. It’s comprehensive, well-documented, and it will absolutely destroy your portability.

// STM32 HAL SPI transmit
SPI_HandleTypeDef hspi1;
uint8_t tx_buf[] = {0xAA, 0xBB, 0xCC};
HAL_SPI_Transmit(&hspi1, tx_buf, sizeof(tx_buf), HAL_MAX_DELAY);

The problem isn’t that HAL_SPI_Transmit is a bad API. It’s actually pretty good. The problem is that SPI_HandleTypeDef contains 15 fields that are deeply STM32-specific — the prescaler values map to ST’s clock tree, the alternate function pin mappings are ST-specific, and the DMA channel configuration assumes ST’s DMA controller topology.

When you call HAL_SPI_Init(), you’re committing to ST’s entire initialization model. Every file that touches that handle is now ST-locked, even if it never reads a vendor-specific field.

I’ve seen codebases where the “platform-independent” business logic imports stm32f4xx_hal.h because someone passed an SPI_HandleTypeDef* through three layers of function calls. That’s the lock-in. It’s not the function call — it’s the type that leaks upward.

Arduino: Simplicity at a Cost

Arduino’s approach is the opposite extreme: hide everything behind the simplest possible API.

// Arduino SPI
SPI.begin();
SPI.transfer(0xAA);

// Arduino GPIO
digitalWrite(13, HIGH);

This is genuinely portable — Arduino runs on AVR, SAMD, ESP32, RP2040, STM32, and more. But the cost is severe. digitalWrite() is famously slow (50-80 cycles on AVR vs. 1-2 cycles for direct port manipulation) because it does a pin lookup table traversal at runtime. You can’t configure DMA-driven SPI transfers. You can’t set up half-duplex mode. You can’t do pin-level interrupts with configurable edge detection on some platforms.

Arduino proves that you can build a universal HAL, but it also proves that “universal” often means “universally limited.”

Zephyr: Device Tree and the Nuclear Option

Zephyr RTOS takes the most sophisticated approach: device tree bindings (borrowed from Linux) combined with a driver model that separates API, driver implementation, and hardware description.

// Zephyr GPIO — device tree driven
const struct device *gpio_dev = DEVICE_DT_GET(DT_NODELABEL(gpio0));
gpio_pin_configure(gpio_dev, PIN, GPIO_OUTPUT_ACTIVE);
gpio_pin_set(gpio_dev, PIN, 1);

// Zephyr SPI — same pattern
const struct device *spi_dev = DEVICE_DT_GET(DT_NODELABEL(spi1));
struct spi_buf tx_buf = { .buf = data, .len = sizeof(data) };
struct spi_buf_set tx = { .buffers = &tx_buf, .count = 1 };
struct spi_config cfg = {
    .frequency = 1000000,
    .operation = SPI_WORD_SET(8) | SPI_TRANSFER_MSB,
};
spi_write(spi_dev, &cfg, &tx);

The hardware description lives in .dts files:

&spi1 {
    status = "okay";
    cs-gpios = <&gpio0 4 GPIO_ACTIVE_LOW>;
    my_sensor: sensor@0 {
        compatible = "bosch,bme280";
        reg = <0>;
        spi-max-frequency = <1000000>;
    };
};

This is the gold standard for portability. The same application code runs on any board with a Zephyr BSP. Swapping MCUs means changing the device tree overlay and the board config — zero application code changes.

But Zephyr is an entire operating system. You’re adopting a build system (west + CMake + Kconfig), a threading model, a memory management policy, a logging framework, and a driver model. For a blinking LED project, this is absurd. For a product with 3+ year lifecycle and potential MCU swaps, it might be exactly right.

Verdict: If you can afford the complexity, Zephyr’s model is the best-designed HAL in the embedded ecosystem. But “can you afford the complexity” is doing a lot of heavy lifting in that sentence.

Hand-Rolled: The Default Choice

Most production firmware I’ve worked on uses a hand-rolled HAL. Not because engineers sat down and designed one, but because someone created hal_gpio.h one afternoon and it grew organically. These range from elegant to horrifying.

The horrifying ones look like this:

// The "abstraction" that isn't
#ifdef STM32F4
    #include "stm32f4xx_hal.h"
    #define MY_SPI_HANDLE hspi1
    #define MY_SPI_TRANSMIT(buf, len) HAL_SPI_Transmit(&MY_SPI_HANDLE, buf, len, 1000)
#elif defined(NRF52)
    #include "nrfx_spi.h"
    #define MY_SPI_HANDLE m_spi
    #define MY_SPI_TRANSMIT(buf, len) nrfx_spi_xfer(&MY_SPI_HANDLE, \
        &(nrfx_spi_xfer_desc_t){.p_tx_buffer = buf, .tx_length = len}, 0)
#endif

This is a thin preprocessor skin over vendor APIs. It “works” until you need error handling (each vendor returns errors differently), or async transfers (each vendor’s callback model is different), or you add a third platform. The #ifdef jungle grows until no one can reason about it.

What Actually Works: Building a Portable GPIO + SPI Abstraction

Here’s how I’d design a HAL for a team that needs to support 2-3 MCU families without adopting Zephyr. This is the pattern I’ve used in production.

Principle 1: Your Types, Not Theirs

The single most important rule: never let a vendor type appear in your public API. The moment SPI_HandleTypeDef or nrfx_spi_t shows up in a header that application code includes, you’re locked in.

Define your own types. They can be thin wrappers — that’s fine. But they’re yours.

// hal/hal_gpio.h — YOUR public API
#pragma once
#include <stdint.h>
#include <stdbool.h>

typedef enum {
    HAL_GPIO_MODE_INPUT,
    HAL_GPIO_MODE_OUTPUT_PP,   // push-pull
    HAL_GPIO_MODE_OUTPUT_OD,   // open-drain
    HAL_GPIO_MODE_AF,          // alternate function (SPI, I2C, etc.)
} hal_gpio_mode_t;

typedef enum {
    HAL_GPIO_PULL_NONE,
    HAL_GPIO_PULL_UP,
    HAL_GPIO_PULL_DOWN,
} hal_gpio_pull_t;

typedef struct {
    uint8_t port;    // 0 = GPIOA / P0, 1 = GPIOB / P1, etc.
    uint8_t pin;     // 0-15 typically
} hal_gpio_pin_t;

typedef struct {
    hal_gpio_mode_t mode;
    hal_gpio_pull_t pull;
    uint8_t af_num;  // alternate function number (ignored if mode != AF)
} hal_gpio_config_t;

// API
int hal_gpio_init(hal_gpio_pin_t pin, const hal_gpio_config_t *cfg);
int hal_gpio_write(hal_gpio_pin_t pin, bool state);
bool hal_gpio_read(hal_gpio_pin_t pin);
int hal_gpio_toggle(hal_gpio_pin_t pin);

Notice what’s missing: no GPIO_TypeDef*, no nrf_gpio_pin_dir_t, no vendor anything. Application code includes this header and only this header.

Principle 2: One Implementation File Per Platform

Each platform gets its own .c file. The build system picks which one to compile.

// hal/stm32/hal_gpio_stm32.c
#include "hal/hal_gpio.h"
#include "stm32f4xx_hal.h"   // vendor header — confined to this file

static GPIO_TypeDef* port_map[] = { GPIOA, GPIOB, GPIOC, GPIOD, GPIOE };

int hal_gpio_init(hal_gpio_pin_t pin, const hal_gpio_config_t *cfg) {
    if (pin.port >= sizeof(port_map)/sizeof(port_map[0])) return -1;

    // Enable clock — this is the kind of vendor-specific detail
    // that MUST live in the platform file
    __HAL_RCC_GPIOA_CLK_ENABLE();  // simplified; real code uses port index

    GPIO_InitTypeDef gpio_init = {
        .Pin  = (1U << pin.pin),
        .Mode = (cfg->mode == HAL_GPIO_MODE_OUTPUT_PP) ? GPIO_MODE_OUTPUT_PP :
                (cfg->mode == HAL_GPIO_MODE_OUTPUT_OD) ? GPIO_MODE_OUTPUT_OD :
                (cfg->mode == HAL_GPIO_MODE_AF)        ? GPIO_MODE_AF_PP :
                                                         GPIO_MODE_INPUT,
        .Pull = (cfg->pull == HAL_GPIO_PULL_UP)   ? GPIO_PULLUP :
                (cfg->pull == HAL_GPIO_PULL_DOWN) ? GPIO_PULLDOWN :
                                                    GPIO_NOPULL,
        .Speed = GPIO_SPEED_FREQ_HIGH,
        .Alternate = cfg->af_num,
    };
    HAL_GPIO_Init(port_map[pin.port], &gpio_init);
    return 0;
}

int hal_gpio_write(hal_gpio_pin_t pin, bool state) {
    HAL_GPIO_WritePin(port_map[pin.port], (1U << pin.pin),
                      state ? GPIO_PIN_SET : GPIO_PIN_RESET);
    return 0;
}

bool hal_gpio_read(hal_gpio_pin_t pin) {
    return HAL_GPIO_ReadPin(port_map[pin.port], (1U << pin.pin)) == GPIO_PIN_SET;
}

int hal_gpio_toggle(hal_gpio_pin_t pin) {
    HAL_GPIO_TogglePin(port_map[pin.port], (1U << pin.pin));
    return 0;
}
// hal/nrf52/hal_gpio_nrf52.c
#include "hal/hal_gpio.h"
#include "nrf_gpio.h"

// nRF uses a flat pin numbering: port * 32 + pin
static inline uint32_t to_nrf_pin(hal_gpio_pin_t p) {
    return (uint32_t)p.port * 32 + p.pin;
}

int hal_gpio_init(hal_gpio_pin_t pin, const hal_gpio_config_t *cfg) {
    uint32_t npin = to_nrf_pin(pin);
    nrf_gpio_pin_pull_t pull =
        (cfg->pull == HAL_GPIO_PULL_UP)   ? NRF_GPIO_PIN_PULLUP :
        (cfg->pull == HAL_GPIO_PULL_DOWN) ? NRF_GPIO_PIN_PULLDOWN :
                                            NRF_GPIO_PIN_NOPULL;
    if (cfg->mode == HAL_GPIO_MODE_INPUT) {
        nrf_gpio_cfg_input(npin, pull);
    } else {
        nrf_gpio_cfg_output(npin);
    }
    return 0;
}

int hal_gpio_write(hal_gpio_pin_t pin, bool state) {
    state ? nrf_gpio_pin_set(to_nrf_pin(pin))
          : nrf_gpio_pin_clear(to_nrf_pin(pin));
    return 0;
}

bool hal_gpio_read(hal_gpio_pin_t pin) {
    return nrf_gpio_pin_read(to_nrf_pin(pin)) != 0;
}

int hal_gpio_toggle(hal_gpio_pin_t pin) {
    nrf_gpio_pin_toggle(to_nrf_pin(pin));
    return 0;
}

The application code is identical regardless of which platform file is compiled. That’s the whole point.

Principle 3: SPI — Where It Gets Interesting

SPI is harder than GPIO because it has more modes, needs error handling, and often involves DMA for performance. Here’s a practical abstraction:

// hal/hal_spi.h
#pragma once
#include "hal/hal_gpio.h"
#include <stdint.h>
#include <stddef.h>

typedef struct hal_spi hal_spi_t;  // opaque — defined per platform

typedef enum {
    HAL_SPI_MODE_0,  // CPOL=0, CPHA=0
    HAL_SPI_MODE_1,  // CPOL=0, CPHA=1
    HAL_SPI_MODE_2,  // CPOL=1, CPHA=0
    HAL_SPI_MODE_3,  // CPOL=1, CPHA=1
} hal_spi_mode_t;

typedef enum {
    HAL_SPI_BIT_ORDER_MSB_FIRST,
    HAL_SPI_BIT_ORDER_LSB_FIRST,
} hal_spi_bit_order_t;

typedef struct {
    uint32_t            max_freq_hz;
    hal_spi_mode_t      mode;
    hal_spi_bit_order_t bit_order;
    hal_gpio_pin_t      cs_pin;       // managed by HAL, not by caller
    bool                cs_active_low;
} hal_spi_config_t;

typedef void (*hal_spi_callback_t)(int status, void *ctx);

// Lifecycle
hal_spi_t *hal_spi_open(uint8_t instance, const hal_spi_config_t *cfg);
void       hal_spi_close(hal_spi_t *spi);

// Blocking
int hal_spi_transfer(hal_spi_t *spi, const uint8_t *tx, uint8_t *rx, size_t len);
int hal_spi_write(hal_spi_t *spi, const uint8_t *tx, size_t len);
int hal_spi_read(hal_spi_t *spi, uint8_t *rx, size_t len);

// Async (optional — returns -ENOTSUP if platform doesn't support it)
int hal_spi_transfer_async(hal_spi_t *spi, const uint8_t *tx, uint8_t *rx,
                           size_t len, hal_spi_callback_t cb, void *ctx);

Key design decisions here:

Opaque handle. hal_spi_t is forward-declared in the header and defined in each platform’s .c file. This is the critical firewall — application code can’t reach into the handle and touch vendor-specific fields because it doesn’t know what they are.

CS pin management. The HAL asserts/deasserts chip select. This sounds minor but it’s one of the most common sources of porting bugs. Different vendors handle CS differently (hardware vs. software, active high vs. low), and if your application code manages CS directly, every call site needs platform ifdefs.

Async as optional. Not every platform supports DMA-driven SPI with the same callback model. Rather than forcing a lowest-common-denominator async API, we let it return -ENOTSUP and let the caller fall back to blocking. Pragmatic beats pure.

The Dispatch Question: Compile-Time vs. Runtime

This is where HAL design gets philosophical. How does hal_spi_transfer() know which implementation to call?

The simplest approach: only one platform .c file is compiled into the binary. The linker resolves hal_spi_transfer to whichever implementation was compiled.

# Makefile
ifeq ($(PLATFORM),stm32)
    HAL_SRC = hal/stm32/hal_spi_stm32.c hal/stm32/hal_gpio_stm32.c
else ifeq ($(PLATFORM),nrf52)
    HAL_SRC = hal/nrf52/hal_spi_nrf52.c hal/nrf52/hal_gpio_nrf52.c
endif

Pros: Zero overhead. No function pointers, no vtables, no indirection. The compiler can inline everything. This is what you want for hard real-time systems where every cycle matters.

Cons: You can only target one platform per binary. You can’t have a test binary that mocks the hardware — unless you add a hal/mock/ platform and compile against that.

This is the approach I recommend for 90% of projects. The “one platform per binary” limitation sounds restrictive until you realize that’s what you’re doing anyway — you don’t ship the same .elf to an STM32 and an nRF52.

Option B: Function Pointer Tables

// hal/hal_spi.h (function pointer variant)
typedef struct {
    hal_spi_t* (*open)(uint8_t instance, const hal_spi_config_t *cfg);
    void       (*close)(hal_spi_t *spi);
    int        (*transfer)(hal_spi_t *spi, const uint8_t *tx, uint8_t *rx, size_t len);
    int        (*write)(hal_spi_t *spi, const uint8_t *tx, size_t len);
    int        (*read)(hal_spi_t *spi, uint8_t *rx, size_t len);
} hal_spi_ops_t;

// Each platform registers its ops
extern const hal_spi_ops_t hal_spi_ops;

// Application code calls through the table
#define hal_spi_transfer(spi, tx, rx, len) hal_spi_ops.transfer(spi, tx, rx, len)

Pros: You can swap implementations at link time or at runtime. Makes mocking trivial for tests — just plug in a mock ops table. This is essentially what Zephyr does under the hood.

Cons: One level of pointer indirection per call. On a Cortex-M4 at 168 MHz, this is ~2-5 extra cycles — irrelevant for SPI (the bus itself is the bottleneck) but potentially meaningful for GPIO bit-banging in a tight loop.

Option C: C++ Virtual Dispatch (vtable)

If you’re in C++ land:

class ISpiDriver {
public:
    virtual ~ISpiDriver() = default;
    virtual int transfer(const uint8_t *tx, uint8_t *rx, size_t len) = 0;
    virtual int write(const uint8_t *tx, size_t len) = 0;
};

class Stm32SpiDriver : public ISpiDriver { /* ... */ };
class Nrf52SpiDriver : public ISpiDriver { /* ... */ };

This is functionally identical to Option B but with language support for the dispatch. Same overhead, slightly cleaner syntax, and you get type safety on the ops table for free. The downside is that many embedded teams avoid C++ virtual dispatch because the vtable pointer adds 4 bytes per object instance, and the compiler-generated vtable code is harder to audit in safety-critical contexts.

Option D: Preprocessor Switching

// hal_spi.h
#if defined(PLATFORM_STM32)
    #include "hal/stm32/hal_spi_stm32_inline.h"
#elif defined(PLATFORM_NRF52)
    #include "hal/nrf52/hal_spi_nrf52_inline.h"
#endif

This is compile-time dispatch with the implementations inlined into the header. Maximum performance (everything can be inlined and optimized), but it means your public headers pull in vendor headers transitively. The whole point of the abstraction was to avoid this. Use this only for truly hot-path operations where the function call overhead is measured and proven to matter.

What I’d Actually Recommend

After shipping firmware on STM32, nRF, TI, and Renesas parts, here’s my practical decision tree:

If you’re starting a new product with potential for MCU changes (most products):

Use compile-time dispatch (Option A) with the opaque handle pattern. Define your own types. One .c per platform, selected by the build system. This gives you zero overhead, clean separation, and easy porting. When you need to test, add a hal/mock/ platform with stub implementations.

If you’re building a framework or SDK that ships to other developers:

Use function pointer tables (Option B). Your users might need to mock, extend, or substitute drivers. The pointer indirection cost is negligible for anything above bit-bang-speed peripherals. This is the right tradeoff for flexibility.

If you’re already using Zephyr or considering an RTOS:

Just use Zephyr’s driver model. Seriously. Don’t build a second HAL on top of Zephyr’s HAL — that’s two layers of abstraction for the same job. Zephyr’s device tree + driver API is the most well-designed HAL in the ecosystem. The cost is adopting Zephyr, but if you’re already there, you’ve already paid it.

If your product will only ever run on one MCU family:

Don’t build a HAL at all. Use the vendor HAL (STM32 HAL, nrfx, ESP-IDF drivers) directly. A HAL is insurance against hardware changes. If that risk is genuinely zero, the insurance premium (development time, code complexity, debugging difficulty) isn’t worth paying.

Common Mistakes

A few patterns I’ve seen fail repeatedly:

Leaking vendor types through “convenience” macros. Someone adds #define MY_SPI hspi1 and now every file that uses MY_SPI transitively depends on ST’s headers. The macro looked harmless. It wasn’t.

Over-abstracting initialization. Init is where platforms differ the most (clock trees, pin muxing, DMA channel allocation). Trying to make init generic leads to massive config structs that are just as vendor-specific as the original API but harder to understand. Let init be platform-specific. Abstract the operations, not the setup.

Abstracting too early. Don’t build a HAL on day one of a project. Write directly against the vendor HAL, get the product working, then extract the abstraction boundary once you can see which operations are actually used. Premature abstraction creates APIs that don’t match real usage patterns.

Ignoring error semantics. STM32 HAL returns HAL_StatusTypeDef (OK, ERROR, BUSY, TIMEOUT). nrfx returns nrfx_err_t. ESP-IDF returns esp_err_t. If your HAL just returns int with 0 for success and -1 for failure, you’ve lost the ability to distinguish between “bus busy, retry later” and “hardware fault, pin not configured.” Define your own error codes that capture the categories your application actually needs to handle.

The Test Story

The strongest argument for a clean HAL isn’t portability — it’s testability. With the opaque handle pattern and compile-time dispatch, you can create a hal/mock/ implementation that:

  • Records every call (what was written, to which pin/bus)
  • Returns scripted responses (inject error conditions)
  • Validates sequencing (CS asserted before transfer, released after)
  • Runs on your development machine, not on target hardware

This means your application logic — the part that talks to sensors, runs protocols, makes decisions — can be tested on x86 with a standard test framework. No JTAG required. No waiting for hardware. No “it works on my board” debugging.

// hal/mock/hal_spi_mock.c
static struct {
    uint8_t tx_log[4096];
    size_t  tx_log_pos;
    uint8_t rx_script[4096];
    size_t  rx_script_pos;
    int     next_error;
} mock_state;

int hal_spi_transfer(hal_spi_t *spi, const uint8_t *tx, uint8_t *rx, size_t len) {
    if (mock_state.next_error) {
        int err = mock_state.next_error;
        mock_state.next_error = 0;
        return err;
    }
    memcpy(&mock_state.tx_log[mock_state.tx_log_pos], tx, len);
    mock_state.tx_log_pos += len;
    memcpy(rx, &mock_state.rx_script[mock_state.rx_script_pos], len);
    mock_state.rx_script_pos += len;
    return 0;
}

// Test helper: inject an error on the next call
void mock_spi_set_next_error(int err) {
    mock_state.next_error = err;
}

This mock compiles and runs on any platform. Your CI pipeline catches protocol bugs before hardware is even available. That’s the real payoff of a well-designed HAL.

Closing Thoughts

The best HAL is the one your team can actually maintain. I’ve seen beautiful, theoretically perfect abstractions that nobody understood and everyone worked around. I’ve also seen ugly #ifdef forests that somehow shipped reliable products for a decade.

The principles that matter:

  1. Your types at the boundary. Vendor types stay in platform files.
  2. Opaque handles. Application code can’t reach into hardware-specific fields.
  3. Abstract operations, not init. Let setup be messy and platform-specific.
  4. Compile-time dispatch by default. Add indirection only when you have a concrete reason.
  5. Mock-friendly from day one. If you can’t test it on x86, your abstraction has holes.

The goal isn’t a perfect universal API. The goal is that when — not if — you need to swap silicon, the damage is contained to a set of well-defined platform files, and your application logic doesn’t change.

That’s a HAL that doesn’t lock you in.


Pranav Jain is a semiconductor software engineer specializing in the abstraction layer between customer applications and firmware. He works with C++, Python, and embedded systems across multiple MCU platforms. Find his open-source work on GitHub.