Advanced RTT in Embedded Rust: A Guide to Multi-Channel Logging and Binary Streaming (Part 1)

Learn to set up multi-channel RTT on the Pi Pico: structured logging with defmt, debug prints, and host commands—all over a single SWD/JTAG probe.

Andre Leppik

Introduction

In our previous posts, we established a basic RTT setup and moved the RTT control block to a custom RAM region. In this two-part series, we’re upgrading to a multi-channel RTT setup on the Pi Pico (RP2040). By the end, you’ll have

  • Structured logging with defmt for compact, efficient logs.
  • Human-readable debug prints for quick, ad-hoc checks.
  • Binary data streaming to send raw sensor data or telemetry.
  • Host command handling to interact with your firmware in real time.

New to RTT? Make sure to check out our previous posts for context:

Adding defmt to the Project

The defmt is a logging framework built for embedded Rust environments where bandwidth and flash space are limited. Traditional logging expands format strings at runtime, producing large and slow serial output. defmt inverts that model:

  • Extracts format strings at compile time.
  • Encodes messages as tiny IDs and raw arguments.
  • Decodes messages on the host, reducing log bandwidth and firmware size.

Step 1: Add defmt Support

To get started, add defmt to your project and enable the defmt feature in rtt-target. Open your Cargo.toml and add the following dependencies:

[dependencies]
defmt = "1.0.1"
rtt-target = {version="0.6.2", features = ["defmt"]}

Step 2: Update Linker Arguments

Ensure your .cargo/config.toml includes the defmt.x linker script:

rustflags = [
    # ... other arguments
    "-C", "link-arg=-Tdefmt.x"
]

Step 3: (Optional) Add Timestamp Support

If you’re working with real-time systems, timestamps can be incredibly useful for debugging. For the RP2040, you can use the hardware timer to add timestamps to your logs:

defmt::timestamp!("{=u32:us}", {
    unsafe { (*pac::TIMER::ptr()).timelr().read().bits() }
});

This ensures every log entry includes timing information, critical for debugging real-time systems.

Pro Tip

The defmt provides an empty, weak timestamp function so that firmware builds without requiring timestamps; if you implement your own _defmt_timestamp, it overrides the default and enables timestamping on the host decoder.

Implementing Multi-Channel RTT

With dependencies ready, we will configure multiple RTT channels. Each channel serves a unique purpose:

  • Up channel 0 will output plain-text debug prints
  • Up channel 1 serves defmt logs
  • Up channel 2 streams binary data
  • Down channel 0 accepts incoming commands

This setup keeps data flows clean, avoids log corruption, and ensures that binary telemetry never mixes with human-readable logs.

Get ready...

Step 1: Initialize RTT Channels

To set up these channels, you’ll need to configure them in your main() function. Here’s how you can do it:

let channels = rtt_init! {
    up: {
        
        0: { size: 256,  mode: ChannelMode::NoBlockSkip, name: "terminal", section: ".rtt_data" },
        1: { size: 1024, mode: ChannelMode::NoBlockSkip, name: "defmt", section: ".rtt_data" },
        2: { size: 256,  mode: ChannelMode::NoBlockSkip, name: "binary", section: ".rtt_data" }
    }
    down: {
        0: { size: 16, name: "terminal", section: ".rtt_data" }
    }
    section_cb: ".rtt_data"
};

NoBlockSkip ensures logging never stalls the firmware, or in other words excess messages are dropped rather than blocking execution.

Step 2: Connect Channels to Loggers

With the channels initialized, the next step is to assign them to the appropriate loggers. For plain-text prints, use set_print_channel, and for defmt logs, use set_defmt_channel:

set_print_channel(channels.up.0);
set_defmt_channel(channels.up.1);

For binary streaming (which we’ll dive into in Part 2), we can initialize the channel like this:

let mut bin_out = channels.up.2;

This allows high-throughput raw byte streaming, ideal for sensor telemetry.

Step 3: Handle Host Commands

One of the most powerful features of RTT is the ability to send commands from your host to the firmware. This is incredibly useful for interactive debugging or running tests. Here’s how you can set it up:

let mut input = channels.down.0;
let mut buf = [0u8; 32];
let count = input.read(&mut buf[..]);
if count > 0 {
    let cmd = core::str::from_utf8(&buf[..count]).unwrap_or("").trim();
    match cmd {
        "version" => rprintln!("Firmware v1.2.3"),
        "meme"   => rprintln!("( ͡° ͜ʖ ͡°)"),
        _        => rprintln!("Unknown cmd: {}", cmd),
    }
}

With the simple command handler it enables interactive debugging and PC-driven tests.

Step 4: Add defmt Logs

Now that everything is set up, let’s add some defmt logs to see it in action. Here’s a simple loop that prints a counter every 500 milliseconds:

let mut i = 0;

    loop {
        delay.delay_ms(500);
        defmt::println!("Loop {}...\n", i);

        i += 1;
    }

Step 5: Verify defmt Symbols

Before we proceed, let’s verify that the defmt section is correctly included in your firmware. Run the following command to check:

$ arm-none-eabi-nm target/thumbv6m-none-eabi/debug/rust_rtt_example.elf | grep defmt
[10] .defmt            PROGBITS        00000000 00dfc0 00000c 00   R  0   0  4

Lets break down the output

  • [10] This is just the section index in the ELF section table
  • .defmt This is the name of the section. Good! This means that the metadata is present
  • PROGBITS This means the section contains raw data bytes
  • 00000000 Virtual Address (VMA). Which means the section is NOT part of any loadable region.

The other parts are File Offset, Size, Flags, Attributes, Link and Alignment info. Which is not that important at the moment. But the .defmt section looks completely correct, so lets continue.

Step 6: Add Dummy Binary Stream

While we’ll focus on binary streaming in Part 2, let’s send a couple of bytes to the binary channel to make sure it’s working:

let _ = bin_out.write(&[0xF0, 0x0D]);

Configuring VS Code for Multi-Channel RTT

To make the most of your multi-channel RTT setup, we would want to configure VS Code to display all the channels. Update your launch.json file to include the following rttConfig section:

"rttConfig": {
    "enabled": true,
    "address": "auto",
    "decoders": [
        {
            "label": "terminal",
            "port": 0,
            "type": "console",
            "timestamp": true
        },
        {
            "label": "defmt",
            "port": 1,
            "type": "console",
            "timestamp": false
        },
        {
            "label": "binary",
            "port": 2,
            "type": "binary",
            "timestamp": false,
        }
    ]
}

Now, fire up your debug session and watch the logs roll in.

Viewing Multi Channel Output

You should now see all three RTT terminals in your debug session. Not super useful yet? Don’t worry the binary data is just gibberish until we parse it.

A terminal view with all 3 RTT channels opened at the same time with gibberish data

What's Next?

We managed to build quite a good multi channel setup, but in Part 2, we will:

  • Stream the RP2040’s internal temperature sensor over the binary stream channel we setup on Channel 2.
  • Decode the defmt logs into human readable text.
  • Build a Python app to parse and visualize the binary stream.

Stay tuned! (And maybe crab a coffee)

Need help with embedded systems development?

Whether you're building something new, fixing stability issues, or automating what slows your team down — we can help.