HomeAbout UsContact Us

Software UART using GPIO and Timer in Embedded C

By Jithin Tom
Published in Embedded C/C++
September 24, 2026
4 min read
Software UART using GPIO and Timer in Embedded C

Table Of Contents

01
Software UART Timing
02
Introduction
03
Core Concepts
04
Implementation Strategy
05
Timing Calculations
06
Transmit Implementation
07
Receive Implementation
08
CPU Load Considerations
09
Practical Example: STM32 Implementation
10
Performance Optimization
11
Common Pitfalls and Solutions
12
When to Use Software UART
13
Conclusion
14
Related Reading
15
References
16
Frequently Asked Questions

Software UART Timing

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

Introduction

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:

  • Prototyping with low-pin-count MCUs
  • Adding debug ports to existing designs
  • Implementing software-defined peripherals
  • Learning fundamental UART concepts

Core Concepts

Software UART relies on precise timing to serialize and deserialize data bits. The fundamental operations include:

  1. Transmission: Converting parallel data to serial bit stream
  2. Reconstruction: Converting serial bit stream back to parallel data
  3. Timing Control: Ensuring each bit lasts for the correct duration
  4. Frame Management: Handling start, data, parity, and stop bits

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.

Implementation Strategy

Hardware Requirements

  • One GPIO pin for TX (output)
  • One GPIO pin for RX (input)
  • One timer peripheral capable of generating periodic interrupts
  • Sufficient CPU cycles to handle ISR overhead

Software Components

  1. Timer ISR: Fires at regular intervals (baud rate × oversampling)
  2. State Machines: Separate TX and RX state machines
  3. Buffers: Storage for incoming/outgoing data
  4. Bit Counters: Track bit position within each frame
  5. Shift Registers: Convert between parallel and serial formats

Timing Calculations

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:

  • Bit Time = 8.68µs
  • Timer Period = 0.5425µs (1.844 MHz timer frequency)

Transmit Implementation

The transmit process follows this sequence in each timer ISR:

1. If in start bit state: drive GPIO low, decrement bit counter
2. If in data bit state: output LSB of shift register, shift right, decrement counter
3. If in parity state: output calculated parity bit, decrement counter
4. If in stop bit state: drive GPIO high, decrement counter
5. If all bits transmitted: return to idle state

Key optimization techniques:

  • Pre-calculate bit masks to avoid division in ISR
  • Use lookup tables for parity calculation
  • Maintain separate shift registers for TX and RX
  • Implement double-buffering to prevent data corruption

Receive Implementation

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 center
3. Sample GPIO pin every N timer ticks (where N = oversampling factor)
4. Use majority voting over multiple samples for noise immunity
5. Assemble received bits into bytes
6. Validate stop bit(s) and transfer data to receive buffer

The receive state machine needs to handle:

  • Start bit detection (transition from high to low)
  • Data bit sampling with noise filtering
  • Parity verification (if enabled)
  • Stop bit validation
  • Framing error detection

CPU Load Considerations

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:

  • 115200 × 16 = 1,843,200 interrupts per second

This represents significant CPU load, especially on lower-frequency MCUs. Mitigation strategies include:

  • Using lower baud rates when high speed isn’t required
  • Implementing half-duplex mode to share the same ISR
  • Using DMA with timer to reduce CPU intervention
  • Offloading to a secondary core or coprocessor if available

Practical Example: STM32 Implementation

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:

  • Correct GPIO Configuration: TX (PA2) is configured as a high-speed General Purpose Output (Push-Pull), and RX (PA3) as an Input with internal pull-up. Crucially, they are not configured as Alternate Function pins, ensuring software bit-banging directly controls the pin state.
  • Atomic Bit Manipulation: The transmit state machine writes directly to the BSRR (Bit Set/Reset Register) rather than the ODR (Output Data Register), preventing race conditions and eliminating read-modify-write jitter.
  • Midpoint Sampling & Glitch Filtering: The receive state machine detects the falling edge of the start bit, waits 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.
  • Lock-Free Ring Buffers: Decouples high-frequency ISR servicing from background task processing.
#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 communication
typedef 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 interrupts
void UART_Init(void) {
// 1. Enable peripheral clocks for GPIOA and TIM2
RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;
RCC->APB1ENR |= RCC_APB1ENR_TIM2EN;
// 2. Configure PA2 (TX) as General Purpose Output Push-Pull, High Speed
GPIOA->MODER &= ~(3U << (2 * 2));
GPIOA->MODER |= (1U << (2 * 2)); // Mode: 01 = Output
GPIOA->OTYPER &= ~(1U << 2); // Push-Pull
GPIOA->OSPEEDR |= (3U << (2 * 2)); // Very High Speed
UART_TX_HIGH(); // Default to IDLE (Mark = High)
// 3. Configure PA3 (RX) as Input with Pull-Up
GPIOA->MODER &= ~(3U << (3 * 2)); // Mode: 00 = Input
GPIOA->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 Hz
uint32_t arr_ticks = (timer_clock_hz / target_freq_hz) - 1;
TIM2->PSC = 0; // Run counter at full timer clock
TIM2->ARR = arr_ticks; // Auto-reload value
TIM2->EGR = TIM_EGR_UG; // Update generation to apply settings
TIM2->SR = 0; // Clear update flag
TIM2->DIER |= TIM_DIER_UIE; // Enable Update Interrupt
// 5. Configure NVIC Priority and enable TIM2 IRQ
NVIC_SetPriority(TIM2_IRQn, 0); // Highest priority to minimize timing jitter
NVIC_EnableIRQ(TIM2_IRQn);
// 6. Start TIM2 counter
TIM2->CR1 |= TIM_CR1_CEN;
}
// Transmit State Machine - executed inside timer tick
static 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 Rejection
static 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 bit
rx_shift_reg = 0;
rx_data_bit_count = 0;
rx_state = UART_RX_DATA;
} else {
// Glitch / noise spike rejected -> return to IDLE
rx_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 midpoint
if (++rx_bit_counter >= OVERSAMPLING) {
rx_bit_counter = 0;
if (UART_RX_READ()) {
// Valid Stop Bit (High): Store received character
rx_buffer_push(rx_shift_reg);
} else {
// Stop bit was Low: Frame error detected
rx_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 bit
UartTxIsr(); // Service transmit state machine
UartRxIsr(); // Service receive state machine
}
}

Performance Optimization

Several techniques can improve software UART performance and reliability:

1. Interrupt Prioritization

  • Assign highest priority to UART timer ISR
  • Prevents jitter from lower-priority interrupts
  • Critical for maintaining accurate bit timing

2. Efficient ISR Design

  • Minimize function calls in ISR
  • Use inline assembly for critical timing sections
  • Pre-compute values outside ISR when possible

3. Double Buffering

  • Separate ISR buffers from application buffers
  • Prevents data corruption during buffer swaps
  • Allows ISR to run uninterrupted

4. GPIO Optimization

  • Use ports with bit-banding or atomic set/clear registers
  • Avoid read-modify-write operations on GPIO
  • Configure pins for maximum slew rate

5. Adaptive Oversampling

  • Reduce oversampling factor at lower baud rates
  • Increase factor only when noise is detected
  • Dynamically adjust based on line quality

Common Pitfalls and Solutions

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

When to Use Software UART

Choose software UART when:

  • Hardware UART peripherals are exhausted
  • Cost reduction is prioritized over power efficiency
  • Flexibility in pin assignment is required
  • Prototyping or educational purposes
  • Baud rates are moderate (up to ~460.8 kbaud)

Stick with hardware UART when:

  • Maximum throughput is required (> 460.8 kbaud)
  • Power efficiency is critical
  • Multiple high-speed serial ports needed
  • Complex features like DMA, LIN, or smartcard support required

Conclusion

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:

  1. Calculate precise timing based on baud rate and oversampling requirements
  2. Implement robust state machines with error detection
  3. Optimize ISR execution for minimal jitter
  4. Validate signal integrity with oscilloscope during development
  5. Consider hardware alternatives for high-throughput applications

With careful design and testing, software UART can be a dependable tool in your embedded development toolkit, especially when hardware resources are constrained.

References

  1. ARM, “Cortex-M Devices Generic User Guides”, ARM DUI 0553 (M4), Section “Nested Vectored Interrupt Controller (NVIC)”
  2. STMicroelectronics, “STM32F4xx Reference Manual”, RM0090, Section “General-purpose timers (TIM2/TIM3/TIM4/TIM5)” and Section “GPIO (BSRR, MODER, OSPEEDR)”
  3. Joseph Yiu, “The Definitive Guide to ARM Cortex-M3 and Cortex-M4 Processors”, 3rd Ed., Chapter 7 “Exceptions and Interrupts” and Chapter 9 “Timers”
  4. ITU-T V.24 / EIA/TIA-232-F, “Interchange Circuits Between Data Terminal Equipment and Data Circuit-Terminating Equipment”
  5. Maxim Integrated, “Understanding UART Baud Rate Error” Application Note
  6. Texas Instruments, “GPIO Speed and Delay Considerations” Application Report

Frequently Asked Questions

Why would I use software UART instead of hardware UART?

Software UART is useful when you run out of hardware UART peripherals, need additional serial ports beyond what the MCU provides, or want to save cost by using a cheaper MCU without multiple UARTs. It's also valuable for learning purposes and prototyping.

What timer resolution do I need for reliable software UART at common baud rates?

For reliable software UART, your timer should interrupt at least 8x the baud rate to sample each bit multiple times. For 115200 baud, you need ~921.6 kHz timer interrupts (1.085µs period). Most applications use 16x oversampling for better noise immunity.

How do I handle CPU load from software UART interrupts?

Software UART increases CPU load proportional to baud rate. At 115200 baud with 16x oversampling, you get ~1.84 million interrupts per second. Use lower baud rates when possible, implement efficient ISRs, or consider using DMA with timer for less CPU-intensive solutions.

Can software UART achieve the same reliability as hardware UART?

With proper timing calculations, interrupt prioritization, and efficient ISR implementation, software UART can achieve reliability comparable to hardware UART for baud rates up to 115200. Beyond that, jitter and interrupt latency become limiting factors.

What GPIO considerations affect software UART performance?

Use GPIO pins with fast toggle speeds and minimal output delay. Avoid pins shared with other high-frequency peripherals. Ensure your GPIO port allows bit-banding or atomic bit-set/clear operations to prevent read-modify-write issues in ISRs.

Tags

embedded-cuartgpiotimersoftware-uartstm32

Share


Previous Article
Fixing TFLite Micro Model Loading Failures on STM32H7
Jithin Tom

Jithin Tom

A Closer Look at C/C++, RTOS, and Embedded Systems

Related Posts

Fixing UART Overrun Errors on NXP i.MX RT with FlexIO
Fixing UART Overrun Errors on NXP i.MX RT with FlexIO
October 13, 2026
5 min
© 2026, All Rights Reserved.
Powered By Netlyft

Quick Links

Advertise with usAbout UsContact Us

Social Media