STM32 UART printf Debugging with HAL_UART_Transmit and a Serial Terminal
Breakpoints are powerful, but some bugs only appear while the program runs at full speed, and stopping the core changes the timing. In these cases a stream of text messages is the most practical debugging tool. On STM32 the easiest route is a UART connected to your PC, with the standard C printf function redirected to it.
This tutorial shows how to send text with HAL_UART_Transmit, how to make printf work, how to print floating point values, and how to receive simple one-character commands from the PC. The same technique will be used in later tutorials to display ADC readings and sensor values.
UART Basics You Need
A UART sends bytes one bit at a time on a TX line and receives on an RX line, with no shared clock. Both ends must agree on the format, usually written as 115200 8N1: 115200 bits per second, 8 data bits, no parity, 1 stop bit. Each byte takes 10 bit times on the wire (start bit, 8 data bits, stop bit), so at 115200 baud you can send at most about 11520 bytes per second.
That number matters. A 50-character message takes over 4 ms to send, and HAL_UART_Transmit blocks until it is finished. Printing too much inside a fast loop will slow your program noticeably.
Connections
| Board | UART | TX pin | RX pin | PC connection |
|---|---|---|---|---|
| Nucleo-F401RE | USART2 | PA2 | PA3 | Built-in ST-LINK virtual COM port over the same USB cable |
| Blue Pill | USART1 | PA9 | PA10 | USB to TTL adapter (CP2102, CH340 or FT232) set to 3.3 V |
For the Blue Pill, connect adapter RX to PA9, adapter TX to PA10, and GND to GND. TX always goes to RX. Do not connect the adapter's 5 V pin to the board if the board is already powered from the ST-LINK. On Windows, open Device Manager to find the COM port number, then open it in PuTTY, Tera Term or the serial monitor of the Arduino IDE at 115200 baud.
CubeMX Settings
- On the Nucleo, USART2 is already enabled in Asynchronous mode by the board template. Check under Connectivity, USART2 that Baud Rate is 115200, Word Length 8 Bits, Parity None, Stop Bits 1.
- On the Blue Pill, enable USART1 in Asynchronous mode with the same parameters. CubeMX assigns PA9 and PA10 automatically.
- Open Project Properties, C/C++ Build, Settings, MCU Settings and tick Use float with printf from newlib-nano (-u _printf_float). Without this,
%fprints nothing. - Generate the code.
Redirecting printf and Receiving Commands
STM32CubeIDE projects include a file syscalls.c that implements the low-level functions newlib needs. Its _write function is declared weak, so you can provide your own version that sends the characters through the UART. Every printf eventually calls _write.
/* USER CODE BEGIN Includes */
#include <stdio.h>
#include <string.h>
/* USER CODE END Includes */
/* USER CODE BEGIN 0 */
/* Replace the weak _write() from syscalls.c.
file 1 = stdout, file 2 = stderr; both go to the UART. */
int _write(int file, char *ptr, int len)
{
(void)file;
HAL_UART_Transmit(&huart2, (uint8_t *)ptr, (uint16_t)len, HAL_MAX_DELAY);
return len;
}
/* USER CODE END 0 */
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_USART2_UART_Init(); /* 115200 8N1 on PA2/PA3 */
/* USER CODE BEGIN 2 */
/* Send without buffering so each printf appears immediately,
even when the string has no newline at the end. */
setvbuf(stdout, NULL, _IONBF, 0);
/* Plain HAL transmit, without printf */
const char banner[] = "\r\nSTM32 UART demo ready\r\n";
HAL_UART_Transmit(&huart2, (uint8_t *)banner, strlen(banner), 100);
printf("SystemCoreClock = %lu Hz\r\n", SystemCoreClock);
printf("Commands: 1 = LED on, 0 = LED off, s = status\r\n");
uint32_t counter = 0;
uint32_t last = HAL_GetTick();
uint8_t rx;
/* USER CODE END 2 */
while (1)
{
/* USER CODE BEGIN 3 */
/* Wait up to 10 ms for one character from the PC */
if (HAL_UART_Receive(&huart2, &rx, 1, 10) == HAL_OK)
{
switch (rx)
{
case '1':
HAL_GPIO_WritePin(LD2_GPIO_Port, LD2_Pin, GPIO_PIN_SET);
printf("LED on\r\n");
break;
case '0':
HAL_GPIO_WritePin(LD2_GPIO_Port, LD2_Pin, GPIO_PIN_RESET);
printf("LED off\r\n");
break;
case 's':
printf("uptime %lu ms, counter %lu\r\n", HAL_GetTick(), counter);
break;
default:
printf("Unknown command '%c'\r\n", rx);
break;
}
}
/* Print a heartbeat once a second, including a float value */
if (HAL_GetTick() - last >= 1000)
{
last += 1000;
counter++;
float seconds = HAL_GetTick() / 1000.0f;
printf("tick %lu, t = %.2f s\r\n", counter, seconds);
}
/* USER CODE END 3 */
}
}
How It Works
HAL_UART_Transmit(&huart2, data, size, timeout)sendssizebytes and returnsHAL_OK,HAL_TIMEOUTorHAL_ERROR.HAL_MAX_DELAYmeans wait as long as needed.HAL_UART_Receive(&huart2, &rx, 1, 10)waits up to 10 ms for one byte. If nothing arrives, it returnsHAL_TIMEOUTand the loop continues, so the heartbeat keeps running.setvbuf(stdout, NULL, _IONBF, 0)disables newlib's line buffer. Otherwise text is held until a newline is printed, which confuses beginners when a prompt without a newline never appears.%luis used foruint32_tbecause on ARM GCC it is defined asunsigned long. Using%dgives a compiler warning.\r\nis sent because many terminal programs need a carriage return as well as a line feed to start the next line at the left margin.
Making Debug Output Easy to Remove
Debug messages are useful during development but cost time and flash in the final firmware. A common practice is to wrap them in a macro that compiles to nothing when a symbol is not defined. Add #define DEBUG_PRINT near the top of main.c, and define DBG(...) as printf(__VA_ARGS__) when it is set and as an empty statement otherwise. In the release build you remove one line and every debug message disappears, without touching the rest of the code. Keep messages short and give each one a prefix such as ADC: or ERR: so you can search the terminal log quickly.
Troubleshooting
- Garbage characters: baud rate mismatch, or the system clock is not what CubeMX assumes. Check that
SystemCoreClockprints the expected value. - Nothing at all on the Nucleo: you opened the wrong COM port. Look for STMicroelectronics STLink Virtual COM Port in Device Manager.
- Floats print as blank: the
-u _printf_floatoption is not enabled. It adds several kilobytes of code, which matters on small chips. - Characters lost while receiving: polling with a 10 ms timeout can miss bytes sent while
printfis busy. For real command parsing, useHAL_UART_Receive_ITor DMA. - Program hangs at first printf with a Blue Pill: TX and RX are swapped, or the adapter is not powered.
Summary
You redirected printf to USART2 by overriding _write, enabled float formatting, disabled stdout buffering, and handled single-character commands with HAL_UART_Receive. Remember that blocking transmission costs time at 115200 baud, so print only what you need. In the next tutorial you will use this output to display ADC readings from a potentiometer.