← LOGBOOK LOG-353
EXPLORING · ELECTRONICS ·
STM32HALCUBEMXCUBEIDEARMCORTEX-MEMBEDDEDGPIOUARTI2C

STM32 HAL — Hardware Abstraction Layer

Understanding how STM32's HAL works before the Blue Pill arrives. What HAL actually is, how CubeMX generates initialization code, and how HAL calls map to real hardware.

Context

Blue Pill is on order. A few days before it arrives — good time to understand the toolchain instead of showing up to the board cold.

The stm32-board-options entry mentioned HAL in passing: “ST’s driver library. Higher-level than bare registers, portable across STM32 family. Most tutorials use HAL.” That’s not enough. Spent today going deeper on what HAL actually is and how code written against it looks.


What HAL Is

HAL stands for Hardware Abstraction Layer. It’s a C library that ST ships with every STM32 chip, bundled inside a package called the STM32Cube firmware (e.g. STM32CubeF1 for the F1/F103 series).

The job of HAL is to wrap the raw hardware registers in functions that are consistent across the whole STM32 family. Without HAL, enabling UART on an STM32F103 means writing to specific registers at specific addresses in that chip’s memory map. The same operation on an STM32H7 means different registers at different addresses. HAL hides that — HAL_UART_Transmit() works on both chips. Only the initialization code changes.

This is why people call STM32 skills “transferable within the family.” You’re not learning chip-specific register layouts. You’re learning HAL, which is the same API surface regardless of series.


The Code Structure CubeMX Generates

STM32CubeMX (now integrated into CubeIDE) is a graphical peripheral configurator. You drag pins to functions, set clock speeds, enable peripherals — then it generates C code that sets everything up. The generated project has a predictable structure:

Core/
  Inc/
    main.h
    stm32f1xx_hal_conf.h   ← which HAL modules to include
  Src/
    main.c                 ← your entry point + generated init
    stm32f1xx_hal_msp.c    ← MCU-specific peripheral init (GPIO, clocks, DMA)
    stm32f1xx_it.c         ← interrupt handlers
Drivers/
  STM32F1xx_HAL_Driver/   ← the HAL source itself
  CMSIS/                  ← ARM's low-level hardware definitions

main.c has a clear structure:

int main(void) {
    HAL_Init();                // HAL housekeeping, SysTick setup
    SystemClock_Config();      // configure PLL, clock tree
    MX_GPIO_Init();            // generated: set up GPIO pins
    MX_USART2_UART_Init();     // generated: set up UART
    MX_I2C1_Init();            // generated: set up I2C

    while (1) {
        // your code here
    }
}

The MX_* functions are generated by CubeMX and live in main.c. They call HAL init functions with a configuration struct. You’re not supposed to edit them directly — CubeMX will overwrite them the next time you regenerate. User code goes in the /* USER CODE BEGIN */ / /* USER CODE END */ comment blocks, which CubeMX preserves on regeneration.


HAL Handles, Init Structs, and the Call Pattern

HAL has a consistent API pattern across peripherals. Every peripheral has:

  1. A handle — a struct that holds the peripheral’s state and configuration. e.g. UART_HandleTypeDef huart2
  2. An init struct — nested inside the handle, holds configuration values. e.g. huart2.Init.BaudRate = 115200
  3. An init functionHAL_UART_Init(&huart2) — applies the config to the hardware
  4. Operation functionsHAL_UART_Transmit(), HAL_UART_Receive(), etc.

For UART transmit:

uint8_t msg[] = "hello\r\n";
HAL_UART_Transmit(&huart2, msg, sizeof(msg) - 1, HAL_MAX_DELAY);

Arguments: handle pointer, data buffer, length in bytes, timeout in milliseconds. HAL_MAX_DELAY means block until done (no timeout).

The same pattern holds for I2C:

uint8_t buf[2];
HAL_I2C_Master_Receive(&hi2c1, device_addr << 1, buf, 2, HAL_MAX_DELAY);

GPIO is slightly different since it’s simpler:

HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET);    // set high
HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5);                 // toggle
GPIO_PinState state = HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_0);

Blocking vs DMA vs Interrupt Modes

HAL exposes three modes for most peripherals:

  • Blocking (polling)HAL_UART_Transmit() — CPU waits until the operation finishes. Simple. CPU can’t do other work while waiting.
  • InterruptHAL_UART_Transmit_IT() — starts the transfer, returns immediately, fires a callback when done. CPU is free during transfer.
  • DMAHAL_UART_Transmit_DMA() — a DMA controller moves data without CPU involvement at all. CPU is free the whole time.

For a first project (GPIO blink, UART printf for debugging) blocking mode is fine. Interrupt and DMA become important for anything real-time or high-throughput.


The Clock Tree Problem

The most confusing part of STM32 startup isn’t HAL — it’s SystemClock_Config(). STM32 chips have a clock tree: multiple oscillator sources (internal RC, external crystal, PLL), and every peripheral is clocked from a branch of that tree. The F103 can run at up to 72 MHz, but by default after reset it runs at 8 MHz (internal RC oscillator, no PLL).

CubeMX generates the right SystemClock_Config() for whatever you configured in the clock tree view. If you’re writing code by hand without CubeMX, this is where most first-timers get stuck — peripherals silently run at the wrong speed because the clock isn’t configured.

For now: trust CubeMX to generate this, understand that it exists.


HAL vs LL vs Bare Registers

Three abstraction levels:

LevelWhat it looks likeOverhead
HALHAL_UART_Transmit(&huart2, buf, len, timeout)Some (function call + state checks)
LLLL_USART_TransmitData8(USART1, byte)Minimal (thin wrappers over registers)
BareUSART1->DR = byteNone (direct register write)

HAL is the right starting point. It’s what CubeMX generates. LL makes sense when you need lower overhead and understand what the registers do. Bare register access is for when you really know what you’re doing or HAL has a bug you need to work around.


What I Want to Try First With the Board

  1. Blink the onboard LED — GPIO output on PC13 (Blue Pill’s onboard LED). Confirm toolchain and ST-LINK wiring works end to end.
  2. UART printf — route printf to USART1 (PA9 TX, PA10 RX) via a USB-to-serial adapter. No onboard USB-serial bridge on the Blue Pill, so this needs the extra adapter. Use for all debugging going forward.
  3. Read a button — GPIO input, polling. Then same thing with interrupt mode.
  4. I2C scan — enumerate addresses on the I2C bus. Will need this for the MPU-6050.

Next

Board ships in a few days. In the meantime: install STM32CubeIDE and run through a CubeMX project generation for the STM32F103C8T6 — configure clock tree, enable USART1, generate code, see what comes out.