Table of Contents
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);
#endifSTM32 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
| Mistake | Problem | Fix |
|---|---|---|
| HAL too thick | Business logic mixed with hardware code | Keep HAL to init/read/write/close only |
| HAL too thin | Application still uses hardware constants | Abstract parameters into meaningful enums |
| One giant hal.h | Everything depends on everything | Separate headers per peripheral type |
| No mock HAL | Cannot unit test on PC | Always write a mock alongside the real HAL |
| HAL exposes register addresses | Application is still hardware-specific | Use 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

Vivek Bhageria — Lead Firmware R&D Engineer, 12+ years. Ex-Bosch (automotive powertrain), MusicTribe (real-time audio), medical devices. M.Tech BITS Pilani. I write at NerdyElectronics — practical, register-level embedded systems for engineers who want to understand what’s actually happening under the hood.






