Embedded printf via ITM and SWO
Master the embedded “Hello World” — how to send debug messages from an ARM Cortex-M microcontroller to your PC without a display, using the ITM hardware unit, SWO pin, and the SWD debug interface.
The Core Problem: No Screen on a Microcontroller
When you learn to program a desktop computer, your very first program prints “Hello World” to a terminal. The output appears instantly on your screen. Easy.
On an embedded microcontroller, there is no terminal, no screen, and no operating system to write to. The chip just runs your firmware in a tight loop. So how do you know what is happening inside? How do you print a variable value? How do you know if your code reached a particular line?
This is one of the first real hurdles every embedded beginner faces, and it has several solutions. This lecture teaches you the most powerful and convenient one: routing printf through the ITM (Instrumentation Trace Macrocell) and out the SWO pin, straight to your IDE — no extra wires, no UART, no USB-to-serial adapter needed.
Without a debug output channel, embedded developers rely entirely on LEDs blinking at different rates, oscilloscope probing, and debugger step-through to understand what their code is doing. All of those are valid tools — but being able to print printf(“ADC value = %d\n”, adc_result); and see it live in your IDE is a massive productivity multiplier. Master this early and you will debug 10× faster than engineers who never set it up.
Four Ways to Get Debug Output — Compared
Before diving deep into ITM/SWO, let’s survey all the options. Each has different hardware requirements, code complexity, and real-time impact on the running application.
This lecture focuses on ITM/SWO — the professional’s choice for Cortex-M3/M4/M7 development. We will also show the UART approach since it is essential for Cortex-M0/M0+ boards that lack ITM hardware.
SWD — Serial Wire Debug Interface
Before understanding how printf gets to your PC, you need to understand the physical debug interface that carries all debug traffic. ARM designed SWD (Serial Wire Debug) specifically for the Cortex-M family as a minimal-pin replacement for JTAG.
+ SWD-DP debug port
+ ITM trace unit
Bidirectional data
Clock from host
Trace output (ITM)
Translates SWD to USB
Captures SWO trace data
shows debug &
trace output
What SWDIO and SWCLK Actually Do
SWDIO is a bidirectional data line. When the host (your PC, via the ST-LINK) is sending commands — read a memory address, set a breakpoint, halt the CPU — it drives SWDIO as output. When the target chip sends back data (register values, memory contents, acknowledgements), the chip drives SWDIO as output. One pin, both directions, time-multiplexed.
SWCLK is always driven by the host. Each rising edge clocks one bit of data on SWDIO. The default SWD clock speed for STM32 is typically set to 4 MHz in most debugger configurations, though higher speeds up to ~20 MHz are possible with quality probes and short wires.
Through just SWDIO and SWCLK you can: program the entire flash memory, read or write any memory address (including peripheral registers), set hardware breakpoints, halt and resume the CPU, read all 16 CPU registers, and trigger a system reset. This is the power of SWD — everything through two wires.
SWD vs JTAG — Full Comparison
JTAG (Joint Test Action Group) is an older standard (IEEE 1149.1) originally designed for board-level circuit testing, later adopted for chip programming and debugging. It uses 4–5 dedicated pins in a daisy-chain topology. ARM used JTAG for the ARM7 and ARM9 families before Cortex-M.
With Cortex-M, ARM introduced SWD as a streamlined alternative. Here is how they compare across every practical dimension:
| Feature | JTAG | SWD |
|---|---|---|
| Number of signals | 4 mandatory: TMS, TCK, TDI, TDO (+ optional TRST) |
2 mandatory: SWDIO, SWCLK (+ optional SWO for trace) |
| GPIO pins used | 4–5 GPIO pins dedicated to JTAG | 2 GPIO pins (PA13=SWDIO, PA14=SWCLK) |
| Multi-device chain | Yes — multiple chips in a JTAG chain | No — SWD is point-to-point only |
| Trace output | Requires separate trace pins (TRACED[3:0]) for ETM instruction trace | SWO pin carries ITM trace data (single-wire trace) |
| Programming flash | Yes | Yes |
| Breakpoints | Yes | Yes |
| Memory access | Yes | Yes |
| STM32 default | Available but not default | Default on all STM32 |
| Connector size | 20-pin or 10-pin JTAG connector | 10-pin Cortex Debug connector or 4-pin header |
| Best used when | Testing multiple chips on one PCB; need full ETM trace | Single-chip debug; want SWV trace (printf via SWO); pin-constrained design |
| Pin | Signal | Direction | Purpose |
|---|---|---|---|
| 1 | VCC (3.3V ref) | Output | Target voltage sense / reference |
| 2 | 3.3V | Output | Power supply to target (if needed) |
| 3 | GND | — | Ground reference |
| 4 | SWCLK | Host → Target | SWD clock |
| 5 | SWO | Target → Host | ITM trace output (Serial Wire Output) |
| 6 | SWDIO | Bidirectional | SWD data — debug commands & responses |
| 7 | GND | — | Ground |
| 8 | NC | — | Not connected |
| 9 | NC | — | Not connected |
| 10 | NRST | Host → Target | Hardware reset line |
ITM — Instrumentation Trace Macrocell
The ITM (Instrumentation Trace Macrocell) is a hardware block inside every ARM Cortex-M3, M4, and M7 processor. It is the engine that makes printf-over-SWO possible. Understanding how it works internally helps you use it correctly and avoid common pitfalls.
The C library formats the string: “value = 42”
ITM_STIM0 is memory-mapped at 0xE0000000. Writing a byte here enqueues it in the ITM FIFO.
The FIFO decouples your CPU from the SWO output rate. Your code continues running; the FIFO drains in background.
The Trace Port Interface Unit (TPIU) formats ITM packets and clocks them out the SWO pin, typically using Manchester or NRZ encoding.
The ST-LINK microcontroller on the Nucleo/Discovery board samples the SWO pin and bundles the data into USB packets sent to your PC.
The IDE receives the USB data, decodes the ITM packets, and displays the formatted string in the Serial Wire Viewer console — no terminal program needed.
ITM Stimulus Ports — 32 Independent Channels
The ITM provides 32 stimulus ports (port 0 through port 31). Each port is a separate FIFO queue, memory-mapped at a specific address. Port 0 is conventionally used for printf text output. Other ports can carry different types of information simultaneously:
| ITM Port | Address | Conventional Use |
|---|---|---|
| Port 0 | 0xE0000000 | printf / text debug output (standard use) |
| Port 1 | 0xE0000004 | Custom event logging (e.g. state machine transitions) |
| Port 2 | 0xE0000008 | RTOS task-switch events |
| Port 3–7 | 0xE000000C–0x1C | Custom application-specific channels |
| Port 8–15 | … | DWT-generated hardware trace packets (routed automatically) |
| Port 16–31 | … | Reserved / vendor-specific |
Writing to a stimulus port is as simple as a 32-bit memory write. The ITM hardware handles everything else — checking if the port is enabled, buffering in the FIFO, merging with other port traffic, and scheduling for output. You never need to manage timing or encoding yourself.
Complete Implementation: Retargeting printf to ITM
There are three distinct tasks:
- Enable the ITM hardware — configure TPIU clock, enable the ITM and stimulus port 0
- Retarget the C library _write() syscall — redirect stdout to ITM port 0
- Configure the IDE — tell STM32CubeIDE the SWO clock frequency and enable the SWV console
#include <stdint.h>
/* ── CoreDebug register (enables DWT and ITM) ── */
#define CoreDebug_DEMCR (*((volatile uint32_t *)0xE000EDFCU))
/* ── ITM registers ── */
#define ITM_TCR (*((volatile uint32_t *)0xE0000E80U)) /* Trace Control Reg */
#define ITM_TER (*((volatile uint32_t *)0xE0000E00U)) /* Trace Enable Reg */
#define ITM_TPR (*((volatile uint32_t *)0xE0000E40U)) /* Trace Privilege Reg */
#define ITM_LAR (*((volatile uint32_t *)0xE0000FB0U)) /* Lock Access Reg */
/* ── TPIU registers ── */
#define TPIU_SPPR (*((volatile uint32_t *)0xE00400F0U)) /* Selected Pin Protocol */
#define TPIU_ACPR (*((volatile uint32_t *)0xE0040010U)) /* Async Clock Prescaler */
#define TPIU_FFCR (*((volatile uint32_t *)0xE0040304U)) /* Formatter/Flush Ctrl */
/* ── ITM Stimulus Port 0 (byte-wide write for single characters) ── */
#define ITM_STIM0_BYTE (*((volatile uint8_t *)0xE0000000U))
#define ITM_STIM0_WORD (*((volatile uint32_t *)0xE0000000U))
void itm_init(uint32_t cpu_hz, uint32_t swo_hz)
{
/* Step A: Enable DWT and ITM via CoreDebug DEMCR */
CoreDebug_DEMCR |= (1U << 24); /* TRCENA bit — master trace enable */
/* Step B: Unlock ITM registers (write magic key to Lock Access Register) */
ITM_LAR = 0xC5ACCE55U;
/* Step C: Configure TPIU
SPPR = 2 → NRZ (UART) encoding on SWO pin (most compatible)
ACPR = (cpu_hz / swo_hz) - 1 → sets the SWO baud rate prescaler */
TPIU_SPPR = 2U;
TPIU_ACPR = (cpu_hz / swo_hz) - 1U;
TPIU_FFCR = 0U; /* Disable formatter (single-wire SWO mode) */
/* Step D: Enable ITM with:
- ATB ID = 1 (trace source identifier)
- Synchronisation packets enabled
- DWT stimulus enabled
- ITM enabled (bit 0) */
ITM_TCR = (1U << 16) | /* ATB ID = 1 (bits 22:16, set to 0x01) */
(1U << 4) | /* SWOENA: SWO enabled */
(1U << 3) | /* DWTENA: DWT packet forwarding enabled */
(1U << 2) | /* SYNCENA: synchronisation enabled */
(1U << 0); /* ITMENA: ITM master enable */
/* Step E: Enable stimulus port 0 (bit 0 of TER) */
ITM_TER = (1U << 0);
/* Step F: Allow unprivileged code to write to ports 0–7 (TPR bits 0–3) */
ITM_TPR = 0x0000000FU;
}
/* ── Send one character through ITM port 0 ── */
void itm_putchar(uint8_t ch)
{
/* Check bit 0 of ITM_STIM0: 1 = port ready, 0 = FIFO full (wait) */
while ((ITM_STIM0_WORD & 1U) == 0);
ITM_STIM0_BYTE = ch;
}
#include <stdint.h>
#include <errno.h>
/* This function is called by the C library for every write() / printf() call.
File descriptor 1 = stdout, 2 = stderr. We route both to ITM port 0.
Any other fd returns an error (no filesystem in bare-metal). */
extern void itm_putchar(uint8_t ch); /* defined in itm_init.c */
int _write(int fd, char *buf, int len)
{
if (fd == 1 || fd == 2) /* stdout or stderr */
{
for (int i = 0; i < len; i++)
{
itm_putchar((uint8_t)buf[i]);
}
return len;
}
errno = EBADF;
return -1;
}
#include <stdio.h>
#include <stdint.h>
extern void itm_init(uint32_t cpu_hz, uint32_t swo_hz);
int main(void)
{
/* System clock assumed 100 MHz (STM32F411 at max speed).
SWO baud rate 2 MHz — a safe value for most ST-LINK V2 hardware. */
itm_init(100000000U, 2000000U);
printf("=== STM32 ITM Printf Demo ===\n");
uint32_t counter = 0;
while (1)
{
counter++;
printf("Loop count : %lu\n", counter);
/* Simple busy-wait delay */
for (volatile uint32_t d = 0; d < 1000000U; d++);
}
}
=== STM32 ITM Printf Demo === Loop count : 1 Loop count : 2 Loop count : 3 Loop count : 4 ...
Using the Higher-Level STM32 HAL Version
If you are using STM32CubeIDE with HAL (Hardware Abstraction Layer), the setup is simpler — the generated startup code and linker scripts are already configured. You only need to add the syscall retarget and call the HAL initialisation, then configure the SWV in the IDE:
#include "main.h"
#include <stdio.h>
/* Add this to syscalls.c (already in the project — just add the _write body) */
int _write(int fd, char *ptr, int len)
{
for (int i = 0; i < len; i++)
{
/* ITM Stimulus Port 0 — wait until ready then write one byte */
while (!(ITM->PORT[0].u32 & 1));
ITM->PORT[0].u8 = (uint8_t)ptr[i];
}
return len;
}
/* In main() — after SystemClock_Config(): */
int main(void)
{
HAL_Init();
SystemClock_Config(); /* Generated by CubeMX */
/* Enable CoreDebug trace (required for ITM) */
CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk;
/* Unlock and enable ITM */
ITM->LAR = 0xC5ACCE55;
ITM->TCR = ITM_TCR_ITMENA_Msk | ITM_TCR_SWOENA_Msk |
ITM_TCR_SYNCENA_Msk | (1 << ITM_TCR_TraceBusID_Pos);
ITM->TER = 1; /* Enable stimulus port 0 */
printf("Hello from HAL + ITM!\n");
while (1)
{
printf("Tick: %lu ms\n", HAL_GetTick());
HAL_Delay(500);
}
}
Configuring STM32CubeIDE Serial Wire Viewer
The code alone is not enough — you must tell STM32CubeIDE the SWO clock frequency so it can decode the incoming bit stream correctly. Getting this wrong results in garbage characters or no output at all.
On STM32F407 and STM32F411 chips, the SWO function is on pin PB3 (alternate function 0). On the Discovery board, PB3 is already connected to the ST-LINK circuit internally — you do not need to wire anything. But on a custom PCB, you must route PB3 to your debug connector’s SWO pin. If PB3 is configured as a GPIO output by your code, SWV will not work.
Beyond printf: Advanced ITM Uses
Sending Binary Data (Not Just Text)
ITM stimulus ports accept 8-bit, 16-bit, or 32-bit writes. Writing 32 bits at once is more efficient than writing 4 bytes individually because the ITM hardware can pack them into a single trace packet, reducing SWO bandwidth.
#define ITM_STIM1_WORD (*((volatile uint32_t *)0xE0000004U))
/* Send a 32-bit value on ITM port 1 (e.g., raw ADC reading) */
void itm_send_u32(uint32_t value)
{
/* Check ITM stimulus port 1 ready bit */
while ((ITM_STIM1_WORD & 1U) == 0);
ITM_STIM1_WORD = value;
}
/* In application code */
void adc_complete_callback(uint32_t raw_value)
{
itm_send_u32(raw_value); /* 32-bit value on port 1 */
printf("ADC = %lu\n", raw_value); /* Text on port 0 */
}
Timestamped Event Logging
By combining ITM trace output with the DWT cycle counter (covered in Lecture 2), you can create timestamped event logs with microsecond accuracy — without any hardware logic analyser:
#include <stdio.h>
#include <stdint.h>
#define DWT_CYCCNT (*((volatile uint32_t *)0xE0001004U))
#define CPU_HZ 100000000U
/* Returns elapsed microseconds from a start timestamp */
static uint32_t elapsed_us(uint32_t start_cycles)
{
return (DWT_CYCCNT - start_cycles) / (CPU_HZ / 1000000U);
}
void process_sensor(void)
{
uint32_t t0 = DWT_CYCCNT;
read_i2c_sensor(); /* some blocking I2C read */
uint32_t read_time = elapsed_us(t0);
printf("[%lu us] I2C read complete\n", read_time);
process_data(); /* compute something */
uint32_t total_time = elapsed_us(t0);
printf("[%lu us] Processing done\n", total_time);
}
UART Printf — For Cortex-M0 / M0+ (No ITM)
Cortex-M0 and Cortex-M0+ processors do not include the ITM unit — it is optional in the ARM architecture, and chip vendors omit it to keep cost and power low. If you are working on a Cortex-M0-based chip (like the STM32F030 or the RP2040’s Cortex-M0+), you must use UART for printf output.
The technique is the same — retarget _write() — but route characters through a USART peripheral instead of ITM:
#include <stdint.h>
#include <errno.h>
/* USART2 registers (STM32F4 / STM32F0 base address 0x40004400) */
#define USART2_SR (*((volatile uint32_t *)0x40004400U))
#define USART2_DR (*((volatile uint32_t *)0x40004404U))
#define USART2_BRR (*((volatile uint32_t *)0x40004408U))
#define USART2_CR1 (*((volatile uint32_t *)0x4000440CU))
/* RCC: enable USART2 clock (APB1ENR bit 17) and GPIOA clock (AHB1ENR bit 0) */
#define RCC_APB1ENR (*((volatile uint32_t *)0x40023840U))
#define RCC_AHB1ENR (*((volatile uint32_t *)0x40023830U))
void usart2_init(uint32_t pclk_hz, uint32_t baud)
{
/* Enable clocks */
RCC_AHB1ENR |= (1U << 0); /* GPIOA clock */
RCC_APB1ENR |= (1U << 17); /* USART2 clock */
/* PA2 = USART2_TX: set to AF7, push-pull, medium speed */
volatile uint32_t *GPIOA_MODER = (volatile uint32_t *)0x40020000U;
volatile uint32_t *GPIOA_AFRL = (volatile uint32_t *)0x40020020U;
*GPIOA_MODER &= ~(3U << (2*2));
*GPIOA_MODER |= (2U << (2*2)); /* Alternate function mode */
*GPIOA_AFRL &= ~(0xFU << (2*4));
*GPIOA_AFRL |= (7U << (2*4)); /* AF7 = USART2 */
/* Set baud rate: BRR = pclk_hz / baud */
USART2_BRR = pclk_hz / baud;
/* Enable transmitter and USART */
USART2_CR1 = (1U << 3) | (1U << 13); /* TE bit | UE bit */
}
/* Send one character via USART2 */
static void usart2_putchar(char c)
{
while (!(USART2_SR & (1U << 7))); /* Wait for TXE (TX empty) */
USART2_DR = (uint32_t)c;
}
/* Retarget _write for printf */
int _write(int fd, char *buf, int len)
{
if (fd == 1 || fd == 2)
{
for (int i = 0; i < len; i++)
usart2_putchar(buf[i]);
return len;
}
errno = EBADF;
return -1;
}
/* main.c */
#include <stdio.h>
int main(void)
{
/* APB1 clock = 50 MHz on STM32F411 */
usart2_init(50000000U, 115200U);
printf("UART printf works!\n");
while (1)
{
printf("Running...\n");
for (volatile uint32_t d = 0; d < 500000U; d++);
}
}
Connect PA2 (USART2 TX) to a USB-to-UART adapter (CP2102, CH340, or FT232). On Linux: minicom -D /dev/ttyUSB0 -b 115200. On Windows: use PuTTY or Tera Term. On macOS: screen /dev/tty.usbserial-* 115200. The STM32 Nucleo boards also expose a Virtual COM Port over the ST-LINK USB — this appears as a COM port and connects to USART2 internally, so you can use UART printf without any extra hardware.
SWO Encoding: NRZ vs Manchester
The TPIU (Trace Port Interface Unit) can encode SWO data in two different line-coding schemes, configured via the TPIU_SPPR register:
| SPPR Value | Encoding | How It Works | When to Use |
|---|---|---|---|
| 1 | Manchester | Each bit is encoded as a transition: 0 = falling edge mid-bit, 1 = rising edge mid-bit. Self-clocking. | When probe firmware supports it. The transition encodes both clock and data in one wire — more robust at long cable runs. |
| 2 | NRZ (UART-like) | Standard asynchronous UART framing: idle high, start bit low, 8 data bits, no parity, stop bit high. Baud rate fixed by TPIU_ACPR. | Recommended for ST-LINK V2 — the most widely supported format. Set SPPR=2 for compatibility with STM32CubeIDE SWV. |
STM32CubeIDE’s SWV viewer works with both, but NRZ (SPPR=2) is more universally supported across different ST-LINK versions and IDE releases. Use Manchester (SPPR=1) only if your probe documentation specifically recommends it.
Complete System Data Path: printf to IDE
Writes to ITM_STIM0
FIFO buffers chars
NRZ encoding
Drives SWO pin
(PB3)
SWCLK
Reads memory / regs
Decodes NRZ frames
Buffers ITM packets
into USB HID packets
(Mini-USB)
Handles debug commands
Displays text output
“Loop count: 42\n”
Common Mistakes and How to Fix Them
Symptom: Garbage characters in the SWV console, or completely missing output.
Cause: The SWO baud rate configured in the IDE does not match the value written to TPIU_ACPR in your code.
Fix: Calculate TPIU_ACPR = (cpu_freq / swo_freq) – 1 and enter swo_freq exactly in the IDE debug configuration. Start with a conservative 2 MHz SWO frequency and increase only if needed.
Symptom: No output at all, even though ITM registers look correct.
Cause: The global trace enable bit (TRCENA, bit 24 of CoreDebug->DEMCR) was not set. Without TRCENA, all DWT and ITM hardware is powered down and ignores writes.
Fix: Always set CoreDebug->DEMCR |= (1 << 24) as the very first step in your ITM initialisation.
Symptom: SWV capture enabled but SWO pin reads constant HIGH or LOW noise.
Cause: Your code (or STM32CubeMX generated code) has configured PB3 as a GPIO output for some other purpose (e.g., driving an LED), overriding the SWO alternate function.
Fix: Do not use PB3 as a GPIO when SWD trace is needed. In CubeMX, check the Pinout view — PB3 should show “SYS_JTDO-SWO” or left unassigned (SWO function is active by default).
Symptom: The SWV console window is open but completely blank despite correct code.
Cause: You must explicitly click “Start Trace” (the red circle button) in the SWV ITM Data Console toolbar. The console does not auto-start capture.
Fix: After opening the SWV ITM Data Console, click “Configure trace”, enable port 0, then click the red circle. Do this before pressing Resume.
Symptom: printf in interrupt handlers causes unpredictable behaviour, output corruption, or system hang.
Cause: printf is not reentrant in newlib-nano. If an interrupt fires while the main code is inside printf and the ISR also calls printf, internal library state gets corrupted.
Fix: Never call printf directly in an ISR. Instead, write to a circular buffer inside the ISR and have the main loop drain and print the buffer. Alternatively, use direct itm_putchar() calls (which are reentrant) instead of printf in ISRs.
Frequently Asked Questions
Lecture 4 covers the complete Cortex-M register bank — R0 through R15, the two stack pointers MSP and PSP, the Link Register, Program Counter, and the xPSR. Understanding these registers is the foundation for writing interrupt handlers, context switches, and RTOS code.

1 Comment