Series / Working with STM32 Peripherals / UART Logging on STM32

UART Logging on STM32

Set up UART output on STM32 for simple firmware logs, connect baud-rate settings to clock assumptions, and use serial output alongside LEDs and the debugger.

On this page

The previous articles used LEDs, buttons, timers, and the debugger to observe firmware behavior.

UART logging adds another kind of visibility: messages from the firmware while it runs. A serial log can show startup progress, state changes, error paths, measured values, and simple diagnostics without stopping the CPU at a breakpoint.

This article sets up a basic polling UART transmit path for STM32 firmware.

What This Article Covers

This article covers:

  • Verifying the UART path on the board.
  • Understanding TX, RX, baud rate, and frame format.
  • Configuring UART output with STM32 HAL.
  • Sending simple strings with HAL_UART_Transmit.
  • Checking clock and baud-rate assumptions.
  • Debugging common serial-output failures.

It does not cover receive interrupts, DMA, ring buffers, command shells, low-power UART behavior, or full printf retargeting. Those are useful later, but the first UART milestone should be simple: send a known string and see it on the host machine.

Verify the UART Path

Before writing code, identify where the UART signal will go.

On many Nucleo boards, one UART is connected through ST-LINK as a virtual COM port. On other setups, you may use an external USB-UART adapter connected to header pins.

Verify these details from the Nucleo L433RC-P user manual, schematic, and CubeMX pin view:

  • Which USART or UART instance is connected.
  • Which MCU pin is UART TX.
  • Which MCU pin is UART RX, if receive is needed.
  • Which alternate function maps those pins to the UART peripheral.
  • Whether ST-LINK exposes that UART as a virtual COM port.
  • Whether an external adapter needs a shared ground.
  • Whether the voltage level is 3.3 V logic, not RS-232 voltage.

UART examples often fail because the code uses one UART instance while the cable or virtual COM port is connected to another.

UART Mental Model

UART is an asynchronous serial interface. The most important signals are:

  • TX: transmit from this device.
  • RX: receive into this device.
  • GND: shared reference when using an external adapter.

For one-way logging, firmware only needs transmit. The host receives the bytes and displays them in a serial terminal.

Both ends must agree on the frame settings. A common starting point is:

115200 baud, 8 data bits, no parity, 1 stop bit

This is often written as 115200 8N1.

UART baud rate generation depends on the UART peripheral clock. If the clock tree is not what you think it is, the UART may transmit at the wrong speed and the terminal may show garbage.

Minimal HAL UART Setup

The example below uses USART2 as a concrete example. Adjust the instance, pins, alternate function, and clock-enable macros for the UART path verified on your board.

#include "stm32l4xx_hal.h"
#include <stdint.h>
#include <string.h>

UART_HandleTypeDef huart2;

static void uart2_gpio_init(void)
{
    __HAL_RCC_GPIOA_CLK_ENABLE();

    GPIO_InitTypeDef gpio = {0};
    gpio.Pin = GPIO_PIN_2 | GPIO_PIN_3;
    gpio.Mode = GPIO_MODE_AF_PP;
    gpio.Pull = GPIO_NOPULL;
    gpio.Speed = GPIO_SPEED_FREQ_VERY_HIGH;
    gpio.Alternate = GPIO_AF7_USART2;

    HAL_GPIO_Init(GPIOA, &gpio);
}

static void uart2_init(void)
{
    __HAL_RCC_USART2_CLK_ENABLE();
    uart2_gpio_init();

    huart2.Instance = USART2;
    huart2.Init.BaudRate = 115200;
    huart2.Init.WordLength = UART_WORDLENGTH_8B;
    huart2.Init.StopBits = UART_STOPBITS_1;
    huart2.Init.Parity = UART_PARITY_NONE;
    huart2.Init.Mode = UART_MODE_TX_RX;
    huart2.Init.HwFlowCtl = UART_HWCONTROL_NONE;
    huart2.Init.OverSampling = UART_OVERSAMPLING_16;
    huart2.Init.OneBitSampling = UART_ONE_BIT_SAMPLE_DISABLE;
    huart2.AdvancedInit.AdvFeatureInit = UART_ADVFEATURE_NO_INIT;

    if (HAL_UART_Init(&huart2) != HAL_OK)
    {
        while (1)
        {
        }
    }
}

static void log_write(const char *message)
{
    HAL_UART_Transmit(
        &huart2,
        (uint8_t *)message,
        (uint16_t)strlen(message),
        HAL_MAX_DELAY);
}

int main(void)
{
    HAL_Init();
    SystemClock_Config();
    uart2_init();

    log_write("STM32 UART log started\r\n");

    while (1)
    {
        log_write("tick\r\n");
        HAL_Delay(1000);
    }
}

The GPIO pins and alternate function shown here are examples. Verify them for your board and UART route before testing.

Explain the Important Lines

The UART handle stores HAL state for the peripheral:

UART_HandleTypeDef huart2;

HAL functions use this handle to know which UART instance they are operating on.

The GPIO clock must be enabled before configuring TX and RX pins:

__HAL_RCC_GPIOA_CLK_ENABLE();

The UART pins need alternate-function mode, not normal GPIO output mode:

gpio.Mode = GPIO_MODE_AF_PP;
gpio.Alternate = GPIO_AF7_USART2;

Alternate-function mapping is where many serial problems start. The pin, UART instance, and alternate-function number must agree with the datasheet and CubeMX configuration.

The UART peripheral clock must also be enabled:

__HAL_RCC_USART2_CLK_ENABLE();

The baud rate and frame format are configured in the handle:

huart2.Init.BaudRate = 115200;
huart2.Init.WordLength = UART_WORDLENGTH_8B;
huart2.Init.StopBits = UART_STOPBITS_1;
huart2.Init.Parity = UART_PARITY_NONE;

The logging helper sends a string with a blocking transmit call:

HAL_UART_Transmit(&huart2, (uint8_t *)message, (uint16_t)strlen(message), HAL_MAX_DELAY);

Blocking transmit is acceptable for first logs. It is not the final answer for high-rate or time-sensitive firmware.

CubeMX-Generated UART Setup

If CubeMX generated the UART setup, you may already have:

  • MX_USART2_UART_Init() or a similar function.
  • A global UART_HandleTypeDef huart2.
  • GPIO alternate-function setup in HAL_UART_MspInit.
  • UART clock enable code in the MSP file.

In that case, use the generated initialization rather than duplicating it:

HAL_Init();
SystemClock_Config();
MX_USART2_UART_Init();
log_write("STM32 UART log started\r\n");

The concepts are the same. CubeMX just places the initialization code in generated files.

CMake Support

If UART HAL code was not already compiled, add the HAL UART source file to the firmware target:

generated/Drivers/STM32L4xx_HAL_Driver/Src/stm32l4xx_hal_uart.c

If your generated MSP file owns UART GPIO setup, make sure that source file is compiled too, commonly:

generated/Core/Src/stm32l4xx_hal_msp.c

Missing UART HAL sources usually produce linker errors for functions such as HAL_UART_Init or HAL_UART_Transmit.

Open a Serial Terminal

Build and flash the firmware:

cmake --build build
openocd -f interface/stlink.cfg -f target/stm32l4x.cfg -c "program build/firmware.elf verify reset exit"

Then open the serial port exposed by ST-LINK or your USB-UART adapter at:

115200 8N1

The exact device name depends on the host operating system and adapter. On macOS, serial devices often appear under /dev/cu.*. VSCode serial monitor extensions, screen, minicom, and other terminal tools can all work.

If the firmware prints once at startup and you opened the terminal after reset, you may miss the first message. Keep the repeating tick message while bringing up the link.

Debug UART Output

If nothing appears in the terminal, use the debugger and a checklist.

Useful checks:

  • Confirm SystemClock_Config() runs before UART initialization.
  • Confirm HAL_UART_Init() returns HAL_OK.
  • Break before HAL_UART_Transmit() and confirm the code reaches it.
  • Confirm the UART instance matches the board route.
  • Confirm the TX pin uses the correct alternate function.
  • Confirm the terminal is connected to the correct serial port.
  • Confirm the terminal baud rate is 115200.
  • If using an external adapter, confirm TX/RX wiring and shared ground.

If the terminal shows unreadable characters, suspect baud rate, clock configuration, or the wrong serial settings.

If the debugger shows transmit calls succeeding but the host receives nothing, suspect pin routing, board jumpers/solder bridges, adapter wiring, or the wrong UART instance.

Common Mistakes

The first common mistake is using the wrong UART instance. USART1, USART2, and other instances are separate peripherals with separate pins and clocks.

The second is assuming the ST-LINK virtual COM port is connected to the UART instance used in code. Verify the board documentation.

The third is missing a shared ground when using an external USB-UART adapter.

The fourth is using the wrong voltage level. STM32 UART pins use logic-level signaling, not RS-232 voltage levels.

The fifth is configuring TX and RX as normal GPIO instead of alternate-function pins.

The sixth is choosing the wrong alternate-function mapping for the selected pins.

The seventh is missing stm32l4xx_hal_uart.c from the CMake source list.

The eighth is opening the terminal after a one-time startup message already transmitted.

The ninth is using blocking transmit in code that later needs tight timing. Blocking logs are fine for bring-up, but they can distort timing-sensitive firmware.

What This Proves

UART logging proves that the firmware can communicate runtime information to the host without stopping at a breakpoint.

It also proves several setup pieces:

  • UART peripheral clocking.
  • GPIO alternate-function configuration.
  • Baud-rate configuration tied to clock assumptions.
  • Host serial-port connection.
  • Basic firmware-to-host visibility.

That makes later I2C and SPI bring-up easier because the firmware can report what it is trying to do.

What This Does Not Prove

This does not prove robust logging architecture.

It does not handle receive commands, interrupt-driven transmit, DMA, buffering, log levels, timestamps, or thread safety. It also does not replace the debugger or hardware measurement tools.

The point is narrower: send simple serial messages reliably enough to support peripheral bring-up.

Next Steps

The next article uses I2C on STM32 for real modules.

UART logging will be useful there because I2C failures often need more visibility than an LED can provide: device addresses, return codes, timeout paths, and retry behavior.