Skip to main content

UART and virtual COM port

Most Discovery and Nucleo boards route one USART to the on-board ST-LINK, which exposes it to the host as a virtual COM port (VCP). No extra adapter is needed — the same USB cable used for debugging carries the serial console.

MSP initialisation

Board-specific pin mapping in stm32xxxx_hal_msp.c:

#define VCP_TX_Pin GPIO_PIN_9
#define VCP_TX_GPIO_Port GPIOA
#define VCP_RX_Pin GPIO_PIN_7
#define VCP_RX_GPIO_Port GPIOB

/**
* @brief UART MSP Initialization
* This function configures the hardware resources used in this example
* @param huart: UART handle pointer
* @retval None
*/
void HAL_UART_MspInit(UART_HandleTypeDef *huart) {
GPIO_InitTypeDef GPIO_InitStruct = { 0 };
RCC_PeriphCLKInitTypeDef PeriphClkInitStruct = { 0 };
if (huart->Instance == USART1) {
/* USER CODE BEGIN USART1_MspInit 0 */

/* USER CODE END USART1_MspInit 0 */

/** Initializes the peripherals clock
*/
PeriphClkInitStruct.PeriphClockSelection = RCC_PERIPHCLK_USART1;
PeriphClkInitStruct.Usart16ClockSelection =
RCC_USART16CLKSOURCE_D2PCLK2;
if (HAL_RCCEx_PeriphCLKConfig(&PeriphClkInitStruct) != HAL_OK) {
Error_Handler();
}

/* Peripheral clock enable */
__HAL_RCC_USART1_CLK_ENABLE();

__HAL_RCC_GPIOB_CLK_ENABLE();
__HAL_RCC_GPIOA_CLK_ENABLE();
/**USART1 GPIO Configuration
PB7 ------> USART1_RX
PA9 ------> USART1_TX
*/
GPIO_InitStruct.Pin = VCP_RX_Pin;
GPIO_InitStruct.Mode = GPIO_MODE_AF_PP;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
GPIO_InitStruct.Alternate = GPIO_AF7_USART1;
HAL_GPIO_Init(VCP_RX_GPIO_Port, &GPIO_InitStruct);

GPIO_InitStruct.Pin = VCP_TX_Pin;
GPIO_InitStruct.Mode = GPIO_MODE_AF_PP;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
GPIO_InitStruct.Alternate = GPIO_AF7_USART1;
HAL_GPIO_Init(VCP_TX_GPIO_Port, &GPIO_InitStruct);

/* USER CODE BEGIN USART1_MspInit 1 */

/* USER CODE END USART1_MspInit 1 */
}
}

/**
* @brief UART MSP De-Initialization
* This function freeze the hardware resources used in this example
* @param huart: UART handle pointer
* @retval None
*/
void HAL_UART_MspDeInit(UART_HandleTypeDef *huart) {
if (huart->Instance == USART1) {
/* Peripheral clock disable */
__HAL_RCC_USART1_CLK_DISABLE();

/**USART1 GPIO Configuration
PB7 ------> USART1_RX
PA9 ------> USART1_TX
*/
HAL_GPIO_DeInit(VCP_RX_GPIO_Port, VCP_RX_Pin);

HAL_GPIO_DeInit(VCP_TX_GPIO_Port, VCP_TX_Pin);
}
}

Points that bite

  • The pins are board-specific. This mapping — PA9 TX, PB7 RX — is what several Discovery boards route to the ST-LINK VCP. Others use PA9/PA10, in which case the GPIOB clock enable is unnecessary. Copying a generated msp.c between boards without checking the schematic produces a port that opens successfully and transmits nothing.
  • Keep the defines and the comments in agreement. These blocks are regenerated by CubeMX, and a stale comment describing different pins than the #defines above it is a reliable source of lost time.
  • Both GPIO port clocks must be enabled when TX and RX are on different ports. Missing one leaves that pin as a plain input and the direction fails silently.
  • Usart16ClockSelection selects the kernel clock feeding USART1 and USART6. The baud-rate divisor is computed from it, not from the APB clock, so a mismatch produces a working port at the wrong bit rate — garbage on the terminal at every setting. See Peripheral kernel clocks.
  • GPIO_SPEED_FREQ_LOW is adequate up to a few hundred kbaud. Raise it for faster links, or the edges are too slow and framing errors appear.
  • TX and RX are named from the MCU's point of view. The board wires the ST-LINK's TX to the MCU's RX. When wiring an external adapter instead, the lines cross; connecting TX to TX gives a silent link in both directions.

Retargeting printf

printf reaches the UART by overriding a newlib hook. There are two override points, and either works — pick one, not both.

Bring up the USART first. In STM32CubeIDE this is New Project ▸ Board Selector with Initialize all peripherals with their default mode: Yes, then enable USART1 in Asynchronous mode. Then verify with a serial terminal on the host.

Override _write

The lower of the two hooks. newlib routes every stream — printf, fputs, fwrite — through _write, so this covers stderr as well as stdout. Put it in main.c or a dedicated syscalls.c:

int _write(int file, char *ptr, int len) {
(void)file;
if (HAL_UART_Transmit(&huart1, (uint8_t *)ptr, len, HAL_MAX_DELAY) != HAL_OK) {
return -1;
}
return len;
}

Override __io_putchar

The hook CubeIDE's generated syscalls.c already calls from its own _write, one character at a time. This is the form ST's examples use:

#include <stdio.h>

/* USER CODE BEGIN 4 */
#define PUTCHAR_PROTOTYPE int __io_putchar(int ch)

/**
* @brief Retargets the C library printf function to the USART.
* @retval int
*/
PUTCHAR_PROTOTYPE
{
HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 0xFFFF);
return ch;
}
/* USER CODE END 4 */

Keep the definition between the USER CODE markers so CubeMX does not overwrite it on the next regeneration. Do not also define _write — the generated one already forwards to __io_putchar, and a second _write collides at link time.

Two things to expect:

  • Output is buffered until a newline, or not flushed at all, unless buffering is disabled: setvbuf(stdout, NULL, _IONBF, 0); early in main().
  • HAL_UART_Transmit blocks. A printf inside an interrupt handler, or inside a fast control loop, will affect timing. Use a ring buffer drained by DMA when the output rate matters.

Nothing appears on the terminal

Work through it in this order — the causes are roughly in frequency order:

  • Wrong COM port selected, or another program still holds it open.
  • Baud rate mismatch. If the text is garbage rather than absent, this or the kernel clock is the cause.
  • The peripheral or GPIO clock was never enabled.
  • The pins were configured with the wrong Alternate function number. AF7 is USART1–3 on most families, AF8 for UART4–8; the table is in the datasheet, not the reference manual.
  • The board routes the VCP to a different USART than the code initialises.
  • ST-LINK firmware too old to expose the VCP. Update it from STM32CubeIDE or STM32CubeProgrammer.