Skip to content

Part 2 · C Meets the Metal39 min read

UART: Serial Communication

The UART is the first peripheral you bring up on a new board and the last one you remove from a shipping product. It carries debug logs, bootloader images, modem AT commands, GPS sentences and factory test commands. It is simple enough to drive with a few register writes and subtle enough that dropped bytes, wrong baud rates and corrupted frames are routine bugs. This lesson covers the wire format and electrical standards, the STM32F407’s USART2 at register level, polled and interrupt-driven drivers with lock-free ring buffers, how to make printf print, how to design a robust packet protocol with COBS framing and a CRC, a reusable command shell, flow control and RS-485, and how to test all of it.

“Asynchronous” means there is no clock wire. Transmitter and receiver agree in advance on a bit rate, the baud rate, and the receiver resynchronizes on every character. The line idles high. Each character is a frame:

idle start D0 D1 D2 D3 D4 D5 D6 D7 stop idle
----+ +---+ +-------+ +---+ +-----------
| | | | | | | |
+------+ +---+ +-------+ +---+
0 1 0 1 1 0 0 1 0 1
Byte 0x4D = 0b01001101, sent LSB first. At 115200 baud each bit is 8.68 us,
so one 10-bit frame takes 86.8 us: at most 11520 bytes per second.
  • Start bit: always 0. The falling edge from idle tells the receiver a frame is starting.
  • Data bits: 8 almost always (7 and 9 exist), least significant bit first.
  • Parity bit (optional): even or odd parity over the data bits. It catches single-bit errors and nothing else, so most protocols skip it and use a CRC at the packet level.
  • Stop bit(s): 1 (or 1.5, 2) bits of 1, guaranteeing an idle-to-start edge before the next frame.

The overwhelmingly common configuration is 8N1: 8 data bits, no parity, 1 stop bit. Ten bit times per byte, so throughput in bytes per second is baud / 10.

The receiver detects the start edge, then samples each bit in what it believes is the middle of the bit period. The STM32 USART oversamples by 16 and takes a majority vote of three samples around the centre. Because there is no shared clock, a mismatch between the two sides’ baud rates makes the sampling point drift a little further with every bit. By the stop bit, about 9.5 bit times after the start edge, the sample must still land inside the stop bit. That gives a theoretical limit around 5% total mismatch between transmitter and receiver; the reference manual’s tolerance tables give somewhat less once sampling granularity and noise are included.

The practical rule: keep each side within about 2% of nominal. Crystal clocks are hundreds of times better than that. The internal RC oscillator, at about 1% and drifting with temperature, is marginal on its own and fails when the other end is also off. And if you are 2× or 3× off because the clock configuration is wrong, you get garbage characters, the signature symptom described in Clocks, reset, and timing.

The same frame format travels over very different physical layers:

StandardLevelsDistanceTopologyNotes
TTL/CMOS UART0 V / 3.3 V (or 5 V), idle hightens of cmpoint to pointwhat the MCU pins speak
RS-232+3 to +15 V = 0, -3 to -15 V = 1 (inverted)~15 mpoint to pointneeds a transceiver (MAX3232); old PCs, industrial gear
RS-485differential pair, ±200 mV threshold~1200 mmultidrop bus, up to 32 unit loadshalf-duplex common; Modbus, DMX, industrial

On the bench you will mostly use a USB-to-serial adapter (FTDI FT232, Silicon Labs CP2102, WCH CH340) at 3.3 V TTL levels. Three wires: adapter TX to MCU RX, adapter RX to MCU TX, and ground to ground. TX to TX is the most common wiring mistake; a missing ground is the second. Some Discovery board revisions route a virtual COM port from the on-board ST-LINK to USART2 on PA2/PA3; check your board’s user manual, and if yours does not, use an adapter on those pins.

USART2 sits on APB1 at 0x40004400. Its clock enable is RCC_APB1ENR bit 17. TX is PA2 and RX is PA3, both alternate function 7. The registers we use:

OffsetRegisterUse
0x00SRstatus flags
0x04DRdata: write to send, read to receive
0x08BRRbaud rate divisor
0x0CCR1enable, TX/RX enable, interrupt enables, word length, parity
0x10CR2stop bits, clock output
0x14CR3flow control, DMA enable, half-duplex

The bits that matter (RM0090):

RegisterBitNameMeaning
SR0PEparity error
SR1FEframing error (stop bit was 0)
SR2NFnoise detected during sampling
SR3OREoverrun: a byte arrived while DR was still full
SR4IDLEline went idle after receiving
SR5RXNEDR holds a received byte
SR6TCtransmission complete: shift register empty too
SR7TXEDR is empty, you may write the next byte
CR12REreceiver enable
CR13TEtransmitter enable
CR15RXNEIEinterrupt on RXNE (and on ORE)
CR16TCIEinterrupt on TC
CR17TXEIEinterrupt on TXE
CR19, 10PS, PCEparity select, parity enable
CR112Mword length: 0 = 8 bits
CR113UEUSART enable
CR115OVER80 = oversample by 16
CR38, 9RTSE, CTSEhardware flow control

With 16× oversampling, the baud rate is f_PCLK / (16 × USARTDIV), and BRR holds USARTDIV as a fixed-point number: a 12-bit mantissa in bits 15:4 and a 4-bit fraction (sixteenths) in bits 3:0.

For 115200 baud from the 42 MHz PCLK1:

USARTDIV = 42 000 000 / (16 * 115200) = 22.786
mantissa = 22 = 0x16
fraction = 0.786 * 16 = 12.58, round to 13 = 0xD
BRR = 0x16D (= 365 decimal)
actual = 42 000 000 / (16 * 22.8125) = 115 068 baud, error -0.11 %

Because the fraction is in sixteenths and the oversampling is 16, the whole register is simply f_PCLK / baud, rounded: 42 000 000 / 115200 = 364.58, round to 365 = 0x16D. That gives the one-line formula used in the driver: BRR = (pclk + baud / 2) / baud. (With OVER8 = 1 the fraction field is only 3 bits and this shortcut no longer applies.)

BaudBRRActualError
96004375 (0x1117)96000 %
115200365 (0x16D)115 068-0.11 %
230400182 (0xB6)230 769+0.16 %
92160046 (0x2E)913 043-0.93 %

The formula depends entirely on knowing PCLK1. If the clock setup did not run, PCLK1 is 16 MHz, the UART runs at 16/42 of the intended rate, and every character is garbage.

The minimum working driver:

#define USART_SR_RXNE (1u << 5)
#define USART_SR_TXE (1u << 7)
#define USART_SR_TC (1u << 6)
#define USART_CR1_RE (1u << 2)
#define USART_CR1_TE (1u << 3)
#define USART_CR1_UE (1u << 13)
static void uart_poll_init(void)
{
RCC->AHB1ENR |= (1u << 0); /* GPIOA */
RCC->APB1ENR |= (1u << 17); /* USART2 */
(void)RCC->APB1ENR;
GPIOA->AFR[0] = (GPIOA->AFR[0] & ~((0xFu << 8) | (0xFu << 12)))
| (7u << 8) | (7u << 12); /* PA2, PA3 = AF7 */
GPIOA->PUPDR = (GPIOA->PUPDR & ~(3u << 6)) | (1u << 6); /* RX pull-up */
GPIOA->MODER = (GPIOA->MODER & ~((3u << 4) | (3u << 6)))
| (2u << 4) | (2u << 6);
USART2->BRR = (42000000u + 115200u / 2u) / 115200u; /* 0x16D */
USART2->CR1 = USART_CR1_UE | USART_CR1_TE | USART_CR1_RE; /* 8N1 */
}
static void uart_poll_putc(uint8_t b)
{
while (!(USART2->SR & USART_SR_TXE)) {
}
USART2->DR = b;
}
static uint8_t uart_poll_getc(void)
{
while (!(USART2->SR & USART_SR_RXNE)) {
}
return (uint8_t)USART2->DR;
}

TXE means the data register is empty, not that the byte has left the pin. The USART has a transmit data register and a shift register behind it: you can write the next byte as soon as the previous one moves into the shift register, so polling TXE keeps the line continuously busy. TC means both are empty and the last stop bit is out. Use TC when you need to know transmission has physically finished: before entering a low-power mode, before switching an RS-485 transceiver back to receive, before changing the baud rate.

Reading DR has a side effect: it clears RXNE. That is why you must never put USART2->DR in a debugger watch window while debugging receive code: the debugger’s read steals the byte.

Polling is fine for a bring-up “hello” and for a bootloader that does nothing else. Its weakness is receive: if the code is busy for longer than one character time (87 µs at 115200) when a byte arrives, the next byte overwrites it, ORE is set, and data is lost. Anything that receives data while doing other work needs interrupts (or DMA).

Newlib’s printf formats into a buffer and eventually calls a low-level function _write(file, ptr, len) to output it. With --specs=nosys.specs, _write is a stub that discards everything. Define your own and printf goes to the UART:

int _write(int file, char *ptr, int len); /* prototype: newlib does not declare it */
int _write(int file, char *ptr, int len)
{
(void)file; /* stdout and stderr both go to the UART */
for (int i = 0; i < len; i++) {
if (ptr[i] == '\n') {
uart_poll_putc('\r'); /* terminals expect CR LF */
}
uart_poll_putc((uint8_t)ptr[i]);
}
return len;
}

Two gotchas. Newlib buffers stdout by line, so output without a trailing \n may not appear until later; call fflush(stdout) or setvbuf(stdout, NULL, _IONBF, 0) at startup. And printf is large and stack-hungry, especially with floating point enabled. The syscall layer, newlib-nano, and lighter alternatives are covered in The C standard library on a microcontroller.

Interrupt-driven RX and TX with ring buffers

Section titled “Interrupt-driven RX and TX with ring buffers”

The production pattern: the interrupt handler moves bytes between the USART and two ring buffers, and the application reads and writes the buffers whenever it likes. Receive never loses a byte as long as the application drains the buffer faster on average than bytes arrive, and transmit never blocks as long as there is room.

Each ring buffer has exactly one producer and one consumer: for RX, the ISR produces and main consumes; for TX, main produces and the ISR consumes. That single-producer single-consumer (SPSC) arrangement needs no locks if it is built right:

buf (size 8, mask 7) head = 13 (next write), tail = 10 (next read)
+---+---+---+---+---+---+---+---+
| | | A | B | C | | | | count = head - tail = 3
+---+---+---+---+---+---+---+---+ slots: 10 & 7 = 2, 11 & 7 = 3, 12 & 7 = 4
^tail ^head
  • head is written only by the producer, tail only by the consumer. Each side reads the other’s index but never writes it, so there is no read-modify-write race on either.
  • Indices run freely and wrap at 2^32. The slot is index & mask. The fill level is head - tail in unsigned arithmetic, correct even across wraparound. “Full” is head - tail == size, so all slots are usable, with no wasted “one empty slot” as in the classic (head + 1) % size == tail design.
  • The size is a power of two, so & mask replaces a slow % and 2^32 is an exact multiple of the size, which keeps the masking consistent across wraparound.
  • Order of operations. The producer writes the data into the slot first, then publishes it by storing the new head. The consumer reads the data, then releases the slot by storing the new tail. Both the buffer and the indices are volatile, so the compiler keeps these accesses in program order, and a 32-bit index store is atomic on Cortex-M, so the other side sees either the old or the new index.

That last point is sufficient on a single-core Cortex-M4 where the only concurrency is interrupts. With DMA, multiple cores, or a Cortex-M7, you need explicit memory barriers or C11 atomics with acquire/release ordering; Concurrency, atomics, and memory ordering writes the fully portable version.

uart.h
#ifndef UART_H
#define UART_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
typedef struct {
uint32_t overruns; /* ORE: hardware lost a byte */
uint32_t frame_errors; /* FE or PE: byte discarded */
uint32_t rx_dropped; /* RX ring full: software lost a byte */
} uart_stats_t;
void uart_init(uint32_t baud);
bool uart_getc(uint8_t *out); /* non-blocking */
void uart_putc(uint8_t b); /* blocks only while TX ring full */
void uart_write(const uint8_t *data, size_t len);
void uart_puts(const char *s);
void uart_get_stats(uart_stats_t *out);
#endif
uart.c
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "stm32f407_regs.h"
#include "irq.h"
#include "nvic.h"
#include "uart.h"
#define UART_PCLK_HZ 42000000u /* APB1 after clock_init_168mhz() */
#define USART2_IRQn 38u
#define USART_SR_PE (1u << 0)
#define USART_SR_FE (1u << 1)
#define USART_SR_NF (1u << 2)
#define USART_SR_ORE (1u << 3)
#define USART_SR_RXNE (1u << 5)
#define USART_SR_TXE (1u << 7)
#define USART_CR1_RE (1u << 2)
#define USART_CR1_TE (1u << 3)
#define USART_CR1_RXNEIE (1u << 5)
#define USART_CR1_TXEIE (1u << 7)
#define USART_CR1_UE (1u << 13)
#define RX_SIZE 256u /* powers of two */
#define TX_SIZE 512u
typedef struct {
volatile uint8_t *buf;
uint32_t mask;
volatile uint32_t head; /* written only by the producer */
volatile uint32_t tail; /* written only by the consumer */
} ring_t;
static volatile uint8_t s_rx_mem[RX_SIZE];
static volatile uint8_t s_tx_mem[TX_SIZE];
static ring_t s_rx = { s_rx_mem, RX_SIZE - 1u, 0u, 0u };
static ring_t s_tx = { s_tx_mem, TX_SIZE - 1u, 0u, 0u };
static volatile uart_stats_t s_stats;
static bool ring_put(ring_t *r, uint8_t b)
{
uint32_t head = r->head;
if (head - r->tail > r->mask) { /* count == size: full */
return false;
}
r->buf[head & r->mask] = b; /* 1. write the data */
r->head = head + 1u; /* 2. publish it */
return true;
}
static bool ring_get(ring_t *r, uint8_t *b)
{
uint32_t tail = r->tail;
if (r->head == tail) { /* empty */
return false;
}
*b = r->buf[tail & r->mask]; /* 1. read the data */
r->tail = tail + 1u; /* 2. release the slot */
return true;
}
void uart_init(uint32_t baud)
{
RCC->AHB1ENR |= (1u << 0);
RCC->APB1ENR |= (1u << 17);
(void)RCC->APB1ENR;
GPIOA->AFR[0] = (GPIOA->AFR[0] & ~((0xFu << 8) | (0xFu << 12)))
| (7u << 8) | (7u << 12);
GPIOA->PUPDR = (GPIOA->PUPDR & ~(3u << 6)) | (1u << 6);
GPIOA->MODER = (GPIOA->MODER & ~((3u << 4) | (3u << 6)))
| (2u << 4) | (2u << 6);
USART2->BRR = (UART_PCLK_HZ + baud / 2u) / baud;
USART2->CR1 = USART_CR1_UE | USART_CR1_TE | USART_CR1_RE | USART_CR1_RXNEIE;
nvic_clear_pending(USART2_IRQn);
nvic_set_priority(USART2_IRQn, 3u); /* short deadline: one char time */
nvic_enable(USART2_IRQn);
}
void USART2_IRQHandler(void)
{
uint32_t sr = USART2->SR;
/* Receive side. Reading SR then DR clears RXNE, ORE, FE, NF and PE. */
if (sr & (USART_SR_RXNE | USART_SR_ORE | USART_SR_FE | USART_SR_NF | USART_SR_PE)) {
uint8_t b = (uint8_t)USART2->DR;
if (sr & USART_SR_ORE) {
s_stats.overruns++; /* an earlier byte was lost */
}
if (sr & (USART_SR_FE | USART_SR_PE)) {
s_stats.frame_errors++; /* this byte is corrupt: drop it */
} else if (sr & USART_SR_RXNE) {
if (!ring_put(&s_rx, b)) {
s_stats.rx_dropped++;
}
}
}
/* Transmit side: only when we asked for TXE interrupts. */
if ((USART2->CR1 & USART_CR1_TXEIE) && (sr & USART_SR_TXE)) {
uint8_t b;
if (ring_get(&s_tx, &b)) {
USART2->DR = b;
} else {
USART2->CR1 &= ~USART_CR1_TXEIE; /* nothing left: stop TXE IRQs */
}
}
}
bool uart_getc(uint8_t *out)
{
return ring_get(&s_rx, out);
}
void uart_putc(uint8_t b)
{
while (!ring_put(&s_tx, b)) {
/* TX ring full: the ISR is draining it. Never call with IRQs disabled. */
}
uint32_t key = irq_save(); /* CR1 is also modified by the ISR */
USART2->CR1 |= USART_CR1_TXEIE;
irq_restore(key);
}
void uart_write(const uint8_t *data, size_t len)
{
for (size_t i = 0; i < len; i++) {
uart_putc(data[i]);
}
}
void uart_puts(const char *s)
{
while (*s != '\0') {
uart_putc((uint8_t)*s++);
}
}
void uart_get_stats(uart_stats_t *out)
{
uint32_t key = irq_save();
out->overruns = s_stats.overruns;
out->frame_errors = s_stats.frame_errors;
out->rx_dropped = s_stats.rx_dropped;
irq_restore(key);
}
/* printf support (newlib) */
int _write(int file, char *ptr, int len);
int _write(int file, char *ptr, int len)
{
(void)file;
for (int i = 0; i < len; i++) {
if (ptr[i] == '\n') {
uart_putc('\r');
}
uart_putc((uint8_t)ptr[i]);
}
return len;
}

The parts that need explanation:

  • One handler for everything. USART2 has a single interrupt vector (IRQ 38). The handler reads SR once and services every condition it finds.
  • Errors must be cleared or the handler loops. With RXNEIE set, an overrun also raises the interrupt. If the handler only reacted to RXNE, an ORE without new data would keep the interrupt asserted and main would never run again. The STM32F4 clears ORE, FE, NF and PE by the sequence “read SR, then read DR”, which is exactly what the handler does whenever any of them is set.
  • Overrun versus dropped. ORE means the hardware lost a byte because the ISR did not run within one character time (another interrupt hogged the CPU, or a critical section was too long). rx_dropped means software lost it because the ring was full (main is not draining fast enough). Different causes, different fixes, so count them separately.
  • TX is started by enabling TXEIE. When DR is empty, TXE is permanently set, so enabling TXEIE immediately fires the interrupt, which pulls a byte from the ring. When the ring empties, the handler disables TXEIE; otherwise TXE would keep firing with nothing to send.
  • The critical section in uart_putc. Main and the ISR both read-modify-write CR1. Without protection, main could read CR1, be interrupted by an ISR that clears TXEIE, and then write back an old value that also undoes some other change. Here the outcome would happen to be harmless, but reasoning about “happens to be harmless” for every register is how subtle bugs ship; three instructions of masked interrupts is cheaper.
  • Priority 3. The receive deadline is one character time, 87 µs at 115200 baud, so UART receive gets a more urgent priority than timers and buttons, following Interrupts done right.

At 115200 baud the interrupt-per-byte approach costs about 11 520 interrupts per second, a few percent of a 168 MHz core. At 2 Mbaud it is 200 000 per second, and the CPU spends much of its time entering and leaving the handler. DMA moves the bytes with no CPU involvement, and the idle-line interrupt (IDLE in SR) tells you when a burst has ended. The configuration (USART2 TX on DMA1 Stream 6, RX on Stream 5, both channel 4) is covered in DMA.

A UART delivers a stream of bytes with no boundaries. As soon as you send anything more structured than human-readable text, you must answer: where does a message start and end, how does the receiver resynchronize after garbage or a reset in the middle of a message, and how does it know a message is intact?

Delimiter. Text protocols end each message with \n. Simple, human-readable, self-synchronizing: after garbage, the receiver discards up to the next newline. Binary data can contain the delimiter byte, so you need an escaping scheme.

Length prefix. Send [len][payload]. Efficient, but fragile: if one length byte is corrupted or a byte is lost, the receiver misreads every subsequent frame boundary and never recovers without a separate sync mechanism (a magic start byte plus timeouts).

SLIP (RFC 1055) escapes: frames end with 0xC0 (END); a payload 0xC0 is sent as 0xDB 0xDC and 0xDB (ESC) as 0xDB 0xDD. Self-synchronizing, but the worst case doubles the data size.

COBS (Consistent Overhead Byte Stuffing) removes every zero from the payload so 0x00 can be the unambiguous frame delimiter, with a worst-case overhead of one byte per 254. It works by replacing each zero with the distance to the next zero:

payload: 11 22 00 33
COBS: 03 11 22 02 33 then the delimiter 00
03 = "copy the next 2 bytes, then a zero" -> 11 22 00
02 = "copy the next 1 byte" (end of data) -> 33

Each code byte n means “the next n - 1 bytes are data, followed by a zero”, except at the end of the frame (no trailing zero) and for 0xFF, which means “254 data bytes, no zero follows” so runs longer than 254 bytes still work.

Integrity. Framing tells you where a message is; a CRC tells you whether it arrived intact. A 16-bit CRC detects all single and double bit errors and all burst errors up to 16 bits in frames of reasonable length. Parity and simple byte sums are much weaker. We use CRC-16/CCITT-FALSE (polynomial 0x1021, initial value 0xFFFF, no reflection), whose standard check value over the ASCII string 123456789 is 0x29B1.

Putting it together, a robust frame is: COBS(payload + CRC16(payload)) followed by 0x00. The receiver collects bytes until a zero, COBS-decodes, checks the CRC, and silently drops anything that fails. After garbage or a mid-frame reset, it resynchronizes at the next zero.

frame.h
#ifndef FRAME_H
#define FRAME_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
/* Worst-case COBS output size for n input bytes (without the 0x00 delimiter). */
#define COBS_MAX_ENCODED(n) ((n) + (n) / 254u + 1u)
size_t cobs_encode(const uint8_t *in, size_t len, uint8_t *out);
bool cobs_decode(const uint8_t *in, size_t len,
uint8_t *out, size_t out_cap, size_t *out_len);
uint16_t crc16_ccitt(const uint8_t *data, size_t len);
#endif
frame.c
#include "frame.h"
size_t cobs_encode(const uint8_t *in, size_t len, uint8_t *out)
{
size_t code_idx = 0; /* where the pending code byte will go */
size_t out_idx = 1; /* next free output position */
uint8_t code = 1; /* 1 + number of data bytes in this block */
for (size_t i = 0; i < len; i++) {
if (in[i] == 0u) {
out[code_idx] = code; /* close the block at this zero */
code_idx = out_idx++;
code = 1;
} else {
out[out_idx++] = in[i];
code++;
if (code == 0xFFu) { /* 254 data bytes: forced block end */
out[code_idx] = code;
code_idx = out_idx++;
code = 1;
}
}
}
out[code_idx] = code; /* close the final block */
return out_idx;
}
bool cobs_decode(const uint8_t *in, size_t len,
uint8_t *out, size_t out_cap, size_t *out_len)
{
size_t i = 0;
size_t o = 0;
while (i < len) {
uint8_t code = in[i++];
if (code == 0u) {
return false; /* zeros never appear inside a frame */
}
for (uint8_t k = 1; k < code; k++) {
if (i >= len || in[i] == 0u || o >= out_cap) {
return false; /* truncated, corrupt, or too long */
}
out[o++] = in[i++];
}
if (code != 0xFFu && i < len) { /* implicit zero between blocks */
if (o >= out_cap) {
return false;
}
out[o++] = 0u;
}
}
*out_len = o;
return true;
}
uint16_t crc16_ccitt(const uint8_t *data, size_t len)
{
uint16_t crc = 0xFFFFu;
for (size_t i = 0; i < len; i++) {
crc ^= (uint16_t)((uint16_t)data[i] << 8);
for (int bit = 0; bit < 8; bit++) {
if (crc & 0x8000u) {
crc = (uint16_t)((crc << 1) ^ 0x1021u);
} else {
crc = (uint16_t)(crc << 1);
}
}
}
return crc;
}

Notes on the code:

  • The encoder reserves a slot for each code byte before it knows the value (code_idx), then fills it in when the block ends at a zero, at 254 data bytes, or at the end of input.
  • The decoder treats every malformed input as an error: a zero inside the frame, a code byte that points past the end, or output that would exceed the caller’s buffer. A decoder is a parser of untrusted input; this is where buffer overflows come from.
  • *out_len is separate from the return value, so an empty payload (encoded as the single byte 01) is distinguishable from an error.
  • The CRC is the bitwise version: small and slow, about 8 iterations per byte. A 256-entry table is faster; both are discussed in Flash, EEPROM, and non-volatile storage.

Test it on your host before it ever runs on the board:

test_frame.c
#include <stdio.h>
#include <string.h>
#include "frame.h"
static void print_hex(const char *label, const uint8_t *p, size_t n)
{
printf("%s", label);
for (size_t i = 0; i < n; i++) {
printf(" %02X", p[i]);
}
printf("\n");
}
int main(void)
{
const uint8_t check[] = "123456789";
printf("crc16(\"123456789\") = 0x%04X\n", crc16_ccitt(check, 9));
const uint8_t payload[] = { 0x11, 0x22, 0x00, 0x33 };
uint8_t enc[COBS_MAX_ENCODED(sizeof payload)];
size_t enc_len = cobs_encode(payload, sizeof payload, enc);
print_hex("encoded:", enc, enc_len);
uint8_t dec[16];
size_t dec_len = 0;
bool ok = cobs_decode(enc, enc_len, dec, sizeof dec, &dec_len);
printf("decode ok=%d len=%zu match=%d\n", ok, dec_len,
ok && dec_len == sizeof payload && memcmp(dec, payload, dec_len) == 0);
enc[1] = 0x00; /* corrupt: a zero inside the frame */
ok = cobs_decode(enc, enc_len, dec, sizeof dec, &dec_len);
printf("corrupt frame rejected=%d\n", !ok);
return 0;
}
Terminal window
gcc -std=c11 -Wall -Wextra -Wpedantic frame.c test_frame.c -o test_frame && ./test_frame
crc16("123456789") = 0x29B1
encoded: 03 11 22 02 33
decode ok=1 len=4 match=1
corrupt frame rejected=1

Every serious firmware project grows a debug console: type status, get the system state; type led on, exercise the hardware. It is invaluable for bring-up, factory test and field diagnostics. Build it once as a reusable module that knows nothing about the commands it runs:

shell.h
#ifndef SHELL_H
#define SHELL_H
#include <stddef.h>
typedef struct {
const char *name;
const char *help;
void (*handler)(int argc, char *argv[]);
} shell_cmd_t;
typedef void (*shell_puts_fn)(const char *s);
void shell_init(const shell_cmd_t *cmds, size_t count, shell_puts_fn out);
void shell_input(char c); /* feed one received character */
#endif
shell.c
#include <stdbool.h>
#include <stddef.h>
#include <string.h>
#include "shell.h"
#define SHELL_LINE_MAX 64u
#define SHELL_ARGS_MAX 8
static const shell_cmd_t *s_cmds;
static size_t s_count;
static shell_puts_fn s_out;
static char s_line[SHELL_LINE_MAX];
static size_t s_len;
static void prompt(void)
{
s_out("> ");
}
void shell_init(const shell_cmd_t *cmds, size_t count, shell_puts_fn out)
{
s_cmds = cmds;
s_count = count;
s_out = out;
s_len = 0;
prompt();
}
/* Split s_line in place on spaces. Returns argc. */
static int tokenize(char *argv[])
{
int argc = 0;
bool in_word = false;
for (size_t i = 0; i < s_len; i++) {
if (s_line[i] == ' ') {
s_line[i] = '\0';
in_word = false;
} else if (!in_word && argc < SHELL_ARGS_MAX) {
argv[argc++] = &s_line[i];
in_word = true;
}
}
return argc;
}
static void execute(void)
{
char *argv[SHELL_ARGS_MAX];
s_line[s_len] = '\0';
int argc = tokenize(argv);
if (argc == 0) {
return;
}
if (strcmp(argv[0], "help") == 0) {
for (size_t i = 0; i < s_count; i++) {
s_out(s_cmds[i].name);
s_out(" ");
s_out(s_cmds[i].help);
s_out("\r\n");
}
return;
}
for (size_t i = 0; i < s_count; i++) {
if (strcmp(argv[0], s_cmds[i].name) == 0) {
s_cmds[i].handler(argc, argv);
return;
}
}
s_out("unknown command, try help\r\n");
}
void shell_input(char c)
{
if (c == '\r' || c == '\n') {
s_out("\r\n");
execute();
s_len = 0;
prompt();
} else if (c == '\b' || c == 0x7F) { /* backspace or DEL */
if (s_len > 0u) {
s_len--;
s_out("\b \b"); /* erase on the terminal */
}
} else if (c >= ' ' && c <= '~' && s_len < SHELL_LINE_MAX - 1u) {
s_line[s_len++] = c;
char echo[2] = { c, '\0' };
s_out(echo);
}
}

The module is deliberately small: a line editor with echo and backspace, an in-place tokenizer that turns led on into argv[0] = "led", argv[1] = "on", and a lookup in a caller-supplied command table. It never touches hardware; output goes through a function pointer. That means you can compile shell.c on your host and drive it from a test that feeds characters and captures output. Both CR and LF end a line, so terminals that send CR LF produce an extra empty line, which execute ignores. The length check s_len < SHELL_LINE_MAX - 1u keeps room for the terminating NUL; characters past the limit are silently dropped rather than written past the buffer.

The firmware below ties it together: interrupt-driven UART, printf through _write, and the shell controlling the LEDs. It uses clock.c (from Clocks, reset, and timing), irq.h and nvic.h (from Interrupts done right), and uart.c and shell.c from this lesson. Link with --specs=nano.specs --specs=nosys.specs so printf comes from newlib-nano.

main.c
#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include "stm32f407_regs.h"
#include "clock.h"
#include "uart.h"
#include "shell.h"
#define LED_PINS_MASK (0xFu << 12) /* PD12..PD15 */
static void leds_init(void)
{
RCC->AHB1ENR |= (1u << 3);
(void)RCC->AHB1ENR;
GPIOD->MODER = (GPIOD->MODER & ~(0xFFu << 24)) | (0x55u << 24);
}
static void cmd_led(int argc, char *argv[])
{
if (argc == 2 && strcmp(argv[1], "on") == 0) {
GPIOD->BSRR = LED_PINS_MASK;
uart_puts("leds on\r\n");
} else if (argc == 2 && strcmp(argv[1], "off") == 0) {
GPIOD->BSRR = LED_PINS_MASK << 16;
uart_puts("leds off\r\n");
} else {
uart_puts("usage: led on|off\r\n");
}
}
static void cmd_status(int argc, char *argv[])
{
(void)argc;
(void)argv;
uart_stats_t st;
uart_get_stats(&st);
printf("uptime %" PRIu32 " ms, leds 0x%" PRIX32 "\n",
millis(), (GPIOD->ODR >> 12) & 0xFu);
printf("rx overruns %" PRIu32 ", frame errors %" PRIu32 ", dropped %" PRIu32 "\n",
st.overruns, st.frame_errors, st.rx_dropped);
}
static const shell_cmd_t s_commands[] = {
{ "led", "led on|off: switch all LEDs", cmd_led },
{ "status", "show uptime and UART error counters", cmd_status },
};
int main(void)
{
(void)clock_init_168mhz(); /* PCLK1 = 42 MHz, BRR depends on it */
systick_init_1ms();
leds_init();
uart_init(115200u);
setvbuf(stdout, NULL, _IONBF, 0); /* printf output appears immediately */
printf("\nfirmware-path shell, type help\n");
shell_init(s_commands, sizeof s_commands / sizeof s_commands[0], uart_puts);
for (;;) {
uint8_t c;
while (uart_getc(&c)) {
shell_input((char)c);
}
__asm volatile ("wfi"); /* UART RX or SysTick wakes us */
}
}

A session in the terminal:

firmware-path shell, type help
> help
led led on|off: switch all LEDs
status show uptime and UART error counters
> led on
leds on
> status
uptime 15873 ms, leds 0xF
rx overruns 0, frame errors 0, dropped 0
> blink
unknown command, try help
>

Points to notice:

  • printf and uart_puts both feed the same TX ring, so their output never interleaves mid-character. Mixing them from main is safe; calling either from an interrupt handler is not.
  • PRIu32 and PRIX32 from inttypes.h give the right format for uint32_t, which is unsigned long on arm-none-eabi but unsigned int on most hosts.
  • The main loop drains the RX ring completely, then sleeps. Every received byte wakes the core through the USART interrupt.
  • Paste a long line into the terminal and watch: characters beyond 63 are dropped by the shell, but status should still report zero overruns, because the ring buffer absorbs the burst.

When a receiver cannot keep up, it needs a way to say “stop”.

Hardware flow control (RTS/CTS) uses two extra wires. The receiver drives RTS (request to send) inactive when its buffer is nearly full; the transmitter checks CTS (clear to send, the other side’s RTS) before sending each character. On the STM32F407, CR3 bit 8 RTSE and bit 9 CTSE make the USART do this in hardware; for USART2 the pins are PA1 (RTS) and PA0 (CTS), AF7. Use it for high baud rates and for modules like Bluetooth and cellular modems, which often require it.

Software flow control (XON/XOFF) sends the byte 0x13 (XOFF, Ctrl-S) to pause and 0x11 (XON, Ctrl-Q) to resume, in-band. No extra wires, but it only works for text: binary data can contain those bytes, and the pause takes effect only after the bytes already in flight arrive. It survives mainly in terminals and legacy equipment.

RS-485 transceivers have a driver-enable pin (DE, often tied to the inverted receiver-enable RE). On a half-duplex bus only one node may drive at a time, so firmware enables the driver, sends, and releases the bus. The trap is releasing too early:

static void rs485_send(const uint8_t *p, size_t n)
{
GPIOA->BSRR = 1u << 8; /* DE high: drive the bus (PA8 as an example) */
for (size_t i = 0; i < n; i++) {
while (!(USART2->SR & USART_SR_TXE)) {
}
USART2->DR = p[i];
}
while (!(USART2->SR & USART_SR_TC)) { /* last stop bit has left the pin */
}
GPIOA->BSRR = 1u << (8 + 16); /* DE low: release the bus */
}

Releasing DE when TXE is set cuts off the final byte, which is still in the shift register. Waiting for TC is the fix. In an interrupt-driven driver, enable TCIE after the last byte is written and release DE in the TC interrupt. (Newer STM32 families have a hardware DE output that does this automatically; the F407’s USART does not.)

UART logging is the default debugging tool, and there are two rules.

Never printf from an interrupt handler. At 115200 baud a 40-character message takes 3.5 ms to transmit. If the handler waits for the UART, every interrupt at its priority and below is blocked for that long. If it writes into the same TX ring that main is in the middle of writing, the ring’s single-producer rule is broken and output is corrupted. And printf itself is not reentrant. Instead, have the handler record an event code and a value into a small log queue, and let main format and print it.

Logging changes timing. A bug that disappears when you add a print statement is usually a race, and the print just moved the timing. Keep logs short, buffer them, and consider binary logging (send event IDs and raw values, format on the host), covered in Debugging and tracing on real hardware.

On the host side, any terminal program works:

Terminal window
picocom -b 115200 /dev/ttyUSB0 # Linux; exit with Ctrl-A Ctrl-X
minicom -D /dev/ttyUSB0 -b 115200 # Linux/macOS alternative
screen /dev/tty.usbserial-0001 115200 # macOS, built in

On Windows, PuTTY with connection type “Serial”, the COM port number from Device Manager, and speed 115200. Set the terminal to send CR or CR LF on Enter; the shell accepts either. If you see nothing at all, check TX/RX crossover and ground. If you see garbage, check the baud rate and, above all, the clock configuration.

Without hardware, Renode models the Discovery board’s USARTs: load your ELF, then open a terminal window on the UART with showAnalyzer sysbus.usart2 in the monitor (the peripheral name comes from the platform description). Typing in the analyzer window sends characters to the firmware’s RX. QEMU’s netduinoplus2 machine connects its first serial port to the host with -serial stdio; check which USART instance the board model wires to it and adjust the driver to that instance.

For the protocol layer, do as test_frame.c does: test encoders, decoders and the shell on the host with known vectors, corrupted inputs and boundary sizes (0 bytes, 254, 255, buffer exactly full) before involving hardware.

  • TX wired to TX, or no common ground. Fix: cross TX/RX, connect GND.
  • Computing BRR from the wrong clock. Fix: use PCLK1 (42 MHz in our setup), and make sure the clock init ran.
  • Handling only RXNE in the ISR, so an overrun interrupt loops forever. Fix: on any of RXNE, ORE, FE, NF, PE, read SR then DR.
  • Leaving TXEIE enabled with nothing to send. Fix: disable it in the handler when the TX ring is empty.
  • Releasing an RS-485 driver or entering sleep when TXE is set. Fix: wait for TC.
  • Calling printf or a blocking uart_putc from an interrupt handler. Fix: queue events, print from main.
  • Watching USART2->DR in the debugger, which clears RXNE and steals bytes. Fix: watch the ring buffer instead.
  • Length-prefixed binary frames with no resync mechanism. Fix: COBS or SLIP framing plus a CRC.
  1. Compute BRR for 57600 baud from the 42 MHz PCLK1 and the actual baud rate and error. Then compute it for 115200 baud if someone forgot clock_init_168mhz() and PCLK1 is 16 MHz with the same BRR = 0x16D: what baud rate is actually produced? (Expected: BRR = 729, 57613 baud, +0.02%; and about 43836 baud.)
  2. Extend test_frame.c with a 300-byte payload of all non-zero bytes and a payload of 10 zeros. Check that the encoded lengths match COBS_MAX_ENCODED limits and that both round-trip.
  3. Add a frame_rx state machine on the target: collect bytes from uart_getc until a zero, COBS-decode, verify the trailing CRC-16, and toggle the orange LED for each valid frame. Write a short Python script with pyserial on the host that sends valid and corrupted frames, and verify only the valid ones toggle the LED.
  4. Compile uart.c with -O2 and disassemble ring_put with arm-none-eabi-objdump -d. Confirm the store to the buffer comes before the store to head. Then remove volatile from the buffer (keep it on the indices) and check whether the compiler is still obliged to keep that order.
  5. Deliberately create overruns: add a 1 ms busy loop inside SysTick_Handler at a higher priority than USART2, paste a long text into the terminal, and watch the status counters. Then fix it by swapping the priorities and explain why the counters stop increasing.

Question 1:What value goes in USART2 BRR for 115200 baud with PCLK1 = 42 MHz and 16x oversampling?

Question 2:An RS-485 driver releases DE as soon as TXE is set after writing the last byte. What happens?

Question 3:A USART ISR handles only RXNE. After a burst of input, main stops running entirely. Most likely cause?

Question 4:In the SPSC ring buffer, why is it safe for the ISR and main to use it without disabling interrupts?

Question 5:Why is COBS framing more robust than a plain length prefix?

  • 8N1 means 10 bit times per byte; both ends must agree on baud rate within a few percent, which a wrong clock configuration easily breaks.
  • BRR = PCLK / baud rounded with 16x oversampling; on USART2 that is PCLK1, 42 MHz at 168 MHz SYSCLK.
  • TXE means you may write the next byte; TC means the line is truly idle. Reading DR clears RXNE, and SR then DR clears the error flags.
  • Interrupt-driven RX and TX through SPSC ring buffers (one writer per index, power-of-two size, publish after writing) is the standard UART driver.
  • Frame binary data with a self-synchronizing scheme such as COBS and protect it with a CRC; treat the decoder as a parser of hostile input.
  • Never print from an interrupt handler. Queue events and format them in main.
  • RS-232 levels destroy MCU pins; use a transceiver. For RS-485, release the driver only after TC.

Next: SPI and I2C introduces the two synchronous buses you will use to talk to sensors, displays and memory chips on the same board.