HomeAbout UsContact Us

Software UART for Embedded Debugging: GPIO-Based Serial

By Jithin Tom
Published in Embedded C/C++
September 11, 2026
4 min read
Software UART for Embedded Debugging: GPIO-Based Serial

Table Of Contents

01
Problem Statement
02
Root Cause Analysis
03
Solution Approach: Bit-Banged UART Fundamentals
04
Trade-Off Analysis
05
Complete Implementation
06
Verification and Testing
07
Summary
08
Frequently Asked Questions

Problem Statement

Many embedded systems face a common limitation: insufficient hardware UART ports for debugging, logging, and external communication. Microcontrollers often provide only one or two hardware UARTs, which may already be allocated to critical functions like Bluetooth modules, GPS receivers, or inter-chip communication. When additional serial channels are needed for console debugging or sensor data, engineers must either upgrade hardware (increasing cost and board complexity) or implement a software alternative.

This article addresses the software UART solution—a bit-banged UART implemented entirely in C using GPIO pins and timer interrupts. We’ll explore the design trade-offs, provide a complete STM32-compatible implementation, and verify functionality with practical testing steps. The focus is on creating a reliable, configurable software UART that integrates smoothly with real-time systems.

Root Cause Analysis

The root cause is hardware resource scarcity. Modern embedded applications demand multiple serial interfaces: one for debug console, another for wireless communication, and possibly more for sensor arrays or peripheral chips. When hardware UARTs are exhausted, two paths emerge:

  1. Hardware upgrade: Select a microcontroller with more UARTs, increasing Bill of Materials (BOM) cost and potentially requiring board redesign.
  2. Software implementation: Use general-purpose I/O pins to emulate UART functionality in firmware.

The software approach avoids hardware changes but introduces timing constraints and CPU overhead. Understanding these trade-offs is essential for determining when a software UART is the appropriate solution.

Solution Approach: Bit-Banged UART Fundamentals

A UART transmits data asynchronously using start bits, data bits (typically 8), optional parity, and stop bits. The receiver samples the incoming signal at the midpoint of each bit period to determine logic levels. For transmission, the GPIO pin is toggled according to the bit pattern at precise intervals.

UART Frame Structure & 3x Oversampling

In standard 8-N-1 format, the idle line sits at logic HIGH. A transmission begins with a LOW start bit, followed by 8 data bits (LSB first), and concludes with a HIGH stop bit. To sample incoming data reliably without hardware clock synchronization, the receiver uses an oversampled timer (3x the baud rate):

UART Frame Format (8-N-1) with 3x Oversampling:
Line Level:
Idle | Start | Bit 0 | Bit 1 | Bit 2 | ... | Bit 7 | Stop | Idle
3.3V ------+ +-------+ +-------+ ... +-------+-------+------
| | | | | | |
0V +-------+ +-------+ + ... + +
Ticks: 0 1 2 3 4 5 6 7 8 ... ... 27 28 29
Sample: * * * *
Verify Sample Sample Verify
Start Bit 0 Bit 1 Stop

Timer Frequency and Baud Rate Calculations

For an STM32 timer running at $f_{\text{timer}} = 72\text{ MHz}$, targeting $115200\text{ Baud}$ with $3\times$ oversampling:

  • Target interrupt frequency: $f_{\text{IT}} = 115200 \times 3 = 345600\text{ Hz}$.
  • Timer prescaler: $\text{PSC} = 0$ (clock divider of 1).
  • Auto-reload register value ($\text{ARR}$): $$\text{ARR} = \left\lfloor \frac{72000000}{1 \times 345600} \right\rfloor - 1 = 208 - 1 = 207$$
  • Actual baud rate achieved: $$\text{Baud}_{\text{actual}} = \frac{72000000}{208 \times 3} \approx 115384.6\text{ Baud}$$
  • Baud rate error: $$\text{Error} = \frac{115384.6 - 115200}{115200} \times 100\% \approx +0.16\%$$

A $0.16\%$ timing error is well within the $\pm 2\%$ margin required for reliable asynchronous serial reception.

Key implementation requirements:

  • Precise timing: Bit periods must remain accurate within ~2% across temperature and clock drift.
  • Interrupt-driven operation: Use timer interrupts to handle bit timing, freeing the main loop for application tasks.
  • Double buffering: Separate transmit and receive circular buffers to decouple interrupt processing from application throughput.
  • Error detection: Frame error detection for invalid stop bits and overflow detection for buffer overruns.

Trade-Off Analysis

AspectHardware UARTSoftware UART
CPU OverheadMinimal (handled by peripheral)Significant (bit-banging and ISR processing)
Pin FlexibilityFixed to specific pinsAny GPIO pins available
Baud Rate AccuracyHigh (crystal-derived)Depends on timer precision and interrupt latency
Concurrent OperationsFully dual-buffered, DMA-capableLimited by interrupt frequency and processing time
Implementation ComplexityLow (peripheral configuration)Moderate to high (timing-critical code)

For debug consoles at 115200 baud, a software UART typically consumes 5-15% CPU time on a 72 MHz Cortex-M3, which is acceptable for many applications. However, for high-speed data logging or multi-channel communication, hardware UARTs remain preferable.

Complete Implementation

Below is a minimal but functional software UART driver for STM32 microcontrollers, adaptable to other architectures. The implementation uses a single timer for both transmit and receive, with separate state machines.

#include "stm32f1xx_hal.h" // Adjust for your MCU
// Configuration - modify for your pins and timer
#define SW_UART_TX_PIN GPIO_PIN_2
#define SW_UART_TX_PORT GPIOA
#define SW_UART_RX_PIN GPIO_PIN_3
#define SW_UART_RX_PORT GPIOA
#define SW_UART_TIMER TIM2
#define SW_UART_TIMER_IRQ TIM2_IRQn
#define SW_UART_BAUD 115200
#define SW_UART_TIMER_CLK 72000000 // Timer clock frequency in Hz
#define SW_UART_OVERSAMPLE 3 // 3x oversampling for reliable RX
// State definitions
typedef enum {
SW_UART_STATE_IDLE,
SW_UART_STATE_START,
SW_UART_STATE_DATA,
SW_UART_STATE_STOP
} sw_uart_state_t;
// Driver state
typedef struct {
sw_uart_state_t rx_state;
sw_uart_state_t tx_state;
uint8_t rx_bit_count;
uint8_t tx_bit_count;
uint8_t rx_tick_count;
uint8_t tx_tick_count;
uint8_t rx_shift_reg;
uint8_t tx_shift_reg;
volatile uint8_t rx_buffer[64];
volatile uint8_t tx_buffer[64];
volatile uint16_t rx_head;
volatile uint16_t rx_tail;
volatile uint16_t tx_head;
volatile uint16_t tx_tail;
volatile uint8_t overflow_flag;
} sw_uart_t;
static sw_uart_t uart;
static TIM_HandleTypeDef htim_uart;
// Initialize GPIO and timer
void sw_uart_init(void) {
// Enable GPIO and timer clocks
__HAL_RCC_GPIOA_CLK_ENABLE();
__HAL_RCC_TIM2_CLK_ENABLE();
// Configure TX pin as push-pull output
GPIO_InitTypeDef GPIO_InitStruct = {0};
GPIO_InitStruct.Pin = SW_UART_TX_PIN;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
HAL_GPIO_Init(SW_UART_TX_PORT, &GPIO_InitStruct);
// Configure RX pin as input with pull-up
GPIO_InitStruct.Pin = SW_UART_RX_PIN;
GPIO_InitStruct.Mode = GPIO_MODE_INPUT;
GPIO_InitStruct.Pull = GPIO_PULLUP;
HAL_GPIO_Init(SW_UART_RX_PORT, &GPIO_InitStruct);
// Configure timer for oversampled baud rate generation
uint16_t prescaler = 1;
uint32_t period = (SW_UART_TIMER_CLK / (prescaler * SW_UART_BAUD * SW_UART_OVERSAMPLE)) - 1;
// Ensure period fits in 16 bits
while (period > 0xFFFF && prescaler < 0xFFFF) {
prescaler *= 2;
period = (SW_UART_TIMER_CLK / (prescaler * SW_UART_BAUD * SW_UART_OVERSAMPLE)) - 1;
}
htim_uart.Instance = SW_UART_TIMER;
htim_uart.Init.Prescaler = prescaler - 1;
htim_uart.Init.CounterMode = TIM_COUNTERMODE_UP;
htim_uart.Init.Period = period;
htim_uart.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;
HAL_TIM_Base_Init(&htim_uart);
HAL_TIM_Base_Start_IT(&htim_uart);
HAL_NVIC_SetPriority(SW_UART_TIMER_IRQ, 1, 0);
HAL_NVIC_EnableIRQ(SW_UART_TIMER_IRQ);
// Initialize state
uart.rx_state = SW_UART_STATE_IDLE;
uart.tx_state = SW_UART_STATE_IDLE;
uart.rx_bit_count = 0;
uart.tx_bit_count = 0;
uart.rx_tick_count = 0;
uart.tx_tick_count = 0;
uart.rx_shift_reg = 0;
uart.tx_shift_reg = 0;
uart.rx_head = uart.rx_tail = 0;
uart.tx_head = uart.tx_tail = 0;
uart.overflow_flag = 0;
// Start with TX line high (idle)
HAL_GPIO_WritePin(SW_UART_TX_PORT, SW_UART_TX_PIN, GPIO_PIN_SET);
}
// Transmit a byte (non-blocking, buffered)
void sw_uart_tx(uint8_t byte) {
uint16_t next_head = (uart.tx_head + 1) % sizeof(uart.tx_buffer);
while (next_head == uart.tx_tail) { // Wait for space in buffer
// Flow control / timeout handling can be placed here
}
uart.tx_buffer[uart.tx_head] = byte;
uart.tx_head = next_head;
// Critical section to safely inspect state and trigger start bit
__disable_irq();
if (uart.tx_state == SW_UART_STATE_IDLE) {
uart.tx_shift_reg = uart.tx_buffer[uart.tx_tail];
uart.tx_tail = (uart.tx_tail + 1) % sizeof(uart.tx_buffer);
uart.tx_bit_count = 0;
uart.tx_tick_count = 0;
uart.tx_state = SW_UART_STATE_START;
// Assert start bit (LOW)
HAL_GPIO_WritePin(SW_UART_TX_PORT, SW_UART_TX_PIN, GPIO_PIN_RESET);
}
__enable_irq();
}
// Receive a byte (non-blocking, returns 0 if no data)
int8_t sw_uart_rx(uint8_t *byte) {
if (uart.rx_head == uart.rx_tail) {
return -1; // No data
}
*byte = uart.rx_buffer[uart.rx_tail];
uart.rx_tail = (uart.rx_tail + 1) % sizeof(uart.rx_buffer);
return 0;
}
// Timer interrupt handler - call from TIM2_IRQHandler
void sw_uart_timer_isr(void) {
// Acknowledge and clear timer update interrupt flag to prevent re-entry lockup
__HAL_TIM_CLEAR_IT(&htim_uart, TIM_IT_UPDATE);
// Handle reception
if (uart.rx_state == SW_UART_STATE_IDLE) {
if (!HAL_GPIO_ReadPin(SW_UART_RX_PORT, SW_UART_RX_PIN)) {
// Detected falling edge (start bit)
uart.rx_state = SW_UART_STATE_START;
uart.rx_tick_count = 0;
}
} else {
uart.rx_tick_count++;
if (uart.rx_state == SW_UART_STATE_START) {
// Sample near midpoint of start bit (tick index 1 of 0..2)
if (uart.rx_tick_count >= 2) {
if (!HAL_GPIO_ReadPin(SW_UART_RX_PORT, SW_UART_RX_PIN)) {
// Start bit confirmed, move to data
uart.rx_state = SW_UART_STATE_DATA;
uart.rx_tick_count = 0;
uart.rx_bit_count = 0;
uart.rx_shift_reg = 0;
} else {
uart.rx_state = SW_UART_STATE_IDLE; // False start / glitch rejection
}
}
} else if (uart.rx_state == SW_UART_STATE_DATA) {
if (uart.rx_tick_count >= SW_UART_OVERSAMPLE) {
uart.rx_tick_count = 0;
// Shift in bit (LSB first for standard UART)
uart.rx_shift_reg >>= 1;
if (HAL_GPIO_ReadPin(SW_UART_RX_PORT, SW_UART_RX_PIN)) {
uart.rx_shift_reg |= 0x80;
}
uart.rx_bit_count++;
if (uart.rx_bit_count >= 8) {
uart.rx_state = SW_UART_STATE_STOP;
}
}
} else if (uart.rx_state == SW_UART_STATE_STOP) {
if (uart.rx_tick_count >= SW_UART_OVERSAMPLE) {
uart.rx_tick_count = 0;
// Check stop bit (must be logic HIGH)
if (HAL_GPIO_ReadPin(SW_UART_RX_PORT, SW_UART_RX_PIN)) {
uint16_t next_rx_head = (uart.rx_head + 1) % sizeof(uart.rx_buffer);
if (next_rx_head != uart.rx_tail) {
uart.rx_buffer[uart.rx_head] = uart.rx_shift_reg;
uart.rx_head = next_rx_head;
} else {
uart.overflow_flag = 1; // Buffer overflow
}
}
uart.rx_state = SW_UART_STATE_IDLE;
}
}
}
// Handle transmission
if (uart.tx_state != SW_UART_STATE_IDLE) {
uart.tx_tick_count++;
if (uart.tx_tick_count >= SW_UART_OVERSAMPLE) {
uart.tx_tick_count = 0;
switch (uart.tx_state) {
case SW_UART_STATE_START:
// Start bit was driven for 1 full bit period; drive first data bit
HAL_GPIO_WritePin(SW_UART_TX_PORT, SW_UART_TX_PIN,
(uart.tx_shift_reg & 0x01) ? GPIO_PIN_SET : GPIO_PIN_RESET);
uart.tx_shift_reg >>= 1;
uart.tx_bit_count = 1;
uart.tx_state = SW_UART_STATE_DATA;
break;
case SW_UART_STATE_DATA:
// Transmit remaining data bits
if (uart.tx_bit_count < 8) {
HAL_GPIO_WritePin(SW_UART_TX_PORT, SW_UART_TX_PIN,
(uart.tx_shift_reg & 0x01) ? GPIO_PIN_SET : GPIO_PIN_RESET);
uart.tx_shift_reg >>= 1;
uart.tx_bit_count++;
} else {
// All 8 data bits sent; drive STOP bit (HIGH)
HAL_GPIO_WritePin(SW_UART_TX_PORT, SW_UART_TX_PIN, GPIO_PIN_SET);
uart.tx_state = SW_UART_STATE_STOP;
}
break;
case SW_UART_STATE_STOP:
// Stop bit was driven for 1 full bit period; check for queued bytes
if (uart.tx_head != uart.tx_tail) {
uart.tx_shift_reg = uart.tx_buffer[uart.tx_tail];
uart.tx_tail = (uart.tx_tail + 1) % sizeof(uart.tx_buffer);
uart.tx_bit_count = 0;
uart.tx_state = SW_UART_STATE_START;
// Assert start bit (LOW) for the next byte
HAL_GPIO_WritePin(SW_UART_TX_PORT, SW_UART_TX_PIN, GPIO_PIN_RESET);
} else {
uart.tx_state = SW_UART_STATE_IDLE;
}
break;
case SW_UART_STATE_IDLE:
default:
break;
}
}
}
}
// Timer ISR entry point
void TIM2_IRQHandler(void) {
sw_uart_timer_isr();
}
// Example usage
int main(void) {
HAL_Init();
sw_uart_init();
const char *msg = "Hello from Software UART!\r\n";
while (*msg) {
sw_uart_tx(*msg++);
}
while (1) {
uint8_t byte;
if (sw_uart_rx(&byte) == 0) {
// Echo received byte
sw_uart_tx(byte);
}
// Background tasks can go here
}
}

Verification and Testing

To verify the software UART implementation:

  1. Hardware setup: Connect the TX pin to an oscilloscope or logic analyzer to verify waveform timing.
  2. Baud rate accuracy: Measure the bit period at the start bit transition; it should match 1/baud rate within 2% tolerance.
  3. Loopback test: Connect TX to RX on the same microcontroller and verify transmitted data is received correctly.
  4. External communication: Connect to a second UART device (e.g., FTDI adapter) and exchange data at various baud rates.
  5. Interrupt latency: Toggle a GPIO in the timer ISR to measure interrupt overhead and ensure it doesn’t exceed bit period margins.
  6. Buffer overflow: Send continuous data at maximum rate and verify the overflow flag sets when buffers are full.

Common issues and solutions:

  • Framing errors: Increase timer priority or reduce ISR processing time.
  • Data corruption: Verify sampling occurs at bit midpoint; adjust timer phase if needed.
  • Buffer overruns: Increase buffer sizes or implement flow control (XON/XOFF or RTS/CTS via GPIO).

Summary

Software UART provides a practical solution when hardware UART ports are insufficient, offering flexibility in pin assignment at the cost of CPU overhead. The STM32-compatible implementation above demonstrates a complete, interrupt-driven driver suitable for debug consoles and low-to-moderate bandwidth applications. Key takeaways include:

  • Use hardware timers for precise bit timing, not software delays.
  • Separate transmit and receive state machines prevent conflicts.
  • Double buffering isolates interrupt context from mainline code.
  • Verify timing with oscilloscope or logic analyzer before deploying.
  • For baud rates above 115200 or multi-channel needs, consider upgrading to a microcontroller with more hardware UARTs.

In resource-constrained systems, a well-tuned software UART can extend serial capabilities without hardware changes, enabling debugging and communication where none existed before.

  • Fixing Sporadic Hard Faults in FreeRTOS Heap Allocation
  • Fixing UART DMA Overrun Errors on STM32
  • Fixing I2C Clock Stretching Timeouts on STM32

References

  1. STMicroelectronics. “STM32F103xx Reference Manual.” RM0008, Rev 21, 2021.
  2. Silicon Labs. “AN495: Software UART Using Timers.” Rev 0.1, 2010.
  3. Michael Barr and Anthony Massa. “Programming Embedded Systems in C and C++.” O’Reilly Media, 2006.
  4. Jack Ganssle. “The Art of Designing Embedded Systems.” 2nd ed., Newnes, 2008.

Frequently Asked Questions

What is a software UART and when is it useful?

A software UART (Universal Asynchronous Receiver-Transmitter) is a UART implemented in software using GPIO pins and timer interrupts, useful when hardware UARTs are insufficient or unavailable for debugging and communication.

What are the main trade-offs of using a software UART?

Software UART consumes CPU cycles for bit-banging and interrupt handling, potentially affecting real-time performance, but offers flexibility in pin selection and avoids hardware peripheral conflicts.

How do you ensure reliable baud rate generation in a software UART?

Reliable baud rate generation requires precise timer interrupts, typically using a hardware timer configured to trigger at an oversampled multiple of the baud rate (e.g. 3x or 8x), with careful handling of interrupt latency and midpoint bit sampling.

Tags

software-uartembedded-cstm32debugging

Share


Previous Article
Fixing Sporadic Hard Faults in FreeRTOS Heap Allocation
Jithin Tom

Jithin Tom

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

Related Posts

Fixing Slow GPIO Toggling on STM32: Register-Level Optimization
Fixing Slow GPIO Toggling on STM32: Register-Level Optimization
September 05, 2026
3 min
© 2026, All Rights Reserved.
Powered By Netlyft

Quick Links

Advertise with usAbout UsContact Us

Social Media