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/ttyACM0

You 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

  1. 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.

  2. Printf with formatting: Implement a subset of printf that handles %d, %x, %s, and %c. Use variadic arguments (<stdarg.h> works in freestanding C).

  3. 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.

  4. 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.