
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.
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:
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.
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.
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 | Idle3.3V ------+ +-------+ +-------+ ... +-------+-------+------| | | | | | |0V +-------+ +-------+ + ... + +Ticks: 0 1 2 3 4 5 6 7 8 ... ... 27 28 29Sample: * * * *Verify Sample Sample VerifyStart Bit 0 Bit 1 Stop
For an STM32 timer running at $f_{\text{timer}} = 72\text{ MHz}$, targeting $115200\text{ Baud}$ with $3\times$ oversampling:
A $0.16\%$ timing error is well within the $\pm 2\%$ margin required for reliable asynchronous serial reception.
Key implementation requirements:
| Aspect | Hardware UART | Software UART |
|---|---|---|
| CPU Overhead | Minimal (handled by peripheral) | Significant (bit-banging and ISR processing) |
| Pin Flexibility | Fixed to specific pins | Any GPIO pins available |
| Baud Rate Accuracy | High (crystal-derived) | Depends on timer precision and interrupt latency |
| Concurrent Operations | Fully dual-buffered, DMA-capable | Limited by interrupt frequency and processing time |
| Implementation Complexity | Low (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.
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 definitionstypedef enum {SW_UART_STATE_IDLE,SW_UART_STATE_START,SW_UART_STATE_DATA,SW_UART_STATE_STOP} sw_uart_state_t;// Driver statetypedef 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 timervoid 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 outputGPIO_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-upGPIO_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 generationuint16_t prescaler = 1;uint32_t period = (SW_UART_TIMER_CLK / (prescaler * SW_UART_BAUD * SW_UART_OVERSAMPLE)) - 1;// Ensure period fits in 16 bitswhile (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 stateuart.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_IRQHandlervoid 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 receptionif (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 datauart.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 transmissionif (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 bitHAL_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 bitsif (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 bytesif (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 byteHAL_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 pointvoid TIM2_IRQHandler(void) {sw_uart_timer_isr();}// Example usageint 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 bytesw_uart_tx(byte);}// Background tasks can go here}}
To verify the software UART implementation:
Common issues and solutions:
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:
In resource-constrained systems, a well-tuned software UART can extend serial capabilities without hardware changes, enabling debugging and communication where none existed before.
Quick Links
Legal Stuff





