When text costs too much
You are debugging a motor controller running at 1kHz. The loop timing is tight. You add a single println!("Speed: {}", rpm) to track a jitter issue. Suddenly, the loop slows to 200Hz. The motor stalls. Or worse, you flash the binary and the linker screams about flash overflow. That one line of text formatting pulled in 12 kilobytes of string manipulation code. On a microcontroller with 64KB of total flash, you just burned 18% of your memory for a debug message.
Standard logging formats strings into human-readable ASCII. It converts numbers to characters, handles padding, and manages encodings. Embedded devices lack the CPU cycles and memory for that overhead. They need logs that are tiny, instant, and readable only when you actually need to inspect them.
defmt solves this by replacing text logging with binary logging. The name stands for "definitely formatted." The device emits compact binary data. Your host computer decodes it into text. The microcontroller never touches string formatting. It just sends numbers and flags.
Binary logging with a shared codebook
defmt works like a telegraph system with a shared codebook. Imagine you need to report weather data. A text message says "Temperature is 23 degrees Celsius." That is 32 characters. A binary code might send a single byte 0x17 if both sides agree that 0x17 means "Temp 23". The receiver looks up 0x17 in the codebook and reconstructs the full message.
defmt generates that codebook at compile time. It scans every defmt::info! macro in your code. It assigns a unique numeric ID to each distinct format string. It bakes a table into the binary that maps IDs to the format strings and type information. The binary also contains the logic to pack arguments into bytes.
At runtime, defmt::info!("Sensor: {}", val) expands to code that writes the ID for "Sensor: {}" followed by the raw bytes of val. No string conversion happens. No character encoding. Just raw data emission. The host tool reads the bytes, matches the ID to the format string in the table, and prints the result.
The device spends zero cycles on text. The binary size shrinks dramatically because the heavy formatting code lives only on the host, not on the device.
Setting up defmt
You need two crates: defmt for the macros and encoding, and a transport layer like defmt-rtt to send the bytes to the host. RTT (Real Time Transfer) uses a shared memory region between the debugger and the microcontroller. It works with most debug probes.
Add the dependencies to your Cargo.toml. Set opt-level = "s" in the release profile to optimize for size, which is standard practice for embedded Rust.
[dependencies]
defmt = "0.3"
defmt-rtt = "0.4"
[profile.release]
opt-level = "s"
The entry point initializes the RTT channel and emits a log. Use defmt::info! instead of println!. The explicit defmt:: prefix is the community convention. It prevents confusion if you ever bring in the log crate or other macros that re-export info!.
#![no_std]
#![no_main]
use defmt_rtt::Config;
use cortex_m_rt::entry;
use panic_probe as _;
/// Entry point for the Cortex-M application.
/// Initializes logging and enters the main loop.
#[entry]
fn main() -> ! {
// Initialize the RTT channel with default buffer sizes.
// This allocates a shared memory block visible to the debugger.
defmt_rtt::init(Config::default());
// Emit a log message. This compiles to a tiny binary write.
// No string formatting code runs on the device.
defmt::info!("Hello, embedded!");
loop {}
}
Flash the device. Run the decoder on your host. Watch the logs appear.
defmt decode -f /dev/ttyUSB0
The decoder reads the RTT buffer, matches the binary IDs to the format strings in the .elf file, and prints "Hello, embedded!" to your terminal.
How the bytes flow
Understanding the flow helps you debug when things go wrong. The process splits into compile time and runtime.
At compile time, defmt parses every format string. It generates a unique ID for each message pattern. It creates a global table mapping IDs to the format strings and the expected argument types. This table is embedded in the binary's read-only memory. The compiler also generates the packing code for each macro call. defmt::info!("Val: {}", x) becomes a function call that writes the ID and the bytes of x to the transport buffer.
At runtime, the device writes binary data to the RTT buffer. The RTT driver handles the low-level memory access. The host tool polls the buffer, extracts the bytes, and decodes them. The decoder uses the ID to find the format string. It unpacks the arguments according to the type information. It prints the formatted text.
Here is the critical trade-off. defmt logs are opaque without the binary. If you flash a device and lose the .elf file, the logs are just garbage bytes. You cannot recover the messages. Standard logging sends text, so you can read it with any terminal. defmt trades universal readability for extreme efficiency. You always need the build artifact to decode. Treat your binaries like logs. Archive them alongside your build outputs.
Logging custom types and panics
defmt shines when logging complex data. You can log structs, enums, and arrays efficiently. The device sends the raw fields, not a text representation.
Implement the Format trait for custom types. The #[derive(Format)] macro handles this automatically. It generates the packing code and registers the type in the codebook.
use defmt::Format;
/// Sensor reading with temperature and humidity.
/// Derive Format to enable efficient binary logging.
#[derive(Format)]
struct SensorReading {
temperature: i16,
humidity: u8,
}
/// Simulate reading from hardware.
fn read_sensor() -> SensorReading {
SensorReading {
temperature: 2300, // 23.00 degrees, scaled by 100
humidity: 65,
}
}
/// Main application loop.
fn main_loop() {
let reading = read_sensor();
// Log the struct directly. The device sends the ID for the struct
// followed by the raw bytes of the fields.
// This is much smaller than formatting the struct to text.
defmt::info!("Reading: {}", reading);
}
defmt also replaces panics. The panic-probe crate integrates with defmt to emit panic information as binary logs. When the device panics, the decoder prints the panic message, file, and line number. This gives you full debug context without adding panic formatting code to the device.
Use defmt::unwrap! and defmt::assert! for defmt-aware error handling. These macros panic with defmt-formatted messages. They include the value that failed the check in the log output.
/// Parse a sensor value and panic with context if it fails.
fn parse_value(raw: u8) -> i16 {
// defmt::unwrap! logs the raw value if the Option is None.
// This saves you from writing manual error handling code.
let parsed = Some(raw as i16).defmt::unwrap!("Invalid raw value");
parsed * 10
}
Derive Format for every custom type you log. The compiler enforces this. If you forget, the code won't compile.
Pitfalls and constraints
defmt has specific rules. Breaking them causes compile errors or runtime data loss.
If you try to log a custom struct without #[derive(Format)], the compiler rejects you with E0277 (trait bound not satisfied). The error message points to the defmt::Format trait. Add the derive macro to fix it.
defmt format strings are stricter than std. You cannot use dynamic width or precision like {:5.2}. The format must be known at compile time to generate the binary encoding. If you need variable formatting, compute the value first and log the result.
RTT buffers have a fixed size. If your device logs faster than the host can read, the buffer wraps around and overwrites old messages. You lose data. This is expected behavior for performance. If you need to capture everything, slow down the logging or increase the buffer size in Config.
The decoder requires the exact binary used to flash the device. If you rebuild the code, the IDs change. Old logs become undecodable. Always run the decoder against the current binary.
Respect the buffer size. Log fast, read faster.
Choosing your logging strategy
Pick the right tool based on your constraints.
Use defmt when you are targeting a no_std embedded device with limited flash and RAM. Use defmt when logging performance matters and string formatting would block your real-time loop. Use defmt when you need to decode logs on a host machine that shares the same binary build artifacts. Reach for log with a backend like serial when you are prototyping on a device with plenty of resources and want human-readable output directly from the device without a host decoder. Reach for println! only when you are debugging a std application or a quick no_std test where binary size is irrelevant.
Binary size wins. Use defmt.