Reference 06 — User Data Packing into the Graph¶
Status: draft, v1, 2026-05-03. How application-level data of any size — from a single boolean to a streaming GB/s feed — gets put into the graph and delivered with appropriate copy semantics. Audience: anyone designing the data layout for their application. See also: 02-graph-model.md §read-vs-write copy semantics; 03-addressing.md §address-shift slicing; 05-protocol-tlvs.md §VALUE.
The packing rules¶
The protocol does not impose a serialization layer. The user picks the packing that fits their data; the protocol just moves TLVs.
Data shape |
Recommended TLV form |
|---|---|
Single scalar (bool, u8, u16, u32, u64, i32, f32, f64) |
One |
Fixed-shape struct (e.g., |
One |
Self-describing record with named fields |
User-range structured TLV (PL=1) with |
Variable-length collection |
User-range structured TLV (PL=1) with homogeneous children |
Memory-mapped hardware register |
Single |
Large payload (anything where a single TLV is too big to ship) |
Address-shift slicing across |
Multiple coherent streams (camera + LIDAR) |
Separate vertices, common timestamp domain; subscriber joins by timestamp |
The key insight: the graph imposes no shape. An endpoint is a name attached to a memory view. The protocol does not preordain payload shape, sample layout, or chunking strategy.
Single boolean (or any single byte)¶
The minimal endpoint. A 1-byte payload, optionally with the timestamp prefix.
// Publisher — see the graph module (../modules/graph.md) and view module (../modules/views.md).
tr::graph::graph_t g;
tr::graph::vertex_handle_t led =
g.register_vertex(tr::graph::path_t("/dashboard/led"), tr::graph::role_t::STORED_VALUE);
// Build a fresh single-byte VALUE view.
bool led_on = true;
tr::view::segment_ptr_t seg = tr::view::heap_alloc(1);
seg->bytes[0] = static_cast<std::byte>(led_on ? 1 : 0);
tr::view::view_t value = tr::view::view_t::over(std::move(seg));
g.write(led, value);
On the wire (no trailer, default LL=0 u16 length):
01 00 01 00 01
^ ^ ^^^^^ ^
| | len=1 payload byte (0x01 = true)
| opt = 0 (no flags)
type = 0x01 VALUE
5 bytes total. With CRC-16 trailer (opt.CR=1, opt.CW=1), 7 bytes. With CRC-32, 9 bytes. With absolute TS + CRC-32 (opt.TS=1, opt.CR=1), 17 bytes.
Header overhead is 4 bytes in the default case (LL=0, payload ≤ 64 KiB), or 6 bytes when LL=1. Trailer overhead is paid per-TLV only when the corresponding opt flags are set, and adds 0 / 2 / 4 / 6 / 8 / 10 / 12 / 14 / 16 bytes depending on which combinations of TS, CR, TF, CW are selected. See 01-data-format.md §frame size summary for the full table.
Per-message overhead is the same whether the payload is 1 byte or 1 MiB; the cost amortizes immediately past the smallest payloads.
Casting trick for true zero-copy on the read side¶
If the publisher’s source data is already a bool somewhere in memory (e.g., a struct field, a stack local with stable address), the TLV’s view can point directly to it instead of being copied:
struct dashboard_state {
bool led_on; // single byte
std::uint8_t brightness;
// ...
};
extern dashboard_state g_dash;
// Borrow the live byte directly — no memcpy, no ownership transfer.
// The view is a (pointer, length=1) span into the live struct.
tr::view::view_t value = tr::view::view_t::over(
tr::view::borrow(std::span<std::byte>{
reinterpret_cast<std::byte*>(&g_dash.led_on), sizeof g_dash.led_on}));
g.write(led, value);
The published TLV reads the byte directly from g_dash.led_on at every fanout. If g_dash is updated between publish and subscriber-consume, the subscriber sees the new value. (Whether this is desired or a bug is application-dependent — usually you want a snapshot, in which case the explicit-copy form above is correct.)
For a multi-byte scalar (u32, f64) the same pattern applies; the protocol does not care about scalar size.
GPIO register (memory-mapped I/O as a vertex)¶
A hardware register is just memory at a fixed address. Wrap it in a view and it becomes a libtracer vertex with zero-copy reads.
constexpr std::uintptr_t GPIOA_IDR_ADDR = 0x40020010; // STM32F4 GPIOA input data register
tr::graph::graph_t g;
// Borrow the MMIO region directly — no allocation, no ownership, nothing to free.
// borrow() takes a std::span<std::byte>; for a register at a fixed address, point
// a std::byte* at that address. The view is a live window onto the register.
auto* idr = reinterpret_cast<std::byte*>(GPIOA_IDR_ADDR);
tr::view::view_t idr_view =
tr::view::view_t::over(tr::view::borrow(std::span<std::byte>{idr, sizeof(std::uint32_t)}));
// Expose GPIOA's input data register as a vertex whose stored view points at the
// live register; every read observes the register with no memory copy.
tr::graph::vertex_handle_t v_idr =
g.register_vertex(tr::graph::path_t("/gpio/A/IDR"), tr::graph::role_t::STORED_VALUE);
g.write(v_idr, idr_view);
Subscriber side¶
auto r = g.read(v_idr); // r is std::expected<rope_t, …>
std::span<const std::byte> bytes = r->only().bytes();
std::uint32_t idr_value;
std::memcpy(&idr_value, bytes.data(), sizeof idr_value); // first copy at the boundary
The TLV’s payload pointer points directly at 0x40020010. The subscriber chooses whether to copy into local memory (for a stable snapshot) or operate on the view directly.
Write side (single-copy, register-mapped)¶
// Standard fresh-VALUE helper.
tr::view::view_t value_u32(std::uint32_t x) {
tr::view::segment_ptr_t seg = tr::view::heap_alloc(4);
for (int i = 0; i < 4; ++i)
seg->bytes[i] = static_cast<std::byte>((x >> (8 * i)) & 0xFF);
return tr::view::view_t::over(std::move(seg));
}
tr::graph::vertex_handle_t v_bsrr =
g.register_vertex(tr::graph::path_t("/gpio/A/BSRR"), tr::graph::role_t::HANDLER);
g.write(v_bsrr, value_u32(1u << 5)); // set pin PA5
The vertex registered for /gpio/A/BSRR (Bit Set/Reset Register at 0x40020018) has a write-handler that copies the incoming TLV’s payload bytes into the register. One copy — from the TLV view into the register. This is the single-copy write semantic: the TLV is a view (no copies on the way in), but landing it in the register requires one write to *(volatile uint32_t *)0x40020018.
Why this matters¶
A single API substrate (g.read / g.write, see the graph module) covers:
Logical software-defined endpoints (sensor readings, control state).
Hardware-defined endpoints (GPIO registers, peripheral SFRs).
Remote endpoints (a register on another MCU, reached by its source route over CAN).
To a subscriber, all three look identical. Tooling like tracer-top enumerates the entire address space — software and hardware — through one walk.
Structured record with named fields¶
For self-describing data, define a user-range structured TLV (opt.PL=1) with NAME + value children. Pick a type code in 0x80–0xFF and document its layout for your project.
The reference impl models a structured node as a parent (POINT) TLV with opt.PL=1 carrying NAME + VALUE children; the encoder emits it under a user-range type code (0x80+). See the wire module.
struct imu_sample {
std::uint64_t ts_ns;
float accel_x, accel_y, accel_z;
float gyro_x, gyro_y, gyro_z;
};
// TLV builder helpers.
tr::wire::tlv_t name_tlv(std::string_view s) {
tr::wire::tlv_t t;
t.type = tr::wire::type_t::NAME;
t.payload = {reinterpret_cast<const std::byte*>(s.data()), s.size()};
return t;
}
tr::wire::tlv_t value_tlv(std::span<const std::byte> b) {
tr::wire::tlv_t t;
t.type = tr::wire::type_t::VALUE;
t.payload = b;
return t;
}
void publish_imu(const imu_sample& s, tr::graph::graph_t& g, tr::graph::vertex_handle_t imu) {
auto bytes = [](const auto& x) {
return std::span<const std::byte>{reinterpret_cast<const std::byte*>(&x), sizeof x};
};
// Parent record: POINT + opt.PL=1, self-describing NAME/VALUE children.
tr::wire::tlv_t rec;
rec.type = tr::wire::type_t::POINT;
rec.opt = tr::wire::opt_t{.pl = true};
rec.children = {
name_tlv("ts_ns"), value_tlv(bytes(s.ts_ns)),
name_tlv("accel"), value_tlv({reinterpret_cast<const std::byte*>(&s.accel_x), 3 * sizeof(float)}),
name_tlv("gyro"), value_tlv({reinterpret_cast<const std::byte*>(&s.gyro_x), 3 * sizeof(float)}),
};
// Encode to wire bytes, then publish as a view over those bytes.
std::vector<std::byte> wire = tr::wire::encode(rec);
tr::view::segment_ptr_t seg = tr::view::heap_alloc(wire.size());
std::memcpy(seg->bytes.data(), wire.data(), wire.size());
g.write(imu, tr::view::view_t::over(std::move(seg)));
}
A subscriber walks the children iteratively (per 01-data-format.md §iterative parsing) and extracts fields by NAME match. This is self-describing on the wire: if the IMU record gains a mag field later, old subscribers ignore it; new subscribers read it.
For a fixed-shape struct where schema evolution doesn’t matter and bytes are precious, pack the whole struct as one VALUE TLV instead:
// One opaque VALUE holding the packed struct.
tr::wire::tlv_t v = value_tlv({reinterpret_cast<const std::byte*>(&s), sizeof s});
std::vector<std::byte> wire = tr::wire::encode(v);
tr::view::segment_ptr_t seg = tr::view::heap_alloc(wire.size());
std::memcpy(seg->bytes.data(), wire.data(), wire.size());
g.write(imu, tr::view::view_t::over(std::move(seg)));
The trade-off is wire-format-versus-self-description, identical to the choice between Cap’n Proto (fixed schema) and JSON (named fields).
Streaming a high-speed ADC (1 GB/s)¶
The publisher slices the stream across enumerated child endpoints with shared timestamps. Each slice is independently routable; the receiver assembles or processes-as-stream per its QoS.
Publisher¶
constexpr std::size_t SLICE_SIZE = 4 * 1024; // 4 KiB per slice, fits one MTU on most LANs
// Slice vertices registered once at init (see §static path handles in 03-addressing).
extern std::vector<tr::graph::vertex_handle_t> adc_raw; // adc_raw[i] == /adc/raw[i]
void on_dma_complete(std::byte* adc_buf, std::size_t buf_len,
tr::graph::graph_t& g) {
std::size_t n_slices = buf_len / SLICE_SIZE;
for (std::size_t i = 0; i < n_slices; ++i) {
// Borrow directly into the DMA buffer — no memcpy, no ownership transfer.
tr::view::view_t slice = tr::view::view_t::over(
tr::view::borrow(std::span<std::byte>{adc_buf + i * SLICE_SIZE, SLICE_SIZE}));
g.write(adc_raw[i], slice);
}
}
Each g.write is a view-clone (a refcount bump on the DMA segment’s backend) and a router dispatch. No byte copies happen between the DMA buffer and the network’s egress. The only copy is in the transport layer when bytes leave the host (send system call into kernel buffer); for SHM or RDMA transports, even that copy disappears.
Subscriber (process-as-stream)¶
Register the subscription by writing a SUBSCRIBER record to the parent’s :subscribers[] field — a subtree subscription observes every indexed child (see 03-addressing.md §subtree subscriptions and the graph module):
// Subscribe with assemble=false (default) — receive each slice as it arrives.
// The SUBSCRIBER value names the local handler path /local/dsp-pipeline.
g.write(tr::graph::path_t("/adc/raw:subscribers[]"), subscriber_value); // subtree: every /adc/raw[i]
// In the dsp-pipeline handler, the delivered view borrows the producer's bytes.
void on_adc_slice(const tr::view::view_t& delivered) {
auto t = tr::wire::view_as_tlv(delivered); // zero-copy decode
std::span<const std::byte> bytes = t->payload; // spans borrow the buffer
process_adc_slice(bytes); // FIR filter, FFT, whatever
}
The subscriber processes 4 KiB at a time, never holding more than one slice’s worth of memory. Throughput is bounded by the DSP pipeline + transport, not by buffer allocation.
Subscriber (assemble for batch processing)¶
Set the assembly QoS atomically on the subscriber’s :settings object, then register the subscription. The address_shift.* / deadline_ns fields are the v1 QoS design (see 03-addressing.md §subscriber assembly policies):
// Atomic whole-object settings write (SETTINGS 0x0B): assemble=true,
// expected_count for 100 ms batches, deadline_ns=200 ms safety window.
g.write(tr::graph::path_t("/local/batch-handler:settings"), assemble_settings_value);
// Register the subtree subscription: observes every /adc/raw[i].
g.write(tr::graph::path_t("/adc/raw:subscribers[]"), subscriber_value);
The router buffers slices per timestamp group; once the group is complete (or deadline expires), it delivers one assembled TLV. This is the right shape for batch DSP that needs N-sample windows.
Transport choice for 1 GB/s¶
Transport |
Realistic max |
When to use |
|---|---|---|
|
5–20 GB/s intra-host |
Producer and consumer on same Linux box |
|
5–20 GB/s intra-host |
Same as SHM but with safety-cert backbone |
|
10–100 Gb/s inter-host |
InfiniBand / RoCE LAN with HPC NICs |
|
10 Gb/s realistic |
LAN with regular NICs |
|
~1 Mb/s |
Don’t even try for GB/s ADCs |
libtracer is a control plane that negotiates the data plane in the GB/s case: the SHM segment, the RDMA queue pair, the iceoryx2 service. The TLV ownership-transfer semantic is what makes the handoff zero-copy at the libtracer layer; the underlying transport then delivers without further library involvement.
High-speed camera + LIDAR with synchronization¶
Two independent streams, common timestamp domain, subscriber joins by timestamp.
Architecture¶
/camera/frame[0..N] ← 30 fps, 4 MiB per frame
/lidar/scan[0..M] ← 10 Hz, 100 KiB per scan
/sensor/clock ← PTP-synced clock vertex (optional)
Each producer publishes to its own vertex with its own slicing. Both producers use the same wall-clock-ns timestamp (typically PTP-synced via the host’s hardware clock).
Publisher: camera¶
extern std::vector<tr::graph::vertex_handle_t> camera_frame; // camera_frame[i] == /camera/frame[i]
void on_frame(std::byte* frame, std::size_t frame_len, std::uint64_t /*ts_ns*/,
tr::graph::graph_t& g) {
std::size_t slice_size = 64 * 1024;
std::size_t n = (frame_len + slice_size - 1) / slice_size;
for (std::size_t i = 0; i < n; ++i) {
std::size_t len = std::min(slice_size, frame_len - i * slice_size);
tr::view::view_t slice = tr::view::view_t::over(
tr::view::borrow(std::span<std::byte>{frame + i * slice_size, len}));
g.write(camera_frame[i], slice); // all slices share the frame's ts_ns domain
}
}
Publisher: LIDAR¶
void on_scan(std::byte* scan, std::size_t scan_len, std::uint64_t /*ts_ns*/,
tr::graph::graph_t& g, tr::graph::vertex_handle_t lidar_scan0) {
// Scan fits in one slice — borrow it directly.
tr::view::view_t t = tr::view::view_t::over(
tr::view::borrow(std::span<std::byte>{scan, scan_len}));
g.write(lidar_scan0, t); // lidar_scan0 == /lidar/scan[0]
}
Subscriber: temporal join¶
// Subscribe to both streams: a SUBSCRIBER record naming the local fusion handler,
// written to each parent's :subscribers[] field (subtree subscription).
g.write(tr::graph::path_t("/camera/frame:subscribers[]"), cam_subscriber_value); // every /camera/frame[i]
g.write(tr::graph::path_t("/lidar/scan:subscribers[]"), lidar_subscriber_value); // every /lidar/scan[i]
// In the fusion handler. The delivered view borrows the producer's bytes; a
// copy of the view_t (refcounted) keeps the segment alive while it sits in a buffer.
static frame_buffer_t pending_frame; // map: ts_ns → assembled frame
static scan_buffer_t pending_scan;
void on_camera_slice(const tr::view::view_t& delivered) {
std::uint64_t ts = slice_timestamp(delivered); // app reads the agreed timestamp
frame_buffer_add_slice(&pending_frame, ts, delivered); // stores a view_t copy
try_emit_pair(ts);
}
void on_lidar_scan(const tr::view::view_t& delivered) {
std::uint64_t ts = slice_timestamp(delivered);
scan_buffer_add(&pending_scan, ts, delivered);
try_emit_pair(ts);
}
void try_emit_pair(std::uint64_t ts) {
// Allow ±5 ms slack in matching (PTP-synced clocks, sensor exposure window)
std::uint64_t slack = 5 * 1000 * 1000;
auto frame = frame_buffer_take_complete_near(&pending_frame, ts, slack);
auto scan = scan_buffer_take_near(&pending_scan, ts, slack);
if (frame && scan) {
run_fusion(*frame, *scan); // views drop when the locals go out of scope
}
}
The subscriber owns the temporal-join policy (window size, slack tolerance, dropping behavior on missing partner). The protocol just delivers timestamped TLVs; synchronization is the application’s responsibility, libtracer makes the timestamps comparable.
Coherency note¶
For sub-microsecond synchronization (e.g., precise stereo-LIDAR fusion), use PTP-synced hardware clocks (STM32F7+, ESP32-S3 with HW PTP, Linux with PHC). For ~ms accuracy (most robotics), NTP-sync is sufficient. The protocol carries u64 nanoseconds and trusts the publisher’s clock; clock sync is a host-level concern.
“Synchronize the value of a variable” pattern¶
A common need: a configuration or state variable that lives in one process and should be reflected on every other interested process. libtracer’s read/write/subscribe primitives handle this directly, no separate “shared variable” type:
Define the variable as a vertex¶
// On the authoritative host: expose the variable as a STORED_VALUE vertex.
static std::int32_t target_rpm = 3000;
tr::graph::vertex_handle_t v_rpm =
g.register_vertex(tr::graph::path_t("/control/target_rpm"), tr::graph::role_t::STORED_VALUE);
// Borrow the live variable's bytes (zero-copy) and store the view.
g.write(v_rpm, tr::view::view_t::over(tr::view::borrow(
std::span<std::byte>{reinterpret_cast<std::byte*>(&target_rpm), sizeof target_rpm})));
Other hosts subscribe with transient_local durability¶
// On a consumer host: set durability=transient_local + history_keep_last=1 on the
// cached subscriber's :settings, then register the subtree subscription.
g.write(tr::graph::path_t("/local/cached/target_rpm:settings"), durability_settings_value);
g.write(tr::graph::path_t("/control/target_rpm:subscribers[]"), subscriber_value);
// Anytime the consumer wants the latest value:
tr::graph::vertex_handle_t cached =
g.register_vertex(tr::graph::path_t("/local/cached/target_rpm"), tr::graph::role_t::STORED_VALUE);
auto r = g.read(cached);
std::int32_t rpm;
std::memcpy(&rpm, r->only().bytes().data(), sizeof rpm);
The combination of:
Read of the local cached vertex = always returns the last-known-value, no network round-trip.
Subscription with
transient_local= late joiners get the current value, not just future updates.Write to
/control/target_rpm= updates the authoritative vertex, fans out to all subscribers, all caches converge.
…gives the “shared variable” semantic without any extra protocol surface. Updates are eventually consistent; ordering within a single subscription is preserved; concurrent writes from multiple authoritative hosts are last-write-wins by timestamp (no CRDT, no consensus — see 04-communication-flows.md §network partition).
Worked example progression¶
The same vertex/edge primitives across eight orders of magnitude of payload rate:
Application |
Payload |
Rate |
Wire bytes / sec |
TLV form |
|---|---|---|---|---|
Single boolean (LED on/off) |
1 byte |
1 Hz |
8 B/s |
1 VALUE TLV |
RC control input |
5 bytes |
100 Hz |
1.2 KB/s |
1 VALUE TLV |
GPIO register |
4 bytes |
poll |
n/a (read-only) |
view into MMIO |
IMU sample |
28 bytes |
1 kHz |
35 KB/s |
user-range record (PL=1) or packed VALUE |
1 KB sensor record |
1 KiB |
1 kHz |
1 MB/s |
user-range record (PL=1) of named fields |
4K camera stream |
8 MiB |
30 Hz |
240 MB/s |
address-shift |
Lidar + camera fusion |
varies |
10 Hz |
250 MB/s |
two vertex trees, ts-join |
1 GS/s ADC |
4 KiB slices |
244 kHz |
1 GB/s |
address-shift |
Continuous shared variable |
4 bytes |
1 Hz |
32 B/s |
VALUE + transient_local sub |
100 GB/s data plane |
varies |
varies |
100 GB/s |
libtracer = control plane only; data plane via RDMA / SHM |
There is no fundamental change to the API, the wire format, or the addressing scheme across this range. What changes is:
Slice size (chosen by the publisher to match transport MTU and processing granularity).
Transport module loaded (UART for RC, TCP for IMU, SHM for ADC, RDMA for HPC).
QoS settings (best-effort for high-rate, reliable for control, transient-local for shared state).
The protocol’s job is to be invariant under these knobs; the user’s job is to choose the knobs.
Same-substrate operations: mix, split, concat¶
A structured TLV (any type with opt.PL=1) can be manipulated structurally without touching bytes. These operations are useful in routers, transforms, and any code that aggregates / disassembles structured TLVs.
Concat: merge two structured TLVs of the same type¶
A structured tr::wire::tlv_t carries its members in a children vector, so these operations are expressed directly on that vector (see the wire module); the leaf payload spans keep borrowing their original buffers — no bytes are copied until you re-encode.
tr::wire::tlv_t a = /* … */; // SETTINGS {NAME "x", VALUE 1}
tr::wire::tlv_t b = /* … */; // SETTINGS {NAME "y", VALUE 2}
tr::wire::tlv_t merged = a; // same type/opt
merged.children.insert(merged.children.end(), b.children.begin(), b.children.end());
// merged is SETTINGS {NAME "x", VALUE 1, NAME "y", VALUE 2}
// The child payload spans still borrow a's and b's buffers.
Split: cut a structured TLV at child index K¶
tr::wire::tlv_t whole = /* … */; // structured TLV with K1+K2 children
tr::wire::tlv_t first = whole, rest = whole;
first.children.assign(whole.children.begin(), whole.children.begin() + K1);
rest.children.assign(whole.children.begin() + K1, whole.children.end());
// first holds the first K1 children; rest holds the remaining K2.
// Both keep borrowing whole's child payload buffers.
Mix: insert a child at position K¶
whole.children.insert(whole.children.begin() + K, new_child);
// The children vector is updated; entries after K shift by one.
// No bytes copied unless you re-encode.
Serialize¶
std::vector<std::byte> out = tr::wire::encode(merged);
// 'out' contains the canonical wire bytes for merged.
// The proof obligation from [02-graph-model.md] guarantees this is identical
// to the bytes that would result from constructing the same logical container
// from scratch.
The proof obligation is the contract that makes mix/split/concat safe to compose freely — no operation can produce a TLV whose serialization differs from its logical content.
What this section is NOT¶
A serialization framework. libtracer doesn’t replace Cap’n Proto / FlatBuffers / Protobuf for typed, schema-evolving, reflection-rich payloads. If you need that, embed those formats inside VALUE TLVs.
A compression layer. libtracer doesn’t compress; if your payload benefits from compression, do it in the publisher and document the encoding (NAME field “encoding” = “zstd-3” inside a structured (
PL=1) TLV).A type system. libtracer’s “TLV type” is a transport routing concern, not a data type. The user-range
0x80..0xFFis for protocol tagging, not for substituting a real schema language.
MCU-friendly publishing (zero-alloc, no snprintf)¶
Normative reference: ../spec/v1.md §3.1. See also: 03-addressing.md §static path handles; 04-communication-flows.md §the static-path write flow.
The examples earlier in this document write by handle after registering a path_t("/path/string") (see the graph module). On hosted platforms (Linux laptops, ESP32 with PSRAM and a relaxed code budget) this is fine. On a 16 KB Cortex-M0+ flashing telemetry from an ISR, it is unacceptable: snprintf alone is 2–6 KB of code, the parser walks the path string each call, and the segment allocator runs from interrupt context.
The MCU-friendly variant is to encode the PATH TLV at build time and pass a handle to the writer. Three orders of magnitude less per-write cost, and snprintf is no longer linked.
Recipe — single sensor, build-time path¶
// Parse-once handle: the PATH TLV is encoded a single time at registration and
// its bytes live for the node's lifetime. The path_t("...") ctor validates the
// literal (ADR-0054). A binding may additionally expose a consteval PATH encoder
// that emits the same bytes into .rodata.
tr::graph::graph_t g;
tr::graph::vertex_handle_t temp =
g.register_vertex(tr::graph::path_t("/sensor/temp"), tr::graph::role_t::STORED_VALUE);
void tim2_irq_handler() { // hard-real-time ISR
float t = read_thermistor_adc();
// Fresh f32 VALUE view.
tr::view::segment_ptr_t seg = tr::view::heap_alloc(4);
std::uint32_t bits;
std::memcpy(&bits, &t, 4);
for (int i = 0; i < 4; ++i)
seg->bytes[i] = static_cast<std::byte>((bits >> (8 * i)) & 0xFF);
// Single load + dispatch. ~0.4 µs at 100 MHz.
g.write(temp, tr::view::view_t::over(std::move(seg)));
TIM2->SR &= ~TIM_SR_UIF;
}
What the macro emits, verbatim, into .rodata:
06 40 12 00 ← outer PATH TLV: type=0x06, opt=PL=1 (0x40), length=18
02 00 06 00 73 65 6E 73 6F 72 ← NAME "sensor"
02 00 04 00 74 65 6D 70 ← NAME "temp"
22 bytes of flash. Zero RAM. Zero per-write allocation.
Recipe — N indexed slots, init-time registration¶
When the path includes a runtime-derived index (an address-shift slice number, a peer-id), encode once at init and reuse the handle:
constexpr std::size_t N_SLICES = 64;
// File scope — vertex handles are filled in at init.
static std::vector<tr::graph::vertex_handle_t> slice_vtx;
// Called once from main() before the DMA / capture loop starts.
void publisher_init(tr::graph::graph_t& g) {
slice_vtx.reserve(N_SLICES);
for (std::size_t i = 0; i < N_SLICES; ++i) {
// String work is ALLOWED here — init runs once and amortizes across the
// program lifetime. path_t::parse returns std::expected; deref on success.
auto p = tr::graph::path_t::parse("/adc/raw[" + std::to_string(i) + "]");
slice_vtx.push_back(g.register_vertex(*p, tr::graph::role_t::STREAM));
// Each register_vertex encodes exactly one long-lived PATH TLV.
}
}
// DMA-half-complete ISR — has to be fast and ISR-safe.
void dma_half_complete_irq(std::byte* bytes, std::size_t len, tr::graph::graph_t& g) {
std::size_t slice = (current_offset / SLICE_SIZE) % N_SLICES;
tr::view::view_t v = tr::view::view_t::over(
tr::view::borrow(std::span<std::byte>{bytes, len}));
g.write(slice_vtx[slice], v); // pointer load + dispatch; no string ops
}
The trade: a one-time RAM cost of ~1.6 KB (64 PATH TLVs averaging ~25 bytes each + bookkeeping) buys ISR-safe publishing of 64 distinct slot paths.
Recipe — indexed slot paths (and a non-implemented optimization)¶
The reference core writes each slice by the handle of its real indexed path /adc/raw[i]:
extern std::vector<tr::graph::vertex_handle_t> adc_raw; // adc_raw[i] == /adc/raw[i], registered at init
void dma_half_complete_irq(std::byte* bytes, std::size_t S, std::size_t n_slices_in_buf,
tr::graph::graph_t& g) {
for (std::size_t i = 0; i < n_slices_in_buf; ++i) {
tr::view::view_t v = tr::view::view_t::over(
tr::view::borrow(std::span<std::byte>{bytes + i * S, S}));
g.write(adc_raw[i], v);
}
}
For very large N (e.g., 4096 slices) where individual registration would burn RAM, a single-PATH-plus-index form — encoding the base /adc/raw once and supplying i at write time, expanding [i] into the dispatch key without allocating — would help. This is a permitted-but-not-implemented optimization (non-normative): the reference core has no separate indexed-handle write. It would be semantically equivalent to the real write to /adc/raw[i] shown above, and from the subscriber’s perspective the wire bytes are identical.
What this buys, concretely¶
For a representative Cortex-M4 RC-car build (1 transport, no GUI, 32 KB flash budget):
Variant |
Flash overhead |
RAM overhead |
Per-write cost |
ISR-safe? |
|---|---|---|---|---|
String-form |
+5 KB ( |
small heap alloc per write |
1–10 µs |
No |
Parse-once |
+bytes of the path |
none |
~0.4 µs |
Yes |
Init-registered handle |
+bytes of the path |
one PATH TLV per path |
~0.4 µs |
Yes |
Indexed slot paths (per- |
+bytes of each path |
one PATH TLV per slot |
~0.5 µs |
Yes |
When the string form is still the right answer¶
Configuration tools / CLIs (
tracer-top, REPLs) where path is a runtime user input — the user typed a string; parse it.Glue code on hosted platforms where the publisher runs at human-speed (a few writes per second from a worker thread). The string form is more readable; the cost is unmeasurable.
Tests, where the verbosity is welcome and code size is irrelevant.
The string-form entry point is implementation-defined and OPTIONAL (../spec/v1.md §3.1.4). A bare-metal build MAY omit it entirely; an ESP32 build with the IDE-companion module loaded will include it.
Don’t conflate this with serialization¶
The static-path optimization is purely about the address of a vertex, not about the value being written. The value TLV (VALUE, a user-range record, an MMIO view) is constructed per-write and follows the rules earlier in this document. What changed is only that the path side of (path, value) no longer requires runtime string work.