Embracing Embedded Rust - from Serial.println to defmt
2025-02-20Having started out with the Arduino ecosystem, I usually want to reach for serial UART communication when I need debugging or logging from an embedded system:
Serial.begin(9600);
// later:
Serial.println("Doing a thing");
Many development boards expose a UART interface over their USB port, making this a straightforward way to get text between the host and the device.
When I started learning embedded Rust, I noticed this pattern was far less common. Instead, authors were reaching for a library called defmt. A minimal usage of defmt looks like this:
use defmt::*;
use defmt_rtt as _;
// later:
info!("Doing a thing");
I had a lot of questions about this. Why do we need to pull in an extra library, in addition to our
HAL crate, to get logging? Where’s the log text actually going, and how do we read it? Why are we
importing defmt_rtt
without using anything from it?
At first, this perceived complexity of defmt scared me away. After finally trying it in an example project, I was completely sold.
For anybody else on the fence, I’m going to cover the basics of defmt, and why it works great in the embedded Rust ecosystem.
What is defmt?
defmt was created by Ferrous Systems, a heavy contributor to the embedded Rust ecosystem, under their Knurling project.
Both a serial UART and defmt will end up displaying some text on your host, but the mechanism they use to build and transmit that text is very different.
A device using serial UART is typically expected to send complete ASCII bytes down the wire. If you need to interpolate variables into the message, then that is the device’s responsibility - possibly by formatting into some kind of fixed-size buffer, or concatenating heap-allocated strings.
defmt takes an entirely different approach. The “de” stands for “deferred”, meaning that message formatting happens on the host.
As a developer, we just write standard Rust string formatting:
info!("Task completed in {}ms", duration_ms);
On the device, defmt sends the individual pieces of this message - the string template to
use, and the duration_ms
value. The host is responsible for putting them together into a complete
log message. 1
How do I receive defmt messages?
Receiving defmt messages requires more setup than a typical serial UART. The host needs to be running a client that is capable of decoding defmt’s message format, and reconstructing the plain-text message.
Fortunately, the tooling around defmt is really good! The flashing and debugging tool probe-rs integrates with defmt, so immediately after uploading your program to your microcontroller, it’ll start showing a stream of defmt output.
There isn’t a single specific protocol which defmt uses under the hood. You select the “transport”
you like by importing a crate which provides a
global_logger
- or implement your own - and
defmt will automatically use that. 2
That’s why our code example from earlier has this seemingly-unused import. The
defmt_rtt
crate provides a global_logger
, and we
must reference the crate from our code to ensure it gets included in the build:
use defmt_rtt as _;
Specifically, this crate provides a transport based on the SEGGER Real Time Transfer (RTT) protocol. This is a good default choice for many ARM microcontrollers.
For the RTT transport, no additional configuration is needed - just include the crate and you’re ready to go. (You’ll never specify the wrong baud rate again!)
Can’t I just keep using serial UART?
You sure can - but if you’re coming from Arduino-land like me, there are a few points to be aware of.
Using serial UART feels like it should be easy, especially since all it takes on Arduino is a
quick setup call to Serial.begin
, and then you have access to Serial.println
everywhere.
The HAL crates within the embedded Rust ecosystem typically require a little more setup code to get a UART up-and-running. That’s not a problem, though - easy to grab from an example or stumble through IDE autocomplete.
Now you’ve got your UART peripheral, you want to emit log messages from two different components in
your software. However the
embedded_io::Write
trait takes
an exclusive &mut self
3, so you can’t share it… oh no, it’s 🦀 the borrow checker! 🦀
You’ll need to reach for some kind of synchronisation primitive, like a
hardware-specific Mutex
implementation
wrapping a RefCell
, so you can access
your UART from anywhere.
You really want your debugging and logging infrastructure to Just Work™, so by doing all this DIY setup, you risk building something brittle that gets in the way when you’re trying to solve an actual issue.
defmt’s logging macros are available everywhere, to use no-strings-attached, right out of the box - much
like println!
in a desktop Rust program. 4
You also need to think about how you’re going to build your log messages. Rust embedded projects
with #[no_std]
can’t allocate heap memory by default, so don’t have access to conveniences like
String
or the
format!
macro.
Crate are available which aim to provide allocation-free string formatting. The heapless
crate provides a fixed-length string
which implements Write
for formatting
capabilities. 5
With defmt’s namesake deferred formatting, you don’t need to think about this at all.
Are there any caveats with defmt?
Serial UART has a huge amount of library and OS support, with every major language offering a choice of cross-platform serial libraries. This gives you flexibility to write quick scripts to automatically gather and process data.
With defmt, you need support for both your transport and a defmt decoder. This isn’t a problem if you’re writing code in Rust, but you don’t necessarily have the freedom to grab your scripting language of choice.
You might also need external hardware to use the most popular transports. My go-to development board, the Raspberry Pi Pico, doesn’t have a debugger on-board, so I use a Raspberry Pi Debug Probe to connect with RTT.
Finally, defmt strictly deals with one-way communication, from the device to the host. If you want some kind of interactive console where the host can relay commands to the device, you might want to stick with serial UART for the lowest-friction approach.
-
In reality, defmt doesn’t even transmit the entire string template. The strings are interned, so an index into a string table is sent instead. ↩
-
global_logger
defines some linker symbols with specific names, using the unsafeno_mangle
attribute. defmt references these names without defining them. If you forget to include a transport crate, you’ll get a linker error - a rare occurrence in Rust! ↩ -
Some HAL crates might give you a writer which only needs
&self
, such as rp-hal. But this trait doesn’t fit into the wider embedded ecosystem, and may still need you to pass lifetimes around in your structs. ↩ -
I really value cases where languages break from their usual restrictions to give you easy ways to debug your program.
There’s a world where Rust might require you to pass some kind of&mut Console
around to print to stdout - but fortunately, that’s all abstracted away by theprintln!
macro.
One particular example I like is Haskell’sDebug.Trace
package. Use of I/O in Haskell is encoded in the type system as a monad, so printing from a deeply-nested function requires that you propagate extra type information through your program.Debug.Trace
provides an escape hatch without this impact. ↩ -
Sometimes the UART peripheral might also implement Rust core’s
Write
-rp-hal
does. This saves the need for a buffer. ↩