UART Serial Console from Scratch
Goal: Configure USART2 on an STM32 to send and receive characters over serial — bare metal, register-level. Build a minimal printf so you can debug from the MCU.
Prerequisites: UART SPI I2C, Interrupts and Timers, GPIO and Digital IO, Memory-Mapped IO
What We’re Building
A serial console at 115200 baud over the Nucleo’s built-in ST-Link virtual COM port (USART2, PA2=TX, PA3=RX). On your PC, open a terminal (screen /dev/ttyACM0 115200) and see output from the MCU.
Step 1: Enable Clocks and Configure GPIO
USART2 is on APB1. TX/RX pins need alternate function mode (AF7 for USART2).
#include <stdint.h>
// RCC
#define RCC_BASE 0x40023800
#define RCC_AHB1ENR (*(volatile uint32_t *)(RCC_BASE + 0x30))
#define RCC_APB1ENR (*(volatile uint32_t *)(RCC_BASE + 0x40))
// GPIOA
#define GPIOA_BASE 0x40020000
#define GPIOA_MODER (*(volatile uint32_t *)(GPIOA_BASE + 0x00))
#define GPIOA_AFRL (*(volatile uint32_t *)(GPIOA_BASE + 0x20))
// USART2
#define USART2_BASE 0x40004400
#define USART2_SR (*(volatile uint32_t *)(USART2_BASE + 0x00))
#define USART2_DR (*(volatile uint32_t *)(USART2_BASE + 0x04))
#define USART2_BRR (*(volatile uint32_t *)(USART2_BASE + 0x08))
#define USART2_CR1 (*(volatile uint32_t *)(USART2_BASE + 0x0C))
void uart_init(void) {
// Enable GPIOA and USART2 clocks
RCC_AHB1ENR |= (1 << 0); // GPIOAEN
RCC_APB1ENR |= (1 << 17); // USART2EN
// PA2 (TX) and PA3 (RX) to alternate function mode (MODER = 10)
GPIOA_MODER &= ~((3 << 4) | (3 << 6));
GPIOA_MODER |= ((2 << 4) | (2 << 6));
// Select AF7 (USART2) for PA2 and PA3
GPIOA_AFRL &= ~((0xF << 8) | (0xF << 12));
GPIOA_AFRL |= ((7 << 8) | (7 << 12));
// Configure USART2: 115200 baud at 16 MHz APB1
// BRR = fclk / baud = 16000000 / 115200 = 138.89
// Mantissa = 138/16 = 8, Fraction = 138%16 = 10
// Or simply: BRR = 0x008B (integer part 8, fraction 11 ≈ 138.6875)
USART2_BRR = 0x008B;
// Enable TX, RX, and USART
USART2_CR1 = (1 << 13) // UE: USART enable
| (1 << 3) // TE: transmitter enable
| (1 << 2); // RE: receiver enable
}Baud Rate Calculation
BRR = fclk / baud
= 16,000,000 / 115,200
= 138.89
BRR register = mantissa:fraction (12:4 bits)
mantissa = 138 / 16 = 8
fraction = 138 % 16 = 10 (0xA)
But 138.89 rounds → BRR = 0x008B gives closer match
A 2% baud rate mismatch causes bit errors at the end of each frame. The receiver samples at 16× oversampling — each bit gets 16 internal samples, and the middle samples are used for the decision.
Step 2: Send and Receive Characters
void uart_putc(char c) {
while (!(USART2_SR & (1 << 7))) {} // wait for TXE (TX empty)
USART2_DR = c;
}
void uart_puts(const char *s) {
while (*s)
uart_putc(*s++);
}
char uart_getc(void) {
while (!(USART2_SR & (1 << 5))) {} // wait for RXNE (RX not empty)
return USART2_DR;
}TXE (bit 7): transmit data register empty — safe to write next byte.
RXNE (bit 5): receive data register not empty — a byte has arrived.
Step 3: Minimal printf
A tiny integer-to-string formatter — enough for debugging:
void uart_print_int(int32_t val) {
char buf[12];
int i = 0;
if (val < 0) {
uart_putc('-');
val = -val;
}
if (val == 0) {
uart_putc('0');
return;
}
while (val > 0) {
buf[i++] = '0' + (val % 10);
val /= 10;
}
while (i > 0)
uart_putc(buf[--i]);
}
void uart_print_hex(uint32_t val) {
uart_puts("0x");
for (int i = 28; i >= 0; i -= 4) {
uint8_t nib = (val >> i) & 0xF;
uart_putc(nib < 10 ? '0' + nib : 'A' + nib - 10);
}
}Step 4: Main — Echo Console
static void delay(volatile uint32_t count) { while (count--) {} }
void main(void) {
uart_init();
uart_puts("=== STM32 UART Console ===\r\n");
uart_puts("Type something:\r\n");
while (1) {
char c = uart_getc();
uart_putc(c); // echo back
if (c == '\r') { // Enter key
uart_puts("\r\n");
}
}
}Step 5: Test
make flash
# On PC (Linux):
screen /dev/ttyACM0 115200
# Or:
picocom -b 115200 /dev/ttyACM0You should see the banner message and be able to type characters that echo back.
Step 6: Add Interrupt-Driven Receive (Optional)
Polling uart_getc blocks. For real firmware, use the RXNE interrupt:
#define NVIC_ISER1 (*(volatile uint32_t *)0xE000E104)
void uart_init_irq(void) {
uart_init();
USART2_CR1 |= (1 << 5); // RXNEIE: enable RXNE interrupt
NVIC_ISER1 |= (1 << 6); // enable IRQ 38 (USART2) in NVIC
}
volatile char rx_buf[256];
volatile uint16_t rx_head = 0;
void USART2_IRQHandler(void) {
if (USART2_SR & (1 << 5)) {
rx_buf[rx_head++ & 0xFF] = USART2_DR; // reading DR clears RXNE
}
}Now the main loop can do other work while characters arrive asynchronously into the ring buffer. See Interrupts and Timers for ISR best practices.
Exercises
-
Command parser: Build a mini command interpreter. Accumulate characters into a line buffer. On Enter, parse and execute:
led on,led off,adc read,uptime. -
Printf with formatting: Implement a subset of
printfthat handles%d,%x,%s, and%c. Use variadic arguments (<stdarg.h>works in freestanding C). -
Baud rate switching: Add a command to change baud rate at runtime. Recalculate BRR on the fly. The PC terminal must reconnect at the new baud.
-
DMA transmit: Configure DMA to send a buffer over UART without CPU involvement. Measure how much CPU time is freed for a 1KB message.
Next: 09 - Read a Sensor over I2C — talk to external hardware.