Skip to content
Home » Software Design » Hardware Abstraction Layer (HAL) Design in C — With Examples

Hardware Abstraction Layer (HAL) Design in C — With Examples

HAL Design in C featured image with purple background, Architecture badge, HL icon, Hardware Abstraction Layer subtitle in the Software Design in C series by NerdyElectronics
Software Design Principles in C
Part 30 of 31View Full Path →

KEY TAKEAWAYS

📘 A clean HAL is 90% disciplined C — interfaces, pointers, and structs. My complete Master C & Embedded C course takes you from zero to hardware-ready code — free on YouTube (53 videos), or guided on Udemy with quizzes, certificate and my Q&A support.

  • A HAL isolates hardware-specific code so your application is portable and testable
  • Design the HAL top-down — start from what the application needs, not what the hardware exposes
  • Use opaque handles, one header per peripheral, and keep the layer thin
  • Always provide a mock implementation for PC-based testing
  • The combination of HAL + dependency inversion + factory pattern gives you maximum flexibility

What Is a Hardware Abstraction Layer?

A Hardware Abstraction Layer (HAL) is a layer of code that sits between your application logic and the actual hardware. It provides a uniform API that hides hardware-specific details — register addresses, bit manipulation, timing quirks — so your application code does not need to know which specific chip or board it is running on.The benefits: your application code becomes portable across different hardware platforms, testable on a PC without hardware, and maintainable because hardware changes are isolated to one layer.

Without a HAL: Direct Hardware Access

// Application code directly manipulates STM32 registers
void led_on(void) {
    // GPIOA base = 0x40020000, BSRR offset = 0x18
    *((volatile uint32_t *)0x40020018) = (1 << 5);  // Set PA5
}

void led_off(void) {
    *((volatile uint32_t *)0x40020018) = (1 << 21);  // Reset PA5
}

float read_temperature(void) {
    // ADC1 base = 0x40012000
    *((volatile uint32_t *)0x40012008) |= (1 << 0);  // Start conversion
    while (!(*((volatile uint32_t *)0x40012000) & (1 << 1)));  // Wait EOC
    int raw = *((volatile uint32_t *)0x4001204C);
    return raw * 3.3f / 4096.0f * 100.0f;
}
This code works, but only on one specific chip. Moving to a different MCU means rewriting everything. Testing on a PC is impossible.

Example 1: GPIO HAL

HAL Interface (Hardware-Independent)

// hal_gpio.h — the abstraction
#ifndef HAL_GPIO_H
#define HAL_GPIO_H

#include <stdbool.h>

typedef enum {
    GPIO_MODE_INPUT,
    GPIO_MODE_OUTPUT,
    GPIO_MODE_ALTERNATE,
} GpioMode;

typedef struct GpioPin GpioPin;  // Opaque

GpioPin *hal_gpio_init(int port, int pin, GpioMode mode);
void     hal_gpio_write(GpioPin *p, bool high);
bool     hal_gpio_read(GpioPin *p);
void     hal_gpio_toggle(GpioPin *p);

#endif

STM32 Implementation

// hal_gpio_stm32.c
#include "hal_gpio.h"
#include "stm32f4xx.h"

struct GpioPin {
    GPIO_TypeDef *port;
    uint16_t pin_mask;
};

static GpioPin pins[16];
static int pin_count = 0;

GpioPin *hal_gpio_init(int port, int pin, GpioMode mode) {
    GpioPin *p = &pins[pin_count++];
    p->port = get_gpio_port(port);  // GPIOA, GPIOB, etc.
    p->pin_mask = (1 <port, pin, mode);
    return p;
}

void hal_gpio_write(GpioPin *p, bool high) {
    if (high)
        p->port->BSRR = p->pin_mask;
    else
        p->port->BSRR = p->pin_mask <port->IDR & p->pin_mask) != 0;
}

void hal_gpio_toggle(GpioPin *p) {
    p->port->ODR ^= p->pin_mask;
}

Mock Implementation (For Testing)

// hal_gpio_mock.c — for unit tests
#include "hal_gpio.h"

struct GpioPin {
    int port, pin;
    bool state;
};

static GpioPin mock_pins[16];
static int pin_count = 0;

GpioPin *hal_gpio_init(int port, int pin, GpioMode mode) {
    GpioPin *p = &mock_pins[pin_count++];
    p->port = port;
    p->pin = pin;
    p->state = false;
    return p;
}

void hal_gpio_write(GpioPin *p, bool high) {
    p->state = high;
}

bool hal_gpio_read(GpioPin *p) {
    return p->state;
}

// Test helper
bool mock_gpio_get_state(GpioPin *p) {
    return p->state;
}
Your application links against hal_gpio_stm32.c for production or hal_gpio_mock.c for testing. The application code never changes.

Example 2: Communication HAL

// hal_comm.h — abstract communication interface
#ifndef HAL_COMM_H
#define HAL_COMM_H

#include <stdint.h>

typedef struct CommDriver CommDriver;

struct CommDriver {
    int  (*init)(CommDriver *self, uint32_t config);
    int  (*send)(CommDriver *self, const uint8_t *data, int len);
    int  (*recv)(CommDriver *self, uint8_t *buf, int max_len);
    void (*close)(CommDriver *self);
    void *hw_ctx;  // hardware-specific context
};

// Factory functions — return the right driver for the platform
CommDriver *hal_comm_create_uart(int port, int baud);
CommDriver *hal_comm_create_spi(int bus, int cs_pin);
CommDriver *hal_comm_create_i2c(int bus, int addr);

#endif

// Application code — hardware agnostic
void app_send_data(CommDriver *comm, const uint8_t *data, int len) {
    if (comm->send(comm, data, len) < 0) {
        log_error("Send failed");
    }
}

// Configuration selects the hardware
CommDriver *comm = hal_comm_create_uart(0, 115200);
app_send_data(comm, payload, payload_len);
The application uses CommDriver without knowing whether it is UART, SPI, or I2C underneath. Switching communication interfaces means changing one line — the factory call.

HAL Design Principles

1. Design Top-Down, Not Bottom-Up

Start by writing the application code you wish you could write. Then design the HAL interface to support it. Do not start by wrapping every hardware register — you will end up with a HAL that mirrors the hardware instead of serving the application.

2. Keep the HAL Thin

The HAL should translate between the application API and hardware registers — nothing more. Business logic does not belong in the HAL.

3. One HAL Per Peripheral Type

hal_gpio.h    // GPIO abstraction
hal_uart.h    // UART abstraction
hal_spi.h     // SPI abstraction
hal_adc.h     // ADC abstraction
hal_timer.h   // Timer abstraction

4. Use Opaque Handles

Return opaque pointers from init functions. The application cannot see hardware-specific struct fields.

5. Provide a Mock Implementation

For every HAL, write a mock that compiles and runs on a PC. This enables unit testing of your application code without any hardware.

Common HAL Mistakes

MistakeProblemFix
HAL too thickBusiness logic mixed with hardware codeKeep HAL to init/read/write/close only
HAL too thinApplication still uses hardware constantsAbstract parameters into meaningful enums
One giant hal.hEverything depends on everythingSeparate headers per peripheral type
No mock HALCannot unit test on PCAlways write a mock alongside the real HAL
HAL exposes register addressesApplication is still hardware-specificUse opaque handles and abstract operations

Key Takeaways

  • A HAL isolates hardware-specific code so your application is portable and testable
  • Design the HAL top-down — start from what the application needs, not what the hardware exposes
  • Use opaque handles, one header per peripheral, and keep the layer thin
  • Always provide a mock implementation for PC-based testing
  • The combination of HAL + dependency inversion + factory pattern gives you maximum flexibility

Leave a Reply

Your email address will not be published. Required fields are marked *