
CPU Core → [Timer ISR] → GPIO Pin↓ ↑ ↓[Bit Counter] ← [Shift Register] → [Serial Out]↓ ↑ ↓[State Machine] ← [Bit Timer] → [Start/Stop Bits]↓ ↑ ↓[Receive Buffer] ← [Sample Points] → [Transmit Buffer]Timer Interval = 1/(Baud Rate × Oversampling Factor)Each ISR: Sample input, shift output, update counters
When your embedded application needs more serial ports than the available hardware UARTs, or you’re working with a microcontroller that lacks sufficient UART peripherals, software UART (also known as bit-banging UART) provides a flexible solution. By using GPIO pins and timer interrupts, you can implement serial communication entirely in software.
This approach is particularly useful in scenarios like:
Software UART relies on precise timing to serialize and deserialize data bits. The fundamental operations include:
The key insight is that UART communication is synchronous at the bit level but asynchronous between devices - each device uses its own clock to sample/transmit bits at the agreed-upon baud rate.
The foundation of reliable software UART is accurate timing. Critical parameters include:
Baud Rate: Bits per second (e.g., 9600, 115200) Bit Time: 1 / Baud Rate (e.g., 104.167µs at 9600 baud) Oversampling Factor: Typically 8x, 16x, or 32x for noise immunity Timer Period: Bit Time / Oversampling Factor
For example, at 115200 baud with 16x oversampling:
The transmit process follows this sequence in each timer ISR:
1. If in start bit state: drive GPIO low, decrement bit counter2. If in data bit state: output LSB of shift register, shift right, decrement counter3. If in parity state: output calculated parity bit, decrement counter4. If in stop bit state: drive GPIO high, decrement counter5. If all bits transmitted: return to idle state
Key optimization techniques:
Receiving requires sampling the GPIO pin at the optimal point within each bit period:
1. Detect falling edge (start bit)2. Wait half a bit time to reach bit center3. Sample GPIO pin every N timer ticks (where N = oversampling factor)4. Use majority voting over multiple samples for noise immunity5. Assemble received bits into bytes6. Validate stop bit(s) and transfer data to receive buffer
The receive state machine needs to handle:
Software UART consumes CPU resources proportional to the baud rate and oversampling factor. The interrupt rate is:
Interrupts per second = Baud Rate × Oversampling Factor
For example, full-duplex 115200 baud with 16x oversampling requires:
This represents significant CPU load, especially on lower-frequency MCUs. Mitigation strategies include:
The following complete, production-grade example demonstrates a full-duplex software UART on an STM32F4 (ARM Cortex-M4) microcontroller.
Key architectural highlights of this implementation include:
BSRR (Bit Set/Reset Register) rather than the ODR (Output Data Register), preventing race conditions and eliminating read-modify-write jitter.OVERSAMPLING / 2 ticks to sample in the center of the bit, verifies line stability to reject noise spikes, and subsequently samples all 8 data bits and the stop bit at their centers.#include "stm32f4xx.h"#include <stdbool.h>#include <stdint.h>#define BAUD_RATE 115200#define OVERSAMPLING 16#define UART_BUF_SIZE 64// Atomic GPIO pin macros (PA2 = TX, PA3 = RX)#define UART_TX_HIGH() (GPIOA->BSRR = (1U << 2))#define UART_TX_LOW() (GPIOA->BSRR = (1U << (2 + 16)))#define UART_RX_READ() ((GPIOA->IDR & (1U << 3)) != 0)typedef enum {UART_TX_IDLE = 0,UART_TX_START,UART_TX_DATA,UART_TX_STOP} UartTxState_t;typedef enum {UART_RX_IDLE = 0,UART_RX_START,UART_RX_DATA,UART_RX_STOP} UartRxState_t;// Circular buffer structure for ISR communicationtypedef struct {uint8_t data[UART_BUF_SIZE];volatile uint16_t head;volatile uint16_t tail;} RingBuffer_t;static RingBuffer_t tx_buf = { .head = 0, .tail = 0 };static RingBuffer_t rx_buf = { .head = 0, .tail = 0 };static volatile UartTxState_t tx_state = UART_TX_IDLE;static volatile UartRxState_t rx_state = UART_RX_IDLE;static volatile uint32_t rx_framing_errors = 0;static inline bool tx_buffer_has_data(void) {return (tx_buf.head != tx_buf.tail);}static inline uint8_t tx_buffer_pop(void) {uint8_t byte = tx_buf.data[tx_buf.tail];tx_buf.tail = (tx_buf.tail + 1) % UART_BUF_SIZE;return byte;}static inline void rx_buffer_push(uint8_t byte) {uint16_t next_head = (rx_buf.head + 1) % UART_BUF_SIZE;if (next_head != rx_buf.tail) {rx_buf.data[rx_buf.head] = byte;rx_buf.head = next_head;}}// Initialize GPIO pins, TIM2 counter, and NVIC interruptsvoid UART_Init(void) {// 1. Enable peripheral clocks for GPIOA and TIM2RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;RCC->APB1ENR |= RCC_APB1ENR_TIM2EN;// 2. Configure PA2 (TX) as General Purpose Output Push-Pull, High SpeedGPIOA->MODER &= ~(3U << (2 * 2));GPIOA->MODER |= (1U << (2 * 2)); // Mode: 01 = OutputGPIOA->OTYPER &= ~(1U << 2); // Push-PullGPIOA->OSPEEDR |= (3U << (2 * 2)); // Very High SpeedUART_TX_HIGH(); // Default to IDLE (Mark = High)// 3. Configure PA3 (RX) as Input with Pull-UpGPIOA->MODER &= ~(3U << (3 * 2)); // Mode: 00 = InputGPIOA->PUPDR &= ~(3U << (3 * 2));GPIOA->PUPDR |= (1U << (3 * 2)); // Pull-Up (prevents floating line)// 4. Configure TIM2 for (BAUD_RATE * OVERSAMPLING) interrupt rate// Note: APB1 timer clock on STM32F4 is 2x APB1 peripheral clock (84 MHz at 168 MHz SYSCLK)uint32_t timer_clock_hz = SystemCoreClock / 2;uint32_t target_freq_hz = BAUD_RATE * OVERSAMPLING; // 1,843,200 Hzuint32_t arr_ticks = (timer_clock_hz / target_freq_hz) - 1;TIM2->PSC = 0; // Run counter at full timer clockTIM2->ARR = arr_ticks; // Auto-reload valueTIM2->EGR = TIM_EGR_UG; // Update generation to apply settingsTIM2->SR = 0; // Clear update flagTIM2->DIER |= TIM_DIER_UIE; // Enable Update Interrupt// 5. Configure NVIC Priority and enable TIM2 IRQNVIC_SetPriority(TIM2_IRQn, 0); // Highest priority to minimize timing jitterNVIC_EnableIRQ(TIM2_IRQn);// 6. Start TIM2 counterTIM2->CR1 |= TIM_CR1_CEN;}// Transmit State Machine - executed inside timer tickstatic inline void UartTxIsr(void) {static uint8_t tx_bit_counter = 0;static uint8_t tx_shift_reg = 0;static uint8_t tx_data_bit_count = 0;switch (tx_state) {case UART_TX_IDLE:if (tx_buffer_has_data()) {tx_shift_reg = tx_buffer_pop();UART_TX_LOW(); // Drive Start Bit (Low)tx_bit_counter = 0;tx_data_bit_count = 0;tx_state = UART_TX_START;}break;case UART_TX_START:if (++tx_bit_counter >= OVERSAMPLING) {tx_bit_counter = 0;// Drive first data bit (LSB)if (tx_shift_reg & 0x01) {UART_TX_HIGH();} else {UART_TX_LOW();}tx_shift_reg >>= 1;tx_data_bit_count = 1;tx_state = UART_TX_DATA;}break;case UART_TX_DATA:if (++tx_bit_counter >= OVERSAMPLING) {tx_bit_counter = 0;if (tx_data_bit_count < 8) {if (tx_shift_reg & 0x01) {UART_TX_HIGH();} else {UART_TX_LOW();}tx_shift_reg >>= 1;tx_data_bit_count++;} else {// All 8 data bits sent: Drive Stop Bit (High)UART_TX_HIGH();tx_state = UART_TX_STOP;}}break;case UART_TX_STOP:if (++tx_bit_counter >= OVERSAMPLING) {tx_bit_counter = 0;tx_state = UART_TX_IDLE; // Frame complete}break;}}// Receive State Machine with Midpoint Sampling & Glitch Rejectionstatic inline void UartRxIsr(void) {static uint8_t rx_bit_counter = 0;static uint8_t rx_shift_reg = 0;static uint8_t rx_data_bit_count = 0;switch (rx_state) {case UART_RX_IDLE:if (!UART_RX_READ()) {// Falling edge detected (Start Bit candidate)rx_bit_counter = 0;rx_state = UART_RX_START;}break;case UART_RX_START:// Sample at the midpoint of the start bit (OVERSAMPLING / 2)if (++rx_bit_counter >= (OVERSAMPLING / 2)) {rx_bit_counter = 0;if (!UART_RX_READ()) {// Line is still Low -> confirmed valid start bitrx_shift_reg = 0;rx_data_bit_count = 0;rx_state = UART_RX_DATA;} else {// Glitch / noise spike rejected -> return to IDLErx_state = UART_RX_IDLE;}}break;case UART_RX_DATA:// Sample each data bit at its center point (every 16 ticks)if (++rx_bit_counter >= OVERSAMPLING) {rx_bit_counter = 0;if (UART_RX_READ()) {rx_shift_reg |= (1U << rx_data_bit_count);}if (++rx_data_bit_count >= 8) {rx_state = UART_RX_STOP;}}break;case UART_RX_STOP:// Verify Stop bit at midpointif (++rx_bit_counter >= OVERSAMPLING) {rx_bit_counter = 0;if (UART_RX_READ()) {// Valid Stop Bit (High): Store received characterrx_buffer_push(rx_shift_reg);} else {// Stop bit was Low: Frame error detectedrx_framing_errors++;}rx_state = UART_RX_IDLE;}break;}}// Timer ISR - called every 0.5425µs (1.844 MHz)void TIM2_IRQHandler(void) {if (TIM2->SR & TIM_SR_UIF) {TIM2->SR = ~TIM_SR_UIF; // Clear interrupt pending bitUartTxIsr(); // Service transmit state machineUartRxIsr(); // Service receive state machine}}
Several techniques can improve software UART performance and reliability:
1. Interrupt Prioritization
2. Efficient ISR Design
3. Double Buffering
4. GPIO Optimization
5. Adaptive Oversampling
Pitfall 1: Timer Jitter Problem: Variable ISR latency causes bit timing errors Solution: Use highest interrupt priority, minimize ISR work, consider using DMA trigger
Pitfall 2: GPIO Delay Variations Problem: Different GPIO pins have different output delays Solution: Calibrate per-pin delays, use same port for TX/RX when possible
Pitfall 3: Noise on RX Line Problem: False start bit detection or bit errors Solution: Implement debouncing, use majority voting, add hardware RC filter
Pitfall 4: Buffer Overrun Problem: RX data overwritten before application reads it Solution: Implement buffer depth checking, use flow control (RTS/CTS), increase buffer size
Pitfall 5: Priority Inversion Problem: Low-priority task holds resource needed by UART ISR Solution: Use priority inheritance protocols, keep ISR non-blocking
Choose software UART when:
Stick with hardware UART when:
Software UART using GPIO and timer interrupts provides a versatile solution for adding serial communication capabilities to embedded systems. While it consumes more CPU resources than hardware UART, proper implementation can achieve reliable communication at practical baud rates.
Key takeaways for successful implementation:
With careful design and testing, software UART can be a dependable tool in your embedded development toolkit, especially when hardware resources are constrained.
Quick Links
Legal Stuff





