EmbeddedRelated.com
Blogs
Memfault State of IoT Report

Creating a GPIO HAL and Driver in C

Jacob BeningoFebruary 28, 20241 comment

Creating a GPIO HAL, a hardware abstraction layer, in C is a fairly simple process. Yet, you’ll often find standards shy away from including them. For example, CMSIS does not include a GPIO HAL. If you read the documentation carefully, they’ll state that the GPIO configurations from one silicon vendor to the next are too different to create a HAL for. I beg to differ. In today’s post, we will build on the techniques we’ve looked at over the last several blogs to create a GPIO HAL. We’ll start by looking at the interface and then dive deep into how the driver might look.

Designing a GPIO HAL

There are several ways that you can go about creating a hardware abstraction layer. First, you could use the process from my book, Reusable Firmware, which states the following procedure:

  1. Review the microcontroller peripheral datasheet
  2. Identify peripheral features
  3. Design and create the interface
  4. Create stubs and documentation templates
  5. Implement for target processor(s)
  6. Test
  7. Repeat for each peripheral

(Note: If I were to update this process today, I’d recommend that you use TDD and write your tests as part of your interface design).

This article is available in PDF format for easy printing

Alternatively, you could shorten the process and create a list of functionalities that you’d like the interface to have, such as:

  • Initialization
  • Write
  • Read
  • SetMux
  • Etc

While every GPIO peripheral is slightly different and may offer special functions, a hardware abstraction layer should provide the lowest common denominator of features. For example, look at the following GPIO interface:

typedef struct
{
    void (*init)  (void);
    void (*read)  (dioChannel_t const channel, dioPinState_t * const state);
    void (*write) (dioChannel_t const channel, dioPinState_t const state);
    void (*toggle)(dioChannel_t const channel);
} halGPIO_t;

You can see in the code above that we’ve kept the HAL interface to just four functions. With this simple of an interface, we probably shouldn’t even have the toggle function, but I think you get the idea.

Also, note that we keep the function signatures for the interface as similar as possible. All functions return void, although we could have them return an error if we wanted. They all act on a channel, which, in this case, is a pin. To read and write, we pass in the state which for writing is the desired state, and for the read, function is the current state. Note that we pass it in by pointer to get the result back. We could have returned the value through the return path, but that would break the signature consistency of the interface.

Finally, you could even ask your favorite AI GPT model to generate a HAL for you. We've gone through this exercise in several of my courses and workshops. It’s always interesting to see how slowly the first HAL comes together and how quickly the AI can generate any other HAL you might want.

The most recent version of GPIO HAL that I use can be found below:

typedef struct 
{
    void (*init)(dioConfig_t const * const config);
    void (*read)(dioChannel_t const channel, dioPinState_t const * const state);
    void (*write)(dioChannel_t const channel, dioPinState_t const state);
    void (*toggle)(dioChannel_t const channel);
    void (*modeSet)(dioChannel_t const channel, DioMode_t const mode);
    void (*directionSet)(dioChannel_t const channel, PinModeEnum_t const mode);
    dioError_t (*registerWrite)(uint32_t const address, uint32_t const value);
    dioError_t (*registerRead)(uint32_t const address, uint32_t *value);
    void (*callbackRegister)(DioCallback_t const function, uint32_t (*callbackFunction)(type));
} halGPIO_t;

You can see that it has common GPIO features along with functions to access “uncommon” features and extend the interface.

Connecting the GPIO HAL to the driver directly

Once you’ve designed your GPIO HAL, you need to connect it to the underlying driver that can interact with the hardware. The HAL inverts the dependencies so that the application is dependent on the HAL, but the driver also has a dependency on the HAL. If the driver can’t match the signature of the HAL so that it is directly assigned through the function pointers, then we need to create “shim” or “wrapper” functions that connect the underlying functions properly. Let’s look at some examples.

Suppose that I have a custom driver that I’ve developed for GPIO that has the following functions:

void Stm32_Gpio_Init(void);
void Stm32_Gpio_Read(dioChannel_t const channel, dioPinState_t * const state);
void Stm32_Gpio_Write(dioChannel_t const channel, dioPinState_t const state);
void Stm32_Gpio_Toggle(dioChannel_t const channel);

The signature of the driver functions match the signature of the interface! We can, therefore create a new GPIO object using the following code:

halGPIO_t gpio =    
{
    .init = Stm32_Gpio_Init,
    .read = Stm32_Gpio_Read,
    .write = Stm32_Gpio_Write,
    .toggle = Stm32_Gpio_Toggle,
};

From here, if I want to access a GPIO channel, all I need to is use the gpio object. For example, if I want to write LED_GREEN to GPIO_HIGH, I could do the following:

gpio->write(LED_GREEN, GPIO_HIGH);

In this case, we are assuming that there is a typedef enum with a list of valid channels for us to use. We also assume that dioState_t has been defined with GPIO_LOW and GPIO_HIGH states.

The case we have just looked at is the best case. What happens if you have a GPIO driver that does not have the same signature as your HAL?

Connecting the GPIO HAL to the driver through a wrapper

Let’s for fun, say that the functions you want to use to control GPIO don’t match your HAL signature. Let’s say that you want to use the STM32 provided HAL functions like:

  • HAL_GPIO_Init
  • HAL_GPIO_ReadPin
  • HAL_GPIO_WritePin
  • HAL_GPIO_TogglePin

How would you go about connecting these functions to the HAL? In this case, you’d have to create wrapper functions that expose the HAL signature but map the signature parameters to the STM32 functions. For example, you might create functions like the following, assuming that we have a part with only GPIOA:

void stm32DioInit(dioConfig_t const * const config) 
{
    // Assuming config provides necessary initialization details    
    // Initialize GPIO Clock
    __HAL_RCC_GPIOA_CLK_ENABLE();

    GPIO_InitTypeDef GPIO_InitStruct = {0};
    GPIO_InitStruct.Pin = config.pinSetupMask;
    HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
}

void stm32DioRead(dioChannel_t const channel, dioPinState_t const * const state) 
{
    // Convert dioChannel_t to GPIO pin and port
    GPIO_PinState pinState = HAL_GPIO_ReadPin(GPIOA, channel);
    *state = (pinState == GPIO_PIN_SET) ? DIO_PIN_STATE_HIGH : DIO_PIN_STATE_LOW;
}

void stm32DioWrite(dioChannel_t const channel, dioPinState_t const state) 
{
    HAL_GPIO_WritePin(GPIOA, channel, (state == DIO_PIN_STATE_HIGH) ? GPIO_PIN_SET : GPIO_PIN_RESET);
}

void stm32DioToggle(dioChannel_t const channel) 
{
    HAL_GPIO_TogglePin(GPIOA, channel);
}

As you can see from the code, we’ve protected our application from the low-level details of the STM32 hardware and functions by wrapping them in the interface. We still need to connect these functions to the interface, but we’ve already looked at how that is done.

A look at the driver configuration table

A question that I often get is about how the GPIO configuration would look. We’ve made some behind the scene assumptions that we are using GPIOA, but what if we wanted to set the mux values differently for different pins or the drive strength? In these cases, you could create a configuration table that is passed into the init function.

In the code below, you’ll find an array named gpioConfg. Each row is the configuration for a GPIO pin. Each row contains configuration for the name, resistor settings, drive strength, filter, direction, state, and function fo the GPIO pin. Each column is the member of gpioConfig_t structure:

static gpioConfig_t const gpioConfig[] =
{
  // Name,  Resister,   Drive, Filter,   Dir,   State,  Function
  {PORTA_1, DISABLED,    HIGH, DISABLED, OUTPUT, HIGH, FCN_GPIO},
  {PORTA_2, DISABLED,    HIGH, DISABLED, OUTPUT, HIGH, FCN_GPIO},
  {SWD_DIO, PULLUP,       LOW, DISABLED, OUTPUT, HIGH, FCN_MUX7},
  {PORTA_4, DISABLED,    HIGH, DISABLED, OUTPUT, HIGH, FCN_GPIO},
  {PORTA_5, DISABLED,    HIGH, DISABLED, INPUT,  HIGH, FCN_GPIO},
  {PORTA_6, DISABLED,    HIGH, DISABLED, OUTPUT, HIGH, FCN_GPIO},
  {PORTA_7, DISABLED,    HIGH, DISABLED, OUTPUT, HIGH, FCN_GPIO},
  {PORTA_8, DISABLED,    HIGH, DISABLED, OUTPUT, HIGH, FCN_GPIO},
};

Configuration tables like this are super useful for writing configurable code around an interface. We can define our configuration values in a typedef struct, and then use an array to make configuration code that is readable and reusable. However we change our configuration, our gpio code wouldn’t need to change, just the configuration! You can write simple init code that iterates over the table and then uses these values to configure the GPIO pins.

Conclusions

Creating a GPIO HAL and driver in C that is reusable and scalable can be quite simple. You’ve seen how we can create an interface using a structure of function pointers. Those pointers can be assigned to low-level gpio functions based on our needs. They could drive GPIO, or they could drive TCP/IP stub functions, or allow us to test our application code. You also saw how we could wrap various driver functions to match the interface.

The gpio hal that we’ve created is simple, but you could easily extend it. You can take these concepts and then expand on them to create SPI, I2C, PWM, Timers, and many other interfaces. The result will be code that is hardware independent and scalable.



Memfault State of IoT Report
[ - ]
Comment by mr_banditApril 3, 2024

A Command Line Interpreter (CLI) is perfect for this, because you can create commands that allow you to control each driver and make them easier to both write and test. I have done the equivalent of this tutorial, and my CLI made it easy. Have commands that look for the register values. You can get fancy and have a command that breaks out the command and data/command register values.

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: