The HAL (Hardware Abstraction Layer) is STMicroelectronics’ native API for driving the peripherals of an STM32: GPIO, UART, timers, ADC, buses… It is the API that the code generated by CubeMX uses by default. It is portable (the same HAL_UART_Transmit call works from an F0 to an H7), at the price of a little verbosity. Learning the HAL means learning to program the microcontroller once it is configured: the natural sequel to CubeMX. For the language itself, see C++; to structure the whole, firmware.

What is it for?

The HAL answers a problem: driving an STM32 peripheral “by hand” means writing the right bits into the right registers, in the right order. That is tedious and hardly portable. The HAL wraps those sequences into readable functions common to the whole range:

  • Readability. HAL_UART_Transmit(&huart2, ...) says what it does. The equivalent register sequence does not.
  • Portability. Code written with the HAL carries over from one STM32 chip to another with few changes, an asset when reusing a project baseline.
  • Consistency with CubeMX. The handles the HAL works on are precisely what CubeMX generates: the two tools are made to go together.

The HAL is not the only layer: for raw performance, you drop down to LL or to the register. But it is the right default for most of a project.

The handles

Every peripheral is represented by a handle structure, generated by CubeMX: UART_HandleTypeDef huart2, TIM_HandleTypeDef htim3, ADC_HandleTypeDef hadc1… The handle holds the whole configuration of the peripheral (pins, baud rate, mode…). HAL functions take the address of the handle as their first argument:

HAL_UART_Transmit(&huart2, (uint8_t *)"Bonjour\r\n", 9, HAL_MAX_DELAY);

You do not create these handles yourself: you configure them in CubeMX and use them in the code. Picking the right handle (&huart2, not &huart1) is a frequent source of mistakes.

The three modes of a peripheral

One and the same peripheral is driven in three ways with the HAL, from the simplest to the most efficient. Understanding that choice is the heart of the HAL.

Three chronograms compared for one and the same transfer: in polling the core is busy for its whole duration; in interrupt mode it starts the transfer, stays free and is only called back at the end; in DMA the controller moves the data in its place while it stays free.|640

Polling (blocking). The function does the work and returns once it is finished (or after a timeout). Simple, but it freezes the program for the duration of the operation.

HAL_UART_Transmit(&huart2, data, len, 100);   // blocks until the end, or 100 ms

Interrupt. The _IT function starts the operation and returns at once. When the operation is finished, the HAL calls a callback that you write. The core stays free in the meantime. It requires the peripheral’s interrupt to be enabled in the NVIC (a tick box in CubeMX).

HAL_UART_Receive_IT(&huart2, &rx, 1);          // arms the reception of one byte
 
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) {
  // called automatically when a byte is received
  HAL_UART_Receive_IT(&huart2, &rx, 1);        // re-arm for the next one
}

DMA (Direct Memory Access). The _DMA function hands the transfer to a dedicated controller that moves the data without the core. A callback signals the end. Ideal for large volumes (audio, display, blocks of measurements). It requires a DMA channel configured in CubeMX.

HAL_UART_Transmit_DMA(&huart2, buffer, taille);   // the DMA takes care of it

Tip

Which mode should you pick? Polling for the simple and the occasional (a debug message). Interrupt to react to an event without blocking (incoming bytes, end of an ADC conversion). DMA for large, regular transfers. You often start in polling and move to interrupt or DMA when the blocking becomes a nuisance, exactly the logic of the interrupt and of structuring the firmware.

HAL or LL

Alongside the HAL, ST ships the LL layer (Low-Layer): very thin functions, often inline, which are almost register writes in disguise (LL_GPIO_SetOutputPin(GPIOA, LL_GPIO_PIN_5)). Compared with the HAL:

  • HAL — portable, readable, a little overhead and memory; the default.
  • LL — close to the register, fast, compact, less portable; for performance-critical paths or chips with very little memory.

You mix the two without trouble, peripheral by peripheral: HAL everywhere, LL where it counts. The choice is made in CubeMX (Project Manager → Advanced Settings → Driver Selector) and it is exclusive for one and the same peripheral: a given USART is generated in HAL or in LL, never in both at once. The layer below that again, direct register access, is the subject of going down to the register.

Example — Button, LED and serial port with the HAL

On a Nucleo configured in CubeMX (LD2 as an output, B1 as an input on PC13, USART2 at 115200), this program lights the LED for as long as the button is held and announces every change on the serial port, all in HAL, in polling:

/* USER CODE BEGIN 3 */
GPIO_PinState etat = HAL_GPIO_ReadPin(B1_GPIO_Port, B1_Pin);
 
if (etat == GPIO_PIN_RESET) {              // B1 pressed = low level on a Nucleo
  HAL_GPIO_WritePin(LD2_GPIO_Port, LD2_Pin, GPIO_PIN_SET);
} else {
  HAL_GPIO_WritePin(LD2_GPIO_Port, LD2_Pin, GPIO_PIN_RESET);
}
HAL_Delay(20);                             // simple debounce
/* USER CODE END 3 */

To move the serial reception to interrupt mode (echoing the characters received, without blocking the loop), you arm the reception once and write the callback, inside the USER CODE zones:

/* USER CODE BEGIN PV */
uint8_t rx;
/* USER CODE END PV */
 
/* USER CODE BEGIN 2 */         // once, after the init
HAL_UART_Receive_IT(&huart2, &rx, 1);
/* USER CODE END 2 */
 
/* USER CODE BEGIN 4 */
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) {
  HAL_UART_Transmit(&huart2, &rx, 1, 10);   // sends the received byte back
  HAL_UART_Receive_IT(&huart2, &rx, 1);     // re-arms the reception
}
/* USER CODE END 4 */

The main loop stays free while the characters are received and echoed “in the background” by the interrupt. This is the move from blocking to reactive, the gesture that tells a firmware that waits from a firmware that reacts.

The program says nothing of its own: it only returns what it receives. Typing hello into the monitor’s input box, the board returns:

hello

Character by character, in fact: five trips through the callback. And all the while the LED keeps following the button: that is the proof that the loop never waited.

Pitfalls

Forgetting HAL_Init() or SystemClock_Config(). CubeMX places them at the top of main. In hand-written code, leaving them out leaves the HAL or the clock uninitialised and nothing works. Check that they are there before anything else.

Blocking on an infinite timeout. HAL_UART_Transmit(..., HAL_MAX_DELAY) waits indefinitely. If the link is silent, the whole program freezes. Prefer a finite timeout when blocking is risky, or move to interrupt/DMA.

Interrupt not enabled in the NVIC. Using HAL_..._IT without ticking the peripheral’s line in CubeMX’s NVIC tab: the callback is never called, without any build error. The number one trap of interrupt mode.

Forgetting to re-arm the _IT reception. One HAL_UART_Receive_IT triggers only one callback. Without re-arming it inside, you only ever receive the first byte.

Wrong handle. Passing &huart1 instead of &huart2 (or one timer for another): the peripheral you address is not the one you think.

HAL_Delay inside an interrupt. HAL_Delay relies on the SysTick interrupt. Calling it from a callback of higher or equal priority can block for good. Do not wait inside an interrupt: do the strict minimum there.

Exercises

Exercise 1 — Blocking or not?

In the example, the main loop calls HAL_Delay(20) on every pass. During those 20 ms, what happens if a character arrives on the serial port in interrupt mode? And in polling mode (blocking HAL_UART_Receive)?

Exercise 2 — HAL or LL?

You have to generate a square wave as fast as possible by toggling a pin in a tight loop. Is HAL_GPIO_TogglePin or LL_GPIO_TogglePin the better choice? Why? And which solution would be better still?

Where it fits in the project

  • Step 4 of the proof-of-concept phase. The HAL is the tool for writing the logic of the PoC once the microcontroller is configured. Picking the right mode (polling / interrupt / DMA) as early as the PoC saves rewriting everything when blocking becomes a problem.
  • Readability and hand-over. HAL code is read back and picked up more easily than register code, which counts in team projects and in the technical file.

Understanding the three modes (and that CubeMX configures the handles the HAL works on) gives the grammar of the native STM32: configure on one side, drive on the other, choosing the right level of reactivity every time.

Going further

See also