Skip to content
Home » Embedded Systems » Debugging » Debugging Communication Protocols: UART, SPI, and I2C Troubleshooting

Debugging Communication Protocols: UART, SPI, and I2C Troubleshooting

Communication Protocols
Part 9 of 10View Full Path →

KEY TAKEAWAYS

  • Protocol debugging requires methodical elimination — always start with physical layer (wiring, voltages) before examining data content
  • An I2C bus scanner is your first debugging tool — it reveals addressing issues, missing pull-ups, and stuck buses instantly
  • SPI CPOL/CPHA mode mismatches are the most common SPI problem — there are 4 modes and both master and slave must match
  • Software instrumentation (GPIO toggles, diagnostic UARTs, error counters) can debug timing-sensitive protocols without affecting their behavior

Why Protocol Debugging Is Different

Debugging a communication protocol is fundamentally different from debugging application logic. You cannot just set a breakpoint and step through the code because halting the CPU stops the protocol timing. The other device on the bus keeps running, and when you resume, the conversation is out of sync. Protocol bugs are also hardware-adjacent — they can be caused by incorrect wiring, missing pull-up resistors, voltage level mismatches, or electromagnetic interference, none of which show up in your C code.

For general debugging strategies, see Debugging Embedded Systems: Tools and Techniques. This article focuses specifically on the three most common embedded communication protocols: UART, SPI, and I2C. For protocol fundamentals, see Communication Interfaces: UART, SPI, I2C.

Debugging UART Problems

Problem: Garbled Data

You see characters, but they are wrong. This is almost always a baud rate mismatch. If the transmitter sends at 115200 but the receiver expects 9600, each bit is interpreted with the wrong timing, producing seemingly random characters.

Diagnostic function that helps identify the actual baud rate by measuring the narrowest pulse on the RX line:

/*
 * UART Baud Rate Estimator
 * Connect the unknown UART TX to a GPIO input pin.
 * This function measures the shortest pulse width, which corresponds
 * to one bit period at the transmitter's baud rate.
 */
#include <stdint.h>

/* Timer for microsecond measurements */
extern uint32_t timer_get_us(void);

/* GPIO read function */
extern int gpio_read_pin(uint32_t pin);

uint32_t estimate_baud_rate(uint32_t gpio_pin, uint32_t sample_time_ms)
{
    uint32_t min_pulse_us = UINT32_MAX;
    uint32_t end_time = timer_get_us() + (sample_time_ms * 1000);
    int last_state = gpio_read_pin(gpio_pin);

    uint32_t edge_time = timer_get_us();

    while (timer_get_us() < end_time) {
        int current_state = gpio_read_pin(gpio_pin);

        if (current_state != last_state) {
            /* Edge detected — measure pulse width */
            uint32_t pulse_us = timer_get_us() - edge_time;
            edge_time = timer_get_us();

            if (pulse_us > 1 && pulse_us < min_pulse_us) {
                min_pulse_us = pulse_us;
            }

            last_state = current_state;
        }
    }

    if (min_pulse_us == UINT32_MAX || min_pulse_us == 0) {
        return 0;  /* No transitions detected */
    }

    /* Baud rate = 1 / bit_period */
    uint32_t estimated_baud = 1000000 / min_pulse_us;

    /* Round to nearest standard baud rate */
    const uint32_t standard_bauds[] = {
        1200, 2400, 4800, 9600, 19200, 38400,
        57600, 115200, 230400, 460800, 921600
    };

    uint32_t closest = standard_bauds[0];
    uint32_t min_diff = UINT32_MAX;

    for (int i = 0; i < 11; i++) {
        uint32_t diff = (estimated_baud > standard_bauds[i])
                      ? (estimated_baud - standard_bauds[i])
                      : (standard_bauds[i] - estimated_baud);
        if (diff < min_diff) {
            min_diff = diff;
            closest = standard_bauds[i];
        }
    }

    return closest;
}

Problem: No Data At All

A systematic checklist for UART “no communication”:

/*
 * UART Diagnostic Self-Test
 * Run this on startup to catch common configuration errors.
 */
typedef struct {
    int clock_enabled;
    int tx_pin_configured;
    int rx_pin_configured;
    int uart_enabled;
    int baud_rate_nonzero;
    int tx_works;          /* Loopback test if TX-RX connected */
} uart_diag_t;

uart_diag_t uart_diagnose(void)
{
    uart_diag_t result = {0};

    /* Check if UART clock is enabled in RCC */
    result.clock_enabled = (RCC_APB2ENR & (1U << 14)) ? 1 : 0;

    /* Check if UART is enabled (UE bit in CR1) */
    result.uart_enabled = (USART1_CR1 & (1U << 13)) ? 1 : 0;

    /* Check baud rate register is non-zero */
    result.baud_rate_nonzero = (USART1_BRR != 0) ? 1 : 0;

    /* Check GPIO configuration for TX pin (PA9)
     * Should be Alternate Function Push-Pull */
    uint32_t pa9_mode = (GPIOA_CRH >> 4) & 0xF;
    result.tx_pin_configured = (pa9_mode == 0x0B) ? 1 : 0;  /* AF PP, 50MHz */

    /* Check RX pin (PA10) — should be floating or pull-up input */
    uint32_t pa10_mode = (GPIOA_CRH >> 8) & 0xF;
    result.rx_pin_configured = (pa10_mode == 0x04 || pa10_mode == 0x08) ? 1 : 0;

    /* Print results */
    printf("UART Diagnostic:\n");
    printf("  Clock enabled:     %s\n", result.clock_enabled ? "OK" : "FAIL");
    printf("  TX pin configured: %s\n", result.tx_pin_configured ? "OK" : "FAIL");
    printf("  RX pin configured: %s\n", result.rx_pin_configured ? "OK" : "FAIL");
    printf("  UART enabled:      %s\n", result.uart_enabled ? "OK" : "FAIL");
    printf("  Baud rate set:     %s\n", result.baud_rate_nonzero ? "OK" : "FAIL");

    return result;
}

Debugging SPI Problems

Problem: SPI Slave Returns All Zeros or All 0xFF

This usually means the slave is not responding at all. Check: CS pin is actually going low, MISO is not floating (needs pull-up or is driven by slave), clock speed is not too fast for the slave.

Problem: CPOL/CPHA Mode Mismatch

SPI has 4 clock modes defined by two parameters. A mismatch between master and slave means data is sampled at the wrong time:

/*
 * SPI Mode Scanner
 *
 * Tries all 4 SPI modes to find which one the slave responds to.
 * Most SPI devices have a WHO_AM_I or device ID register that returns
 * a known constant value. We use this to identify the correct mode.
 *
 * Mode 0: CPOL=0, CPHA=0 — Clock idle low, sample on rising edge
 * Mode 1: CPOL=0, CPHA=1 — Clock idle low, sample on falling edge
 * Mode 2: CPOL=1, CPHA=0 — Clock idle high, sample on falling edge
 * Mode 3: CPOL=1, CPHA=1 — Clock idle high, sample on rising edge
 */
void spi_mode_scanner(uint8_t reg_addr, uint8_t expected_value)
{
    printf("SPI Mode Scannern");
    printf("Reading register 0x%02X, expecting 0x%02X\n\n", reg_addr, expected_value);

    for (int mode = 0; mode < 4; mode++) {
        /* Reconfigure SPI with this mode */
        spi_set_mode(mode);

        /* Read the register */
        uint8_t value = spi_read_register(reg_addr);

        printf("  Mode %d (CPOL=%d, CPHA=%d): got 0x%02X %s\n",
               mode, mode >> 1, mode & 1, value,
               (value == expected_value) ? " ← CORRECT" : "");
    }
}

/* Example usage:
 * Most accelerometers have WHO_AM_I at register 0x0F
 * spi_mode_scanner(0x0F, 0x33);   // LIS3DH returns 0x33
 */

Debugging I2C Problems

The I2C Bus Scanner — Your Most Important Tool

The I2C bus scanner probes every possible 7-bit address (0x08 to 0x77) and reports which ones respond with an ACK. This instantly tells you if your device is connected, powered, and what address it is using. The 7-bit vs 8-bit address confusion causes many problems — some datasheets list the 8-bit address (including the R/W bit) while the I2C driver expects 7-bit.

/*
 * I2C Bus Scanner
 * Probes all valid 7-bit addresses (0x08 to 0x77).
 * Addresses 0x00-0x07 and 0x78-0x7F are reserved by the I2C spec.
 */
void i2c_bus_scan(void)
{
    int found = 0;

    printf("I2C Bus Scann");
    printf("     0  1  2  3  4  5  6  7  8  9  A  B  C  D  E  Fn");

    for (uint8_t addr = 0; addr < 0x80; addr++) {
        if ((addr & 0x0F) == 0) {
            printf("0x%X0:", addr >> 4);
        }

        if (addr < 0x08 || addr > 0x77) {
            printf(" --");
        } else {
            /* Try to communicate with this address.
             * Send START + address + R/W, check for ACK. */
            int ack = i2c_probe_address(addr);

            if (ack == 0) {
                printf(" %02X", addr);
                found++;
            } else {
                printf(" --");
            }
        }

        if ((addr & 0x0F) == 0x0F) {
            printf("n");
        }
    }

    printf("\nFound %d device(s)\n", found);

    /* Print known device addresses for common chips */
    if (found > 0) {
        printf("\nCommon device addresses:\n");
        printf("  0x27, 0x3F       — PCF8574 LCD backpackn");
        printf("  0x48-0x4F        — TMP102, LM75 temperature sensorn");
        printf("  0x50-0x57        — AT24Cxx EEPROMn");
        printf("  0x68             — DS1307/DS3231 RTC, MPU6050\n");
        printf("  0x76, 0x77       — BMP280/BME280 pressure sensorn");
        printf("  0x1E             — HMC5883L compassn");
        printf("  0x53             — ADXL345 accelerometern");
    }
}

/*
 * i2c_probe_address — Try to communicate with a slave address.
 * Returns 0 if ACK received (device present), -1 if NAK.
 *
 * Implementation: send START, address + WRITE, then STOP.
 * If the slave ACKs the address byte, it is present.
 */
int i2c_probe_address(uint8_t addr_7bit)
{
    /* Generate START condition */
    i2c_start();

    /* Send address with WRITE bit (bit 0 = 0) */
    uint8_t addr_byte = (addr_7bit << 1) | 0;  /* 7-bit address + W */
    int ack = i2c_send_byte(addr_byte);

    /* Generate STOP condition */
    i2c_stop();

    return ack;  /* 0 = ACK (device found), -1 = NAK */
}

Problem: I2C Bus Stuck (SDA Held Low)

If a slave device is interrupted mid-transaction (reset, power glitch), it may hold SDA low indefinitely, preventing any further communication. The bus recovery procedure toggles SCL 9 times, which clocks out any stuck data bits and releases SDA:

/*
 * I2C Bus Recovery
 *
 * If a slave holds SDA low (stuck bus), toggle SCL up to 9 times.
 * Each SCL cycle clocks out one bit from the stuck slave.
 * After 9 clocks (max 8 data bits + 1 ACK), SDA should be released.
 * Then send a STOP condition to reset the bus.
 */
int i2c_bus_recovery(void)
{
    /* Temporarily configure SCL and SDA as GPIO outputs */
    gpio_set_mode(I2C_SCL_PIN, GPIO_MODE_OUTPUT_OD);
    gpio_set_mode(I2C_SDA_PIN, GPIO_MODE_INPUT);

    /* Check if SDA is stuck low */
    if (gpio_read(I2C_SDA_PIN) == 1) {
        printf("I2C bus is OK (SDA is high)\n");
        return 0;  /* Bus is fine */
    }

    printf("I2C bus stuck! Attempting recovery...\n");

    /* Toggle SCL up to 9 times */
    for (int i = 0; i < 9; i++) {
        gpio_write(I2C_SCL_PIN, 0);
        delay_us(5);
        gpio_write(I2C_SCL_PIN, 1);
        delay_us(5);

        /* Check if SDA was released */
        if (gpio_read(I2C_SDA_PIN) == 1) {
            printf("SDA released after %d clock(s)\n", i + 1);
            break;
        }
    }

    /* Generate STOP condition: SDA low-to-high while SCL is high */
    gpio_set_mode(I2C_SDA_PIN, GPIO_MODE_OUTPUT_OD);
    gpio_write(I2C_SDA_PIN, 0);
    delay_us(5);
    gpio_write(I2C_SCL_PIN, 1);
    delay_us(5);
    gpio_write(I2C_SDA_PIN, 1);    /* STOP: SDA rises while SCL is high */
    delay_us(5);

    /* Reconfigure pins for I2C peripheral */
    gpio_set_mode(I2C_SCL_PIN, GPIO_MODE_AF_OD);
    gpio_set_mode(I2C_SDA_PIN, GPIO_MODE_AF_OD);

    /* Reinitialize I2C peripheral */
    i2c_reinit();

    /* Verify recovery */
    if (gpio_read(I2C_SDA_PIN) == 1) {
        printf("I2C bus recovery successfuln");
        return 0;
    } else {
        printf("I2C bus recovery FAILED — check hardwaren");
        return -1;
    }
}

Software Debugging Techniques

GPIO Toggles for Timing Measurement

Toggle a spare GPIO pin at key points in your protocol code. Connect an oscilloscope or logic analyzer to the GPIO to see exact timing without affecting the protocol:

/* Debug pin toggling — use a spare GPIO */
#define DEBUG_PIN_PORT  GPIOB
#define DEBUG_PIN       (1U << 0)   /* PB0 as debug output */

static inline void debug_pin_high(void) {
    DEBUG_PIN_PORT->BSRR = DEBUG_PIN;           /* Set (atomic) */
}
static inline void debug_pin_low(void) {
    DEBUG_PIN_PORT->BSRR = (DEBUG_PIN << 16);   /* Reset (atomic) */
}
static inline void debug_pin_toggle(void) {
    DEBUG_PIN_PORT->ODR ^= DEBUG_PIN;
}

/* Use in ISR to measure interrupt latency and duration: */
void USART1_IRQHandler(void)
{
    debug_pin_high();      /* Rising edge = ISR entry */

    uart_isr_handler(UART_PORT_1);

    debug_pin_low();       /* Falling edge = ISR exit */
    /* Pulse width on scope = ISR execution time */
}

/* Use around SPI transfer to measure bus timing: */
void read_sensor(void)
{
    debug_pin_high();      /* Mark start of transfer */

    cs_low();
    spi_transfer(CMD_READ_DATA);
    uint8_t msb = spi_transfer(0x00);
    uint8_t lsb = spi_transfer(0x00);
    cs_high();

    debug_pin_low();       /* Mark end of transfer */
}

Protocol Event Logger

Log protocol events with timestamps to a secondary UART or memory buffer for post-mortem analysis:

/* Lightweight protocol event logger */
typedef enum {
    EVT_I2C_START,
    EVT_I2C_ADDR_ACK,
    EVT_I2C_ADDR_NAK,
    EVT_I2C_DATA_TX,
    EVT_I2C_DATA_RX,
    EVT_I2C_STOP,
    EVT_I2C_ERROR,
    EVT_SPI_CS_LOW,
    EVT_SPI_CS_HIGH,
    EVT_SPI_XFER,
    EVT_UART_TX,
    EVT_UART_RX,
    EVT_UART_ERROR,
} proto_event_type_t;

typedef struct {
    uint32_t timestamp_us;
    proto_event_type_t type;
    uint8_t data;
} proto_event_t;

#define EVENT_LOG_SIZE  256
static proto_event_t event_log[EVENT_LOG_SIZE];
static volatile uint16_t event_index = 0;

void proto_log_event(proto_event_type_t type, uint8_t data)
{
    if (event_index < EVENT_LOG_SIZE) {
        event_log[event_index].timestamp_us = timer_get_us();
        event_log[event_index].type = type;
        event_log[event_index].data = data;
        event_index++;
    }
}

void proto_dump_log(void)
{
    static const char *event_names[] = {
        "I2C START", "I2C ADDR ACK", "I2C ADDR NAK",
        "I2C DATA TX", "I2C DATA RX", "I2C STOP", "I2C ERROR",
        "SPI CS LOW", "SPI CS HIGH", "SPI XFER",
        "UART TX", "UART RX", "UART ERROR"
    };

    printf("Protocol Event Log (%d events):\n", event_index);
    printf("  Time(us)  Event          Datan");
    printf("  --------  -------------- ----\n");

    uint32_t t0 = (event_index > 0) ? event_log[0].timestamp_us : 0;

    for (uint16_t i = 0; i < event_index; i++) {
        printf("  %8lu  %-14s 0x%02X\n",
               event_log[i].timestamp_us - t0,
               event_names[event_log[i].type],
               event_log[i].data);
    }
}

void proto_clear_log(void)
{
    event_index = 0;
}

Systematic Debugging Checklist

When a communication protocol is not working, follow this checklist in order. Do not skip steps — the most common problems are physical layer issues that software debugging tools cannot detect.

For All Protocols

  1. Check power — Is the slave device powered? Measure VDD with a multimeter.
  2. Check ground — Are grounds connected between master and slave? This is the #1 forgotten wire.
  3. Check voltage levels — Are both devices running at the same voltage? 3.3V MCU talking to 5V device needs a level shifter.
  4. Check clock enable — Is the peripheral clock enabled in the RCC/clock controller?
  5. Check pin configuration — Are the pins configured for alternate function (not GPIO)?
  6. Check with a scope/logic analyzer — Is there ANY signal on the line? If not, the problem is configuration, not protocol.

UART-Specific

  • TX→RX crossover (not TX→TX)
  • Baud rate match (within 2%)
  • Same frame format (data bits, parity, stop bits)
  • Check for inverted logic (RS-232 vs TTL)

SPI-Specific

  • CS pin going low during transfer
  • CPOL/CPHA mode matching the slave’s datasheet
  • Clock speed within the slave’s maximum
  • Bit order (MSB first vs LSB first)
  • MOSI/MISO not swapped

I2C-Specific

  • Pull-up resistors on SDA and SCL (typically 4.7kΩ for 100kHz, 2.2kΩ for 400kHz)
  • Correct 7-bit address (not the 8-bit shifted address from the datasheet)
  • Bus not stuck (run recovery if SDA is low)
  • No address conflicts (two devices with the same address)
  • Clock speed within the slave’s capability
  • Error Handling Patterns in C
  • Interfacing Sensors with Microcontrollers

Summary

Protocol debugging is a skill that improves dramatically with practice. Build a personal toolkit: an I2C bus scanner, an SPI mode scanner, a UART diagnostic function, and a protocol event logger. These tools pay for themselves the first time they save you from a multi-day debugging session.

Always work from the physical layer upward: power, ground, voltage, wiring, then configuration, then data content. Most “software bugs” in communication protocols turn out to be hardware problems.

For deeper dives into each protocol, read UART Protocol Deep Dive. For the driver implementations, see Writing a UART Driver. For general debugging methodology, see Debugging Embedded Systems.

📖 Related: Using a Logic Analyzer for Embedded Debugging: Complete Guide

Leave a Reply

Your email address will not be published. Required fields are marked *