Memfault State of IoT Report

Creating a Hardware Abstraction Layer (HAL) in C

Jacob BeningoOctober 23, 20233 comments

In my last post, C to C++: Using Abstract Interfaces to Create Hardware Abstraction Layers (HAL), I discussed how vital hardware abstraction layers are and how to use a C++ abstract interface to create them. You may be thinking, that’s great for C++, but I work in C! How do I create a HAL that can easily swap in and out different drivers? In today’s post, I will walk through exactly how to do that while using the I2C bus as an example.

The purpose of hardware abstraction layers

Embedded software developers have traditionally written software that tightly coupled their application code to the hardware. Embedded products were often well-tuned hot rods, and that tight coupling allowed developers to maximize performance. Developers didn’t worry about reuse, portability, and maintainability. Today, those are critical features of nearly all embedded software.

This article is available in PDF format for easy printing

Hardware Abstraction Layers help developers decouple their application code from the underlying hardware. The HAL provides an interface to the application code, a dependency, and the dependency the underlying implementation must provide! Developers will adhere to the SOLID dependency inversion principle if done correctly, breaking the application dependency on low-level implementation details.

Breaking the low-level dependencies and hiding the implementation allows developers to quickly move their application to new hardware, both embedded and simulated on the host environment. Hardware abstraction layers are a powerful tool for embedded developers but often must be implemented successfully. Let’s look at how we can define an I2C HAL and then how we might fill in the implementation details.

Defining an I2C hardware abstraction layer

There are several characteristics that we would like any HAL to exhibit. These include:

A generic interface that provides standard features

A reasonably sized interface consisting of a dozen or fewer functions

The ability to map the interface to the desired underlying implementation

There are certainly more, but those are the three that we will focus on today.

A hardware abstraction layer is often defined by only providing the header file. The header file defines what functions the interface provides. For example, I often use an I2C HAL interface that looks like the following:

#ifndef I2C_INTERFACE_H_
#define I2C_INTERFACE_H_

#include <stdint.h>
#include <stdbool.h>

typedef struct
    bool (*Init)(uint32_t (*Time_Get)(void));
    bool (*Write)(uint16_t const TargetAddress, uint8_t const * const  Data, uint32_t const DataLength);
    bool (*Read)(uint16_t const TargetAddress, uint8_t * Data, uint32_t const DataLength);
    bool (*WriteRead)(uint16_t const TargetAddress, uint8_t const * const Data, uint32_t const DataLength, uint8_t * const DataOut, uint32_t const DataOutLength);

#endif /*I2C_INTERFACE_H_*/

(Note: I’ve kept the initialization function simple for the blog’s sake. You’d likely pass in a configuration structure to set the I2C pins and baud rate).  

From a quick look at the above code, you can see that there are only four functions provided to the higher-level code:

  • Init
  • Write
  • Read
  • WriteRead

Any application that wants to use I2C would need these general functions and features. What if my microcontroller I2C peripheral has some particular function I need? Well, in that case, you create an extension to that interface! You could name it something like i2c_interface_ext.h and provide those extra functions. That way, the core I2C interface remains stable and constant, but you still have the flexibility to leverage less common features.

Mapping the HAL to a driver

You likely noticed that our I2C interface was wrapped in a structure. When you declare an I2C object, you can assign or map a driver function to the interface! For example, you might create an I2C0 object that maps the interface as follows:

I2C_t I2C0 = {.Init = I2C0_Initialize,
              .Write = I2C0_Write,
              .Read = I2C0_Read,
              .WriteRead = I2C0_WriteRead};

You can then initialize I2C0 by making the call:

SuccessFlag = I2C0.Init(tx_time_get);

In this example, we pass the ThreadX RTOS get time function to the initialization. This is an example of dependency injection. We don’t want to couple our RTOS to the I2C driver, so we inject the function to get the RTOS time into the driver. That time will then be used to check for communication time-outs.

The above mapping is super easy if your interface matches the driver functions that you write or that are provided by your silicon vendor. However, what do you do if they don’t match?

What can you do when driver interfaces are very different?

You might find that your driver doesn’t match your desired HAL interface. For example, the STM32 function to transmit data on I2C is:

HAL_StatusTypeDef HAL_I2C_Master_Transmit(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint8_t *pData, uint16_t Size, uint32_t Timeout);

The function in Microchip Harmony is:

bool SERCOM0_I2C_XferSetup(uint16_t address, uint8_t* wrData, uint32_t wrLength, uint8_t* rdData, uint32_t rdLength, bool dir, bool isHighSpeed);

These two very different interfaces don’t seem like they have much of anything to do in common. However, you can write your functions that wrap and map the parameters so that the lower-level driver works based on the information provided by the application. Let’s look at the Microchip Harmony Example.

Making your driver fit the interface

One of the first things you can do with your drivers is wrap them in a function that simplifies how they call the low-level driver. For example, we could write a SERCOM0 function that writes to I2C and looks like the following:

bool SERCOM0_I2C_Write(uint16_t address, uint8_t* wrData, uint32_t wrLength)
    return SERCOM0_I2C_XferSetup(address, wrData, wrLength, NULL, 0, false, false);

SERCOM0_I2C_Write now has a form closer to our HAL interface, but it doesn’t provide the timeout functionality we expect. We could now write the I2C_Write function that we mapped to our I2C0 object and include the timeout feature with code like the following:

bool I2C0_Write(uint16_t const TargetAddress, uint8_t const * const Data, uint32_t const DataLength) {
    bool IsTransferSuccessful = false;

    IsTransferSuccessful = SERCOM0_I2C_Write(TargetAddress, (uint8_t *)Data, DataLength);

    if(IsTransferSuccessful == true)
        IsTransferSuccessful = I2C_TimeoutCheck();

    return IsTransferSuccessful;

If you ran this code, you’d have an I2C HAL to map to any lower-level implementation! You could write code similar for the STM32 and then switch between STM32 and Microchip parts by configuring your HAL. It wouldn’t be limited to microcontrollers, either! You could just as easily map to some function that simulates I2C on a host computer.


Hardware Abstraction Layers are an excellent tool for embedded developers. They also aren’t a tool that is only available to C++ developers! You’ve seen in this post how you can take a peripheral as complex as I2C and reduce it to its essential functions, map it to a driver, and decouple your application code from it! The flexibility that this type of design can provide is immeasurable!

Memfault State of IoT Report
[ - ]
Comment by mpickNovember 15, 2023

What about the private elements like port and pin, to take the LED example from the previous C++ article ?

Would they be present in the interface structure, or an other structure ? How would that be implemented practically ?

[ - ]
Comment by beningjwNovember 20, 2023

For GPIO, port and pin could still be private, but there would need to be some public enum or something that translates into the port and pin. 

I typically use a GPIO enum that lists the function of each pin. Then behind the abstraction convert it to the port and pin. 

I'm not sure if that is clear, but I could do a future post on GPIO as an example. 

[ - ]
Comment by mpickNovember 28, 2023

I have to admit it is not completely clear for me. I would love an example, if you can find the time for it !

To post reply to a comment, click on the 'reply' button attached to each comment. To post a new comment (not a reply to a comment) check out the 'Write a Comment' tab at the top of the comments.

Please login (on the right) if you already have an account on this platform.

Otherwise, please use this form to register (free) an join one of the largest online community for Electrical/Embedded/DSP/FPGA/ML engineers: