ChibiOS/HAL design: an object-oriented approach

The ChibiOS Hardware Abstraction Layer

The C programming language is clearly an imperative programming language not designed to be object-oriented. Anyway, the object-oriented approach remains something much more related to the design and mindset more than to syntax. The ChibiOS/HAL is an Hardware Abstraction Layer which design could be considered very object-oriented. We encountered it in almost every article related to ChibiOS but we often ignored its design as it comes with a series of demos which allow to use it easily without a deep knowledge of its internal mechanisms.

This article is dedicated to people who want to go in deep and see how stuff works. Starting from ChibiOS 3.0, ChibiOS/HAL became essentially a standalone product that is not rigidly attached to the OS and can be used on bare metal or in conjunction with any other RTOS.

ChibiOS/HAL provides a hardware abstraction allowing nevertheless a custom use of peripherals. HAL has undergone a lot of changes based on community’s suggestions and after years of upgrades and bug fixes, it has reached a high level of stability, reliability and flexibility.
To pursue its goals, HAL has:

  • To encapsulate the driver complexities allowing somehow to properly address the hardware-dependent configuration: such implementation allows to cover many different scenarios.
  • To provide an hardware-independent and universal high level API: this ensure that application are portable across different microcontrollers.
  • To ensure an intrinsic optimization and provide and non-polled implementation to make HAL usable in Real Time applications.
  • To be able to work as stand-alone in OS-less applications;
  • To support most common peripherals like ADC, CAN, DAC, GPIO, ICU, PWM, SPI, TIM, UART, USB and many others.

Achieving abstraction using a layered architecture design

HAL is organized in a multi-layered way and more precisely it has two layers.

  • The top layer which offers a universal Application Programming Interface (also abbreviated as API) we can directly approach.
  • The low level driver (or LLD) layer which resolves differences across different hardware.

We could imagine this architecture as a LEGO wall: we can directly approach only from its top layer and this provide us an universal interface to the underlying low level. The interface remains unchanged in time and across different microcontrollers and this makes application built on top portable across different hardware and easy to maintain in time.

A diagram which explains how HAL layers are organized

The trick is done linking proper LLD drivers depending on underlying microcontroller. To understand this let’s take a look to the organization of HAL files. HAL is contained inside the folder hal contained in our ChibiOS root directory.

ChibiOS/HAL hierarchy
A screenshot of the ChibiOS/HAL hierarchy

What we have called Top Layer is contained inside the two folders include and src. Exploring the first folder we will header files like hal_adc.h, hal_pal.hhal_pwm.h, … while in the second source files like hal_adc.c, hal_pal.chal_pwm.c, … and so on.

 

Such code performs those operations which are common to any hardware. Operations which are hardware dependent are executed calling low level driver API. While there is only one top level layer there are many LLD layers which are related to specific platforms.

These LLD are contained inside the folder ports. As example considering the folder hal/ports/stm32/ we can find

  • a folder for each STM32 subfamily which contains the low level drivers specific of that subfamily and the platform.mk (a piece of makefile used to list and link drivers used by that platform).
  • a folder named LLD which contains certain driver which are common across more than a subfamily.

What follows is the platform.mk of the STM32F4xx subfamily were we can spot all the LLD used by it. As example F4 uses the ADCv2, CANv1, DACv1 and so on.

To complete the trick the proper platform.mk shall be included in the project makefile. As example the following codebox contains a snippet of makefile from a demo for the STM32 Nucleo-64 F401RE. We can see that it includes hal/ports/STM32/STM32F4xx/platform.mk 

To next table reports the LLD associated to each STM32 subfamily

Sub-Family ADC BDMA CAN CRYP DAC DMA EXTI GPIO I2C MAC OTG QUADSPI RTC SDIO SDMMC SPI TIM USART USB xWDG
STM32F0 ADCv1 ND CANv1 ND DACv1 DMAv1 EXTIv1 GPIOv2 I2Cv2 ND ND ND RTCv2 ND ND SPIv2 TIMv1 USARTv2 USBv1 xWDGv1
STM32F1 Specific ND CANv1 ND DACv1 DMAv1 EXTIv1 GPIOv1 I2Cv1 MACv1 OTGv1 ND RTCv1 SDIOv1 ND SPIv1 TIMv1 USARTv1 USBv1 xWDGv1
STM32F2 ADCv2 ND CANv1 ND DACv1 DMAv2 EXTIv1 GPIOv2 I2Cv1 MACv1 OTGv1 QUADSPIv1 RTCv2 SDIOv1 ND SPIv1 TIMv1 USARTv1 ND xWDGv1
STM32F3

ADCv3

Specific

ND CANv1 ND DACv1 DMAv1 EXTIv1 GPIOv2 I2Cv2 ND ND ND RTCv2 ND ND SPIv2 TIMv1 USARTv2 USBv1 xWDGv1
STM32F4 ADCv2 ND CANv1 ND DACv1 DMAv2 EXTIv1 GPIOv2 I2Cv1 MACv1 OTGv1 QUADSPIv1 RTCv2 SDIOv1 ND SPIv1 TIMv1 USARTv1 ND xWDGv1
STM32F7 ADCv2 ND CANv1 CRYPv1 DACv1 DMAv2 EXTIv1 GPIOv2 I2Cv2 MACv1 OTGv1 QUADSPIv1 RTCv2 ND SDMMCv1 SPIv2 TIMv1 USARTv2 ND xWDGv1
STM32H7 ADCv4 BDMAv1 ND CRYPv1 ND DMAv3 ND GPIOv2 I2Cv3 ND ND ND RTCv2 ND ND SPIv3 TIMv1 USARTv2 ND xWDGv1
STM32L0 ADCv1 ND CANv1 ND DACv1 DMAv1 EXTIv1 GPIOv2 I2Cv2 ND ND ND RTCv2 ND ND SPIv1 TIMv1 USARTv2 USBv1 xWDGv1
STM32L1 Specific ND ND ND DACv1 DMAv1 EXTIv1 GPIOv2 I2Cv1 ND ND ND RTCv2 ND ND SPIv1 TIMv1 USARTv1 USBv1 xWDGv1
STM32L4 ADCv3 ND CANv1 ND DACv1 DMAv1 EXTIv1 GPIOv3 I2Cv2 ND OTGv1 QUADSPIv1 RTCv2 ND SDMMCv1 SPIv2 TIMv1 USARTv2 USBv1 xWDGv1

Driver enabling and peripheral allocation

HAL allows to completely exclude code from the compiled binary through the driver switch. Working with embedded systems, it is extremely usual to deal with textual configuration headers which contains a lot of preprocessor directives. A driverswitch is just a boolean constant definition which include/exclude piece of code from the compilation process. In ChibiOS the driver switches related are contained in the halconf.h file

The following code snippet has been taken by a demo for STM32 and we can see it contains two switches: the first one is enabled and includes all the code related to Serial Driver, the second one is disabled and excludes all the code related to PWM Driver.

A driver relies on hardware peripheral. To use it we have then to assign a peripheral to the driver and this can be done acting on mcuconf.h.

As example in the following code we are assigning USART2 to the Serial Driver. As side note this code has been copied from the MCU configuration header of the original demo for STM32 Nucleo-64 F401RE.

Compile error while assigning same peripheral to multiple drivers

Note that is not possible to assign the same peripheral to different driver because this would generate conflict on IRQ management and the project would not compile.

As instance, it is not possible to assign USART 2 both to the Serial Driver and UART Driver and trying to do will result in a compile error. In figure 4 the problem windows reports the compile error derived by such operation. The error is due to the tentative to assign the same IRQ line (VectorD8) to two different drivers.

 

Each ChibiOS driver can be enabled or disabled in the halconf.h file and each project has is own HAL configuration header. In the original demos all unused drivers are usually disabled. If you have launched the default demo you should have noticed that we use Serial Driver to print test suite results, and us most likely your project will be a copy of the default one most-likely you will find the SD already enabled.

The driver object

Each driver is represented by a structure and every API associated to a certain driver requires its pointer. As this structure contains almost every information related to driver, API doesn’t require a long list of parameters and moreover this implementation allows multiple instances.

As example considering  the SPI driver we have an instance for each SPI peripheral

As example assigning the STM32 SPI1 to serial driver the SPID1 object would become available. Note that, the driver implementation inumbering is aligned to peripheral numbering: SPI peripheral 1 is associated to SPID1, SPI peripheral 2 is associated to SPID2 and so on.

We can also note that there is a certain strictness in coding style. We can indeed notice that

Each API of the a driver starts with the prefix. Function names are camel-case, preprocessor constants uppercase and variables lowercase.

Driver finite state machine

Each driver in ChibiOS\HAL implements a Finite State Machine. The current state of the driver is stored inside the object in a field named state (you can access it using structure syntax as example SPID2.state or PWMD1.state). The following image has been grabbed from the ChibiOS\HAL documentation and illustrates the finite state machine of the SPI driver

SPI Driver FSM
The Finite State Machine for the SPI Driver.

Driver initialization

Looking at SPI finite state machine, we cannot call function spiStart() on SPID1 if this driver is into the state SPI_UNINIT.

Every driver must be initialized and its state moved to XXX_STOP (in our example SPI_STOP): this operation is performed by a function named xxxInit() (in our example spiInit). There is an “init” function for every driver and they are called automatically by halInit() if the driver switch is enabled

If a ChibiOS driver is enabled in the HAL configuration file, it is automatically initialized on HAL initialization. Initialization is related to variable initialization more than on hardware configuration.

Configuring a driver

Before to use it, the a driver shall be properly initialized and configured. This operation is carried out by another function: the start. The following code box reports some start function from different drivers.

Such functions shall be called at least once by the user application before the real usage. Their purpose is to configure the peripherals and this involves setup of all that hardware configurability.

In ChibiOS/HAL every driver except PAL shall be started before to be used.

The xxxStart function receives two parameters which are a pointer to the serial driver object we want to start (e.g. &SD1, &SPI3, &PWM7 or whatever is the driver we are going to use) and a pointer to a structure which represent the related configuration. This structure contains all the dependencies which are strictly related to the underlying hardware. This means that moving from a STM32 family to another which has a different underlying hardware we most likely have to apply some changes to these configuration structures.

Starting a driver we need a pointer to it  and a pointer to its configuration structure. This structure contains all the HW dependencies and has to been reviewed if we port our application on a different microcontroller.

The start function usually enables peripheral clock. In certain application (especially those addressed to low power) it is undesidered to keep a peripheral clocked when it is not used. Because of that there is another function we could use to stop the driver and stop the peripheral clock: the stop.

This function can be called by the user application when the peripheral is unnecessary. It is almost intuitive that after a stop we need a new start to be able to use again the driver.

Start Do Stop

A most common paradigm is the Start-Do-Stop:

  • we start the driver when needed configuring it.
  • we do some operations on that driver.
  • we stop it as soon as it has completed operations.

This offers advantage especially if peripheral is actually used in a small slice of the whole time amount of execution time.

It is possible to stop a driver to reduce hardware power consumption when peripheral is used for a small percentage of execution time. After being stopped a the driver shall be re-started to be used again.

Changing configuration on the fly

An approach similar to Start-Do-Stop can be used is used when we need to change driver configuration on-the-fly. In such scenario we can start peripheral multiple time with different configuration. Note that stop is not required rather is discouraged as in case of subsequent start operation certain operation are skipped.

It is possible to start driver more than once with different configuration in case we need to change driver configuration on-the-fly.

The configuration structure is composed by two separated parts: the first part remains unchanged across all kind of hardware, the second is strictly hardware dependent. Dealing with configuration there are some hints that can help you to figure out how to correctly compose them:

  • Take a look to demos under the testhal folder and testex folders: in these demos you can find some precomposed configuration that you can copy and use in your application. As the configuration depends on hardware be sure to pick a demo for the subfamily you are currently using.
  • You can take a look to the definition of configuration structure following the breadcrumbs: first you need to detect which LLD your platform is using through the platform.mk file, then you have to open the related low level driver header where you can find the configuration structure definition.
  • Those fields which are related to hardware are described in the reference manual, use it to spot the meaning of each bit.
  • Remember that the start function usually does some internal obvious modification to register values passed through the configuration.
  • There are some simple techniques to change certain bit of the register leaving others unchanged. Such operation takes the name of bit-masking and the adopted paradigm to deal with registers read-modify-write. This is a knowledge you really should do have. If don’t I highly suggest to read this article (even at later moment).
  • You do not have to define register bitmasks as they are already defined and available in CMSIS header files. You can find these files under the folder chibios182\os\common\ext\ST.

Be the first to reply at ChibiOS/HAL design: an object-oriented approach

Leave a Reply