Embedded bugs feel “mystical” because you’re debugging two things at once: software and electrons. The fastest teams fix this by building a layered workflow: start with UART logs for cheap visibility, move to JTAG/SWD when you need real control, and pull out a logic analyzer when timing or buses are involved. This post shows how to use each tool on purpose—and how to combine them into a repeatable debugging loop.
Quickstart
If you’re stuck on a board right now, do these in order. They’re the highest-impact “fast wins” and they prevent you from wasting hours on the wrong layer.
1) Sanity check power, reset, and clock
Before you debug firmware, confirm the board can exist in a stable state.
- Power rail is correct (3.3V vs 5V), stable, and not brown-outing
- Reset line isn’t held low (or glitching)
- Clock source is valid (HSE crystal/oscillator actually running)
- Watchdog isn’t instantly resetting you (disable temporarily)
2) Get any UART output
UART logs are the cheapest visibility tool. Even one “boot reached X” line narrows the search space.
- Confirm UART voltage level (most MCUs are 3.3V TTL, not RS-232)
- Connect GND ↔ GND and cross TX/RX
- Try common baud rates (115200, 921600, 57600)
- Print a boot banner before anything complex
3) Attach a debugger (JTAG/SWD) and halt early
When UART is silent or unreliable, a debugger tells you whether the CPU is alive and where it is.
- Connect SWDIO/SWCLK (or JTAG) + GND (VTref if required)
- Try “connect under reset” if firmware locks the bus
- Halt at reset handler / main and step forward
- Read fault status registers if you suspect a crash
4) Use a logic analyzer for timing and buses
If the bug involves I2C/SPI/UART framing, chip selects, or “it works sometimes”, measure the signals.
- Capture at a sane sample rate (often 5–10× the fastest edge frequency)
- Decode the protocol (I2C/SPI/UART) and confirm the bytes match expectations
- Check reset/power-good timing relative to bus activity
- Mark a firmware “event pin” to correlate code → waveform
Always ask: “What observation would prove me wrong?” UART, JTAG, and a logic analyzer are just different ways to create that observation—cheaply and repeatably.
Overview
“Embedded Debugging: UART Logs, JTAG, and Logic Analyzers” is really about observability. You want to answer four questions quickly:
- Is the CPU running? (or stuck in reset / brown-out / fault)
- Where is it running? (bootloader, reset handler, main loop, interrupt)
- What is it doing? (state transitions, configuration, errors)
- What are the pins doing? (timing, bus transactions, glitches)
Tool map: which one to use (and why)
| Tool | Best for | What it can’t tell you | Typical “win” |
|---|---|---|---|
| UART logs | Progress markers, errors, quick insights, post-mortem breadcrumbs | Precise timing, early boot before UART init, hard faults with no prints | “We never reached init step 3.” |
| JTAG/SWD + GDB | Breakpoints, stepping, memory/register inspection, watchpoints, fault analysis | Real-world timing (debugging changes timing), external signal integrity | “We crash in ISR X; here’s the bad pointer.” |
| Logic analyzer | Digital timing, bus decode (I2C/SPI/UART), chip select issues, intermittent glitches | CPU internal state, why firmware chose a value | “I2C NACKs because the address is wrong / bus is stuck.” |
The goal of this post isn’t to turn you into a tool collector. It’s to help you build a workflow: reproduce → observe → isolate → fix → prevent. You’ll also get checklists and “common mistakes” so you can skip the classic traps (wrong UART voltage, missing GND reference, breakpoints breaking timing, and more).
The best embedded debugging setups are boring: a reliable serial console, a debugger that connects every time, and a logic analyzer you trust. Boring tools make for fast fixes.
Core concepts
Good debugging is less “try random things” and more “reduce uncertainty”. These concepts help you decide which tool to use, what to measure, and how to avoid self-inflicted chaos.
1) Observability layers
Think of debugging as layers, from cheapest to deepest:
- Firmware-level signals: logs, counters, state flags, “event pins” (a GPIO toggle)
- CPU-level control: halt/step, read registers/memory, breakpoints/watchpoints (JTAG/SWD)
- Board-level truth: what the pins and buses actually do (logic analyzer, scope)
Most “mysterious” issues happen when you assume the wrong layer is trustworthy. Example: you trust a UART log, but the UART driver drops bytes under load. Or you trust a breakpoint, but the watchdog resets because you paused too long.
2) Determinism and “Heisenbugs”
Embedded systems are timing-sensitive. When you debug, you often change timing: breakpoints stall the CPU, logging adds latency, and even a debugger connection can alter power/noise slightly. A Heisenbug is a bug that disappears when observed.
If a bug only happens “without the debugger attached”, suspect timing, interrupts, DMA races, watchdog behavior, or marginal signal integrity. That’s a strong hint to use a logic analyzer and lightweight instrumentation instead of heavy stepping.
3) UART vs JTAG vs SWD (what they really are)
- UART is a serial communication peripheral. For debugging, it’s a “printf pipe” (one-way or two-way). Cheap, simple, and often good enough.
- JTAG is a multi-wire debug/test interface (common in many MCUs and CPUs). It can control the core and access memory.
- SWD (Serial Wire Debug) is an ARM two-wire alternative to JTAG (SWDIO + SWCLK). It’s what many Cortex-M boards expose.
Practically: treat “JTAG/SWD” as “the debugger port.” Your probe might be ST-LINK, J-Link, CMSIS-DAP, etc. The workflow is similar: connect, halt, inspect, step, set breakpoints/watchpoints.
4) Timing correlation: the “event pin” trick
When you’re using a logic analyzer, add one spare GPIO as a timing marker. Toggle it around the code region you care about. Then you can correlate: code section A → bus transaction B → interrupt C.
If you can’t explain the bug without pointing at a waveform, you probably need an event pin + logic analyzer capture. It’s the easiest way to turn “sometimes” into a measured sequence.
Step-by-step
Here’s a practical workflow you can reuse across boards and projects. It’s intentionally tool-agnostic: whether you’re on an ESP32, STM32, AVR, RP2040, or a custom SoC, the pattern holds.
Step 1 — Make the bug reproducible (or simulate it)
- Write down the exact trigger (power cycle, command sequence, sensor input, time delay)
- Minimize variables (same cable, same power supply, same firmware build)
- If it’s intermittent, add a loop to reproduce it faster (e.g., reboot/test 100 times)
- Define success/failure in one sentence (“device must boot to main loop within 2 seconds”)
Step 2 — Add UART logs that don’t make things worse
UART logging is great until it changes behavior: blocking writes can break real-time code, printing in interrupts can deadlock, and high log volume can flood buffers. The key is to log state transitions and fault breadcrumbs, not everything.
High-signal log lines
- Boot banners and firmware version/build hash
- “Reached init step X” markers
- Peripheral config summaries (baud, address, mode)
- Fault reasons (reset cause, hard fault, asserts)
- Measured values when crossing thresholds
Low-signal log spam
- Printing inside tight loops at full speed
- Verbose logs in interrupts or DMA callbacks
- Logging every sensor sample (unless downsampled)
- Dumping huge buffers without flow control
If you’re working bare-metal or on a small RTOS, a simple ring buffer avoids blocking and keeps your system responsive. Here’s a minimal pattern: log into a buffer fast, flush from a safe context (main loop or low-priority task).
<!-- Minimal non-blocking UART logger using a ring buffer (conceptual, adapt to your HAL/SDK) -->
#include <stdint.h>
#include <stdarg.h>
#include <stdio.h>
#define LOG_BUF_SIZE 1024
static volatile uint16_t log_head = 0;
static volatile uint16_t log_tail = 0;
static uint8_t log_buf[LOG_BUF_SIZE];
/* TODO in your project: implement uart_tx_start_if_idle() and uart_can_tx() and uart_tx_byte(). */
static inline uint16_t rb_next(uint16_t i) { return (uint16_t)((i + 1u) % LOG_BUF_SIZE); }
static void rb_push(uint8_t b) {
uint16_t next = rb_next(log_head);
if (next == log_tail) {
/* Buffer full: drop byte (or overwrite oldest). Dropping is safer than blocking. */
return;
}
log_buf[log_head] = b;
log_head = next;
}
void log_printf(const char* fmt, ...) {
char tmp[128];
va_list ap;
va_start(ap, fmt);
int n = vsnprintf(tmp, sizeof(tmp), fmt, ap);
va_end(ap);
if (n <= 0) return;
if (n > (int)sizeof(tmp)) n = (int)sizeof(tmp);
for (int i = 0; i < n; i++) rb_push((uint8_t)tmp[i]);
rb_push((uint8_t)'\n');
/* Kick TX if needed (implementation depends on your UART driver/interrupt/DMA). */
uart_tx_start_if_idle();
}
/* Call from main loop or a low-priority task to flush. */
void log_flush_poll(void) {
while (log_tail != log_head && uart_can_tx()) {
uint8_t b = log_buf[log_tail];
log_tail = rb_next(log_tail);
uart_tx_byte(b);
}
}
The ring buffer makes logging “cheap” (constant-time) and prevents your system from blocking on UART throughput. Dropping a few debug bytes under stress is usually better than changing timing enough to hide the bug.
Step 3 — Use JTAG/SWD for control, not for “guessing”
When you can connect a debugger, you get superpowers: halt the CPU, inspect memory, set breakpoints, and read fault registers. Use it to answer targeted questions:
- Did we reach
main()? - Are interrupts firing?
- What is the stack pointer and where is it pointing?
- Did we hard fault? If yes, what was the fault address?
- Is a variable changing when it shouldn’t? (watchpoint)
Debugger quick checks
- Try “halt” and see if the PC changes (CPU alive?)
- Read reset reason registers (brown-out? watchdog?)
- Set a breakpoint at
mainand step through init - Use a watchpoint on a suspect variable (stack corruption, race)
When to avoid stepping
- Timing-sensitive protocols (bit-banging, tight SPI windows)
- Watchdog-enabled systems without pause handling
- Interrupt-heavy designs where halting changes ordering
- Power/noise-sensitive bring-up (marginal hardware)
Below is a “known-good” baseline workflow with OpenOCD and GDB (common for ARM Cortex-M via SWD). Adapt the target config file to your MCU and probe. The important part is the sequence: connect → reset/halt → verify you can read memory → set breakpoints/watchpoints.
# Terminal 1: start OpenOCD (example flow; adjust interface/target files)
openocd -f interface/cmsis-dap.cfg -f target/stm32f4x.cfg
# Terminal 2: connect with arm-none-eabi-gdb
arm-none-eabi-gdb build/firmware.elf <<'GDB'
target extended-remote :3333
monitor reset halt
# Confirm the CPU is halted and symbols are loaded
info registers
# Break at main and run
break main
continue
# If you suspect a crash, inspect the backtrace
bt
# Watch a variable (or a memory address) for unexpected writes
# (Watchpoints are limited; use them surgically.)
watch some_global_state
continue
GDB
If stepping looks “weird” (lines skipped, variables optimized away), compile a debug build (symbols on, sane optimizations like -Og).
Then confirm the problem still reproduces—otherwise you might be chasing a debug-only artifact.
Step 4 — Measure the pins with a logic analyzer
Logic analyzers are unbeatable for digital truth: did the chip select assert? did the I2C address get ACKed? are UART bytes framed correctly? They’re especially powerful when you combine them with firmware markers (an “event pin”) and logs.
Capture strategy (simple rules)
- Common ground: connect the analyzer ground to the board ground
- Voltage levels: confirm the analyzer input tolerates your logic level
- Sample rate: start high enough to see edges cleanly; reduce if you need longer captures
- Trigger: trigger on reset deassertion, chip select, or an event pin toggle
- Decode: use protocol decoders (I2C/SPI/UART) and validate bytes, not just edges
Here’s an example using sigrok’s CLI to capture and decode I2C. This is handy when you want to commit captures to a bug report, run the same capture repeatedly, or automate “did it ACK?” checks.
# Example: capture I2C on two channels and decode with sigrok-cli
# - Set the driver to your device (run: sigrok-cli --scan)
# - Adjust samplerate and capture time to your bus speed and debug needs
sigrok-cli -d fx2lafw \
--config samplerate=24MHz \
--time 2s \
-C D0=SCL,D1=SDA \
-P i2c \
-O ascii
Once you can see the transaction, debugging becomes concrete: you can spot missing pull-ups, wrong address, clock stretching, bus stuck low, or chip select glitches in minutes.
Step 5 — Close the loop: fix, verify, and prevent regression
Verify the fix
- Re-run the exact reproduction steps
- Compare logs/captures before vs after
- Test worst-case timing (stress mode, max bus traffic)
- Power cycle repeatedly (intermittent bugs love warm resets)
Prevent the bug from returning
- Add a boot banner with version/build ID
- Keep a “debug build” config (UART enabled, asserts on)
- Write one small regression test (even if manual)
- Document the root cause in a short checklist
Common mistakes
These mistakes show up in almost every embedded project—especially during bring-up. The fixes are usually simple, but you have to recognize the pattern.
Mistake 1 — UART “doesn’t work” (but it’s wiring/levels)
UART debugging fails most often for non-software reasons.
- Fix: share a common ground (board GND ↔ adapter GND).
- Fix: cross TX/RX (board TX → adapter RX, board RX → adapter TX).
- Fix: confirm voltage (3.3V TTL vs 5V TTL vs RS-232).
- Fix: verify baud rate and UART settings (8N1 is common, but not universal).
Mistake 2 — Using printf in an ISR
Blocking I/O inside interrupts can deadlock, break timing, or starve lower-priority work.
- Fix: log into a ring buffer; flush from main/low-priority context.
- Fix: in interrupts, prefer counters/flags over strings.
- Fix: if you must log, rate-limit it.
Mistake 3 — “Debugger won’t connect” because firmware is hostile
Fast reboots, sleep modes, or pin remaps can lock you out.
- Fix: try “connect under reset” (hold reset while connecting).
- Fix: lower SWD/JTAG speed (signal integrity and long wires matter).
- Fix: ensure VTref/reference pin expectations are met (probe-dependent).
- Fix: avoid using SWD pins for GPIO in early bring-up.
Mistake 4 — Breakpoints “fix” the bug (timing changes)
If the bug disappears when halted/stepped, it’s often a race or timing edge.
- Fix: use lightweight instrumentation (event pin, counters, timestamp logs).
- Fix: use a logic analyzer to see real timing on the bus/pins.
- Fix: test with release-like build settings after you find the root cause.
Mistake 5 — Blaming software for a hardware signal problem
Missing pull-ups, bad termination, and ground bounce can look like “random firmware.”
- Fix: capture I2C/SPI lines and confirm voltage levels and ACK/NACK behavior.
- Fix: check the basics: pull-ups on I2C, correct CS polarity, shared ground.
- Fix: reduce bus speed as a diagnostic (if it “fixes” it, suspect signal integrity).
Mistake 6 — Not reading reset/fault reasons
Random reboots are often not random at all.
- Fix: print the reset cause on boot (watchdog, brown-out, external reset).
- Fix: on faults, capture key registers (fault status, PC, LR, stack pointer).
- Fix: keep a tiny “fault dump” stored in RAM/backup registers if UART is unreliable.
If you can’t explain the failure with one screenshot (a log snippet, a backtrace, or a waveform capture), you probably need a better observation before you change more code.
FAQ
Do I need JTAG/SWD if I already have UART logs?
Not always. UART logs are often enough for application-level bugs and “did we reach this state?” problems. You need JTAG/SWD when the system crashes before logs, hangs with no output, corrupts memory, or when you must inspect registers/stack to understand faults.
What’s the difference between JTAG and SWD?
They’re both debug interfaces. JTAG uses more pins (a traditional scan chain), while SWD is a two-wire ARM alternative (SWDIO + SWCLK). For many Cortex-M boards, SWD is the default. The tooling and workflow (GDB/OpenOCD, breakpoints, memory inspection) are similar.
Why is my UART output garbled?
Most commonly it’s the wrong baud rate or wrong voltage level. Also check that you share ground, you’re using the correct pin pair, and your UART settings match (8N1 is common). If it’s “mostly readable but sometimes corrupted,” suspect noise, long wires, incorrect clock config, or an overloaded log path dropping bytes.
Why does the debugger connect but can’t halt or flash reliably?
Suspect signal integrity and target state. Lower the SWD/JTAG speed, shorten wires, ensure solid ground, and try connect-under-reset. If the firmware rapidly enters low-power modes or remaps debug pins, it can destabilize the session—especially during bring-up.
Can breakpoints change real-time behavior?
Yes—often dramatically. Halting the CPU pauses interrupts, changes DMA timing, and can trigger watchdog resets. If the bug disappears with breakpoints, use event pins + logic analyzer, lightweight logs, counters, and watchpoints instead of stepping.
Logic analyzer vs oscilloscope: which should I buy/use first?
If you mostly debug digital buses (I2C/SPI/UART), start with a logic analyzer. Protocol decode and long captures are huge advantages. If you debug analog issues (power rails, ringing, rise times, brown-outs), you’ll want an oscilloscope. Many teams use both: analyzer for “what bytes and when,” scope for “are the edges/power actually valid.”
How do I find UART pins on an unknown board?
Start with the schematic or silkscreen, then confirm with measurement. Look for labeled headers (TX/RX/GND), debug pads, or test points near the MCU. If documentation is missing, a logic analyzer can help: probe suspect pins during boot and look for a UART-like idle-high waveform, then try decoding at common baud rates.
Cheatsheet
A compact checklist you can keep open while debugging.
UART logs checklist
- GND ↔ GND connected
- TX/RX crossed correctly
- Voltage level is compatible (TTL vs RS-232, 3.3V vs 5V)
- Correct settings (baud, 8N1/7E1, etc.)
- Boot banner printed early
- Logs are rate-limited and non-blocking under load
- Reset cause printed on boot
JTAG/SWD checklist
- SWDIO/SWCLK (or JTAG pins) wired correctly + solid GND
- Probe supports target voltage/reference expectations
- Lower debug clock if unstable
- Try connect-under-reset for “hostile” firmware
- Halt at reset/main and verify PC/SP look sane
- Use watchpoints for “who wrote this?” mysteries
- Keep a debug build (symbols, sensible optimization)
Logic analyzer checklist
- Analyzer ground connected (always)
- Inputs are safe for your logic level
- Choose sample rate + capture length intentionally
- Trigger on reset/CS/event pin
- Decode protocol and verify bytes/ACKs, not just edges
- Look for stuck lines (I2C SDA/SCL held low)
- Correlate firmware with an event pin toggle
Debug workflow checklist
- Reproduce reliably (or accelerate reproduction)
- Pick the fastest observation that reduces uncertainty
- Change one thing at a time
- Keep “before/after” evidence (log/backtrace/waveform)
- Verify in release-like conditions before declaring victory
- Document the root cause + prevention note
Wrap-up
Embedded debugging gets dramatically easier when you stop treating tools as separate “modes” and start treating them as layers of truth: UART logs for cheap state visibility, JTAG/SWD for CPU control and fault inspection, and logic analyzers for timing and bus reality. When you combine them—logs + debugger + waveforms—you turn “it sometimes fails” into a measured sequence you can fix.
What to do next (quick actions)
- Add a boot banner + reset-reason print to your firmware today
- Set up a reproducible debugger command sequence (connect, reset/halt, break at main)
- Reserve one GPIO as an “event pin” for logic analyzer correlation
- Save the Cheatsheet and treat it like your bring-up playbook
If you want to go deeper, the related posts below pair well: wiring/prototyping hygiene, protocol selection (UART/I2C/SPI), and a general debugging checklist that works across software and hardware.
Quiz
Quick self-check (demo). This quiz is auto-generated for hardware / iot / embedded.