Writing a Portable Hardware Abstraction Layer (HAL) in Embedded C

Firmware often outlives the microcontroller it was written for. A part goes out of stock, a cheaper chip appears, or the product line needs a bigger memory. If the application code reads registers directly everywhere, moving to a new chip means rewriting everything. A hardware abstraction layer (HAL) puts a thin, stable interface between the application and the hardware so that only the bottom layer changes.

This article shows how to layer firmware, how to design HAL interfaces that do not leak vendor details, two ways to select the implementation, and how a mock implementation lets you test application logic on a PC.

Layering the Firmware

+------------------------------------------+
| Application: state machines, protocols   |  portable
+------------------------------------------+
| Device drivers: sensor, display, EEPROM  |  portable (use HAL only)
+------------------------------------------+
| HAL interface: hal_gpio.h, hal_i2c.h     |  portable header
+------------------------------------------+
| HAL implementation: hal_gpio_stm32.c     |  one per MCU family
+------------------------------------------+
| Vendor library / CMSIS / registers       |  chip-specific
+------------------------------------------+
LayerKnows aboutMust not know about
ApplicationFeatures, product behaviour, driver APIsRegister names, pin numbers of the MCU
Device driversA specific external chip such as a temperature sensorWhich MCU or which I2C peripheral is used
HAL interfaceGeneric operations: write pin, transfer bytesAny vendor type or header
HAL implementationRegisters or the vendor SDK of one MCU familyThe application

A board support package (BSP) often sits beside the HAL and maps logical names to physical resources, for example LED_STATUS to port C pin 13 on one board and port A pin 5 on another.

Rules for a Good HAL Interface

  • Use only standard C types in headers: stdint.h, stdbool.h, stddef.h. If hal_gpio.h includes a vendor header, every file that includes it becomes chip-specific.
  • Name resources logically. Use small integer IDs or opaque handles, not raw register pointers.
  • Return error codes. An I2C transfer can fail with a missing acknowledge or a timeout. A function that returns void hides it.
  • Specify blocking and timing behaviour in the header comment: does the function wait, for how long at most, may it be called from an ISR?
  • Keep it small. Abstract what the application needs, not every feature of every chip. A HAL that tries to expose everything becomes as complex as the vendor library it wraps.
  • Allow an escape hatch. Performance-critical code may still access hardware directly, but keep it in clearly marked chip-specific files.

Two Ways to Select the Implementation

Link-time selection is the most common. One header declares the functions; each MCU has its own .c file implementing them; the build system compiles only the right file. There is no run-time cost, and the compiler can even inline small functions with link-time optimisation.

Run-time selection uses a structure of function pointers, often called an operations table or vtable. Each driver instance carries a pointer to its operations. This costs one indirect call, but it lets one firmware image handle, for example, two different sensor chips detected at start-up, and lets tests swap in a mock without rebuilding.

AspectLink-timeRun-time (function pointers)
OverheadNoneOne indirect call per operation
Switching implementationChange build configurationAssign a different ops pointer
Multiple implementations in one imageAwkwardNatural
Typical useMCU peripheralsExternal devices, test doubles

Example: GPIO Interface with a Mock for Testing

The single-file program below compiles with any C99 compiler. In a real project the three parts would be separate files: the interface header, an implementation file per target, and the application. The application code heartbeat_update never touches hardware; it only calls the interface, so the same function runs on the PC with the mock driver and on the board with the real one.

Function-pointer GPIO interface, a PC mock implementation and portable application logic
#include <stdio.h>
#include <stdint.h>
#include <stdbool.h>

/* ===== hal_gpio.h : portable interface, no vendor headers ===== */
typedef enum { HAL_OK = 0, HAL_ERR_PARAM, HAL_ERR_HW } hal_status_t;

typedef struct {
    hal_status_t (*config_output)(uint8_t pin);
    hal_status_t (*write)(uint8_t pin, bool level);
    hal_status_t (*read)(uint8_t pin, bool *level);
} hal_gpio_ops_t;

/* ===== hal_gpio_mock.c : PC test implementation ===== */
#define MOCK_PINS 8
static bool mock_level[MOCK_PINS];
static unsigned mock_writes;

static hal_status_t mock_config_output(uint8_t pin)
{
    return pin < MOCK_PINS ? HAL_OK : HAL_ERR_PARAM;
}
static hal_status_t mock_write(uint8_t pin, bool level)
{
    if (pin >= MOCK_PINS) return HAL_ERR_PARAM;
    mock_level[pin] = level;
    mock_writes++;
    return HAL_OK;
}
static hal_status_t mock_read(uint8_t pin, bool *level)
{
    if (pin >= MOCK_PINS || level == NULL) return HAL_ERR_PARAM;
    *level = mock_level[pin];
    return HAL_OK;
}
static const hal_gpio_ops_t gpio_mock = { mock_config_output, mock_write, mock_read };

/* An hal_gpio_stm32.c file would define gpio_stm32 with functions that
   write GPIOx->BSRR, read GPIOx->IDR and so on. */

/* ===== app_heartbeat.c : portable application logic ===== */
#define LED_STATUS 3u   /* logical pin from the board support package */

typedef struct {
    const hal_gpio_ops_t *gpio;
    uint32_t last_ms;
    bool on;
} heartbeat_t;

static hal_status_t heartbeat_init(heartbeat_t *hb, const hal_gpio_ops_t *gpio)
{
    hb->gpio = gpio;
    hb->last_ms = 0;
    hb->on = false;
    return gpio->config_output(LED_STATUS);
}

/* Short blink: on for 100 ms, off for 900 ms. Called with the current time. */
static void heartbeat_update(heartbeat_t *hb, uint32_t now_ms)
{
    uint32_t elapsed = now_ms - hb->last_ms;
    if (!hb->on && elapsed >= 900u) {
        hb->on = true;  hb->last_ms = now_ms;
        (void)hb->gpio->write(LED_STATUS, true);
    } else if (hb->on && elapsed >= 100u) {
        hb->on = false; hb->last_ms = now_ms;
        (void)hb->gpio->write(LED_STATUS, false);
    }
}

/* ===== test on the PC ===== */
int main(void)
{
    heartbeat_t hb;
    int failures = 0;
    bool level;

    if (heartbeat_init(&hb, &gpio_mock) != HAL_OK) failures++;

    for (uint32_t t = 0; t <= 3000; t += 10) {
        heartbeat_update(&hb, t);
        if (t == 950) {                         /* expect LED on at 950 ms  */
            gpio_mock.read(LED_STATUS, &level);
            if (!level) failures++;
        }
        if (t == 1050) {                        /* expect LED off at 1050 ms */
            gpio_mock.read(LED_STATUS, &level);
            if (level) failures++;
        }
    }
    printf("pin writes: %u, failures: %d\n", mock_writes, failures);
    printf("%s\n", failures ? "TEST FAILED" : "TEST PASSED");
    return failures ? 1 : 0;
}
pin writes: 6, failures: 0
TEST PASSED

What the Example Demonstrates

The interface uses only standard types, and every function returns a status. The application stores a pointer to the operations table, so on the board you pass &gpio_stm32 and in the test you pass &gpio_mock. The test advances simulated time in 10 ms steps and checks the pin through the mock: the LED turns on at 900 ms, off at 1000 ms, on at 1900 ms and so on, giving six writes in three seconds.

Time is also passed in as a parameter rather than read from a global tick inside the function. That small decision is what makes the logic testable: the test controls the clock completely.

Common Mistakes

  • Including the vendor header in the HAL interface, so the abstraction exists only on paper.
  • Copying the vendor API one-to-one into a HAL with a different prefix, which adds a layer without hiding anything.
  • Hiding errors by returning void, then debugging silent I2C failures in the field.
  • Abstracting too early: write the second implementation before generalising, because the real differences only appear when you port.

Summary

A HAL separates portable application and driver code from chip-specific register access. Keep its headers free of vendor types, name resources logically, return errors and document blocking behaviour. Select implementations at link time for MCU peripherals and through function-pointer tables where you need several implementations or test doubles. The payoff is firmware you can test on a PC and move to a new microcontroller by rewriting only the bottom layer.