consortium-dbg
defmt logging over shared memory: the CDBG ring, its firmware-side
global_logger, its host-side reader and decoder, and an optional panic handler.
The problem
A coprocessor on a Consortium SoC usually has no UART of its own to log through, or has one already spoken for. But it does share memory with a Linux-capable core that has a filesystem, a clock, and somewhere to put logs. So the debug path runs the same way as everything else in this workspace: firmware writes into a shared window, Linux reads it.
defmt is the right encoder for the firmware end. Format strings and type
metadata stay in the ELF and never reach the wire, so a log line costs a few bytes
and no formatting code — which is what makes logging affordable on a Cortex-M at
all. The cost is that the bytes are meaningless without the ELF they came from, so
the host side needs a decoder and the matching binary.
Architecture
firmware (no_std) Linux / host
──────────────────── ─────────────────────
defmt::info!(..)
└─ logger::ConsortiumLogger host::HostRing
(global_logger; rzcobs-encodes (validate CDBG, drain frames)
one frame, wraps it in a └─ host::DefmtSession
packet, appends to the ring) (decode against the ELF)
│ │
└──────── CDBG shared window ────────┘
descriptor + circular buffer
panic::(handler) ─ latches Flags::Panic in the descriptor
| Module | Role |
|---|---|
config |
The ring base/length ConsortiumLogger::init records (private). |
descriptor |
The CDBG descriptor and the single-writer/single-reader ring rules. |
packet |
Per-frame envelope inside the ring. |
logger |
Firmware #[global_logger]; encodes and appends. |
host |
Host-side validation, draining, and defmt decoding. |
panic |
Optional #[panic_handler] that records a crash in the ring. |
The descriptor header (CDBG magic + version) comes from
consortium-shared-memory, the same header consortium-ipc-transport-memory uses
for CIPC — so a misconfigured base address produces a clear "wrong structure"
error rather than garbage.
Descriptor discipline
The ring has exactly one writer (firmware) and one reader (host), and there is no lock. What keeps it correct is that each side only mutates the fields it owns:
| Field | Owner |
|---|---|
write_ptr, capacity, flags |
writer (firmware) |
read_ptr |
reader (host) |
write_ptr == read_ptr means empty. Preserve that split when changing anything
here — it is the entire synchronization story.
Frames, and why they are staged
The ring is a byte buffer, but a reader must recover frame boundaries: an encoded
defmt frame is not self-delimiting, and a host attaching partway through a
firmware run starts reading at an arbitrary offset. So each frame is wrapped in a
packet header:
[0..2] magic : u16 - 0xC0DE, deliberately not ASCII
[2] flag : u8 - FLAG_VALID | FLAG_CONSUMED
[3] len : u8 - payload bytes that follow
[4..] payload - one rzcobs-encoded defmt frame
The logger stages one complete frame before appending it, for two reasons: frames
must be rzcobs-encoded before storage (so the bytes defmt hands over are not the
bytes to store), and len sits in a header that precedes the payload (so the length
must be known first).
Consequences worth knowing:
lenis au8, so a frame carries at most 255 payload bytes. A frame that overruns the staging buffer is dropped with an overflow flag latched, never truncated — a partial frame would desynchronize the decoder for everything after it.MAGICis non-ASCII on purpose. A plausible misconfiguration points the region at something text-shaped; a non-ASCII magic makes that visible instead of accidentally matching.static mutstaging is sound becausedefmtguarantees theacquire/write/releasespan runs in a single, non-reentrant context. Everyunsafeblock inlogger.rscites that; preserve the assumption when editing.
Configuration
Layout comes from the manifest, not from firmware source. csti build lowers
[dbg.<core>] in Consortium.toml into the generated init(), which calls
ConsortiumLogger::init(base, len) first thing. Generated code is therefore the
single place the layout is written down; there are no CONSORTIUM_DBG_* build
variables.
Until that call the logger and the panic handler are no-ops, so a firmware crate built without generated code still links and runs; it just logs nothing.
There is no [dbg.ap]: the AP is always the host that decodes, never a producer. A
coprocessor with no [dbg.<core>] table simply has no debug ring.
Warm resets and panics
Debug SRAM often survives a reset, so firmware that just starts writing would resume
into a stale ring with cursors pointing at a previous run's frames.
ConsortiumLogger::init resets the descriptor to an empty ring; generated init()
calls it automatically when a [dbg.<core>] logger is present.
The optional panic handler disables interrupts, emits the panic message as an
ordinary defmt frame (so the host decodes it with no extra code), latches
Flags::Panic in the descriptor, then halts with the target's WFI. It never blocks
waiting for the host to drain. A double panic is guarded: if logging itself faults,
the second entry skips straight to halt rather than recursing. The Panic flag is
how a host tells a crash from a clean end-of-stream in retained memory.
Features
Defaults are host and logger.
| Feature | Effect |
|---|---|
logger |
Firmware #[global_logger] writing into the ring. |
host |
Host-side HostRing reader; implies std. |
decode |
Adds defmt-decoder/defmt-parser for DefmtSession; implies host. |
panic-handler |
Installs #[panic_handler]; implies logger. |
arch-cortex-m |
Interrupt-disable and halt for Cortex-M. |
arch-riscv |
Interrupt-disable and halt for RISC-V. |
panic-handler needs exactly one arch feature, and the two are mutually
exclusive. Both conditions are compile_error!s rather than link-time surprises.
decode re-exports defmt_decoder and defmt_parser, so a consumer such as
consortium-runtime-app can name Frame and Level without its own dependency on
either.
Testing
Host-side tests cover descriptor initialization, ring read/write behaviour, decoding fixtures, and the overflow/error flags — no target hardware needed.
License
Apache-2.0. See LICENSE.