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 +------------------------------------------+
| Layer | Knows about | Must not know about |
|---|---|---|
| Application | Features, product behaviour, driver APIs | Register names, pin numbers of the MCU |
| Device drivers | A specific external chip such as a temperature sensor | Which MCU or which I2C peripheral is used |
| HAL interface | Generic operations: write pin, transfer bytes | Any vendor type or header |
| HAL implementation | Registers or the vendor SDK of one MCU family | The 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. Ifhal_gpio.hincludes 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.
| Aspect | Link-time | Run-time (function pointers) |
|---|---|---|
| Overhead | None | One indirect call per operation |
| Switching implementation | Change build configuration | Assign a different ops pointer |
| Multiple implementations in one image | Awkward | Natural |
| Typical use | MCU peripherals | External 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.
#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.