Skip to content
michael.caisse.io michael.caisse.io

CIB Guide Chapter 4 of 8

Message

Describe a wire format once as fields and bits, then read it, write it, match it and dispatch it — with the matching work moved to compile time.

Headers
include/msg/
Namespace
msg
Builds on
match · lookup
Dispatch
plain · indexed
Storage
owning · views

The shape of it

A protocol description usually ends up spread across three places: a struct with bitfields, a parser that validates the header, and a switch statement that routes to handlers. msg replaces all three with one description in types, and derives the rest from it.

Bits Field
A name, a type, and where its bits live. Knows how to extract and insert, and may carry a matcher.
Layout Message
A named set of fields. Gives you an owning type and view types over someone else’s buffer.
Routing Service
Callbacks with matchers. Plain services test in turn; indexed services look up and intersect bitsets.
RAW 0x8000BA11 0x0042D00D view FIELDS id = 0x80 len = 0xBA11 extract INDEX LOOKUP id → {0,1} status → {1,2} intersect candidates {1} residual CALLBACK cb1(const_view) the matcher's remaining, unindexed terms are checked before the call
The indexed path. Everything to the left of "candidates" is two table lookups and a bitset AND; the tables were computed at compile time from the callbacks' matchers.

Quick start

cmd.hpp fields, a message, a callback, a service
#include <msg/callback.hpp>
#include <msg/field.hpp>
#include <msg/message.hpp>
#include <msg/service.hpp>

using namespace msg;

using id_f  = field<"id",  std::uint32_t>::located<at{0_dw, 31_msb, 24_lsb}>;
using len_f = field<"len", std::uint32_t>::located<at{0_dw, 15_msb,  0_lsb}>;

// the required value both defaults the field and matches incoming data
using cmd_defn = message<"cmd", id_f::with_required<0x80>, len_f>;
using cmd_msg  = owning<cmd_defn>;

constexpr auto on_cmd = callback<"on_cmd", cmd_defn>(
    [](const_view<cmd_defn> v) { do_something(v.get("len"_f)); });

struct cmd_service : service<const_view<cmd_defn>> {};

struct project {
    constexpr static auto config = cib::config(
        cib::exports<cmd_service>, cib::extend<cmd_service>(on_cmd));
};
main.cpp
cib::nexus<project> nexus{};
nexus.init();

// build one...
auto m = cmd_msg{"len"_field = 0x1234};

// ...or point at bytes that arrived from somewhere
auto const raw = std::array<std::uint32_t, 1>{0x8000'1234};
cib::service<cmd_service>->handle(const_view<cmd_defn>{raw});

The callback has no explicit matcher here, so it inherits the definition’s own: id == 0x80. Data with a different id is not delivered.

Fields

three ways to say where the bits are
using id_f    = field<"id", std::uint32_t>::located<at{0_dw, 31_msb, 24_lsb}>;  // dword, msb, lsb
using alt_f   = field<"alt", std::uint16_t>::located<at{55_msb, 52_lsb}>;       // raw bit positions
using byte_f  = field<"b", std::uint8_t>::located<at{2_bi, 7_msb, 0_lsb}>;       // byte index

// split across two places: the earlier `at` holds the more significant bits
using split_f = field<"split", std::uint32_t>::located<at{1_dw, 15_msb, 8_lsb},
                                                       at{1_dw,  7_msb, 0_lsb}>;
DWORD 0 id (31:24) unused (23:16) len (15:0) DWORD 1 unused (31:24) status (23:16) split hi (15:8) split lo (7:0)
The two split locations form one 16-bit value; writing 0xd00d puts 0xd0 in bits 15:8 and 0x0d in bits 7:0. Verified against the raw words.
AliasEffect
with_default<V>Default on construction; still writable
with_const_default<V>Default, not writable
without_defaultWritable, but must be given at construction
with_required<V>Const default and an equal_to matcher: the usual way to pin a message type
with_equal_to<V>, with_in<Vs...>, with_greater_than<V>, with_less_than<V>, with_predicate<P>, with_matcher<M>Attach a matcher without changing the default
with_new_name<"n">, with_new_type<U>, shifted_by<N, Unit>Derive a variant of the field

Each relational alias yields the matcher its name promises — with_less_than<V> gives msg::less_than_t<F, V>, with_greater_than_or_equal_to<V> gives msg::greater_than_or_equal_to_t<F, V>. For anything outside that set, with_matcher<M> takes a matcher directly.

A field is at most 64 bits. Asking for more bits than the type holds is Field size is smaller than sum of locations!; a single location wider than 64 bits is Individual field location size cannot exceed 64 bits!.

Messages

definition, then storage
using cmd_defn = message<"cmd", id_f::with_required<0x80>, len_f, status_f, split_f>;

cmd_msg m{"len"_field = 0xba11, "status"_field = 0x42, "split"_field = 0xd00d};
m.get("id"_field);        // 0x80, the required default
m.set("len"_field = 1);
m["status"_field] = 7;    // proxy assignment
m.data();                 // stdx::span over the words: {0x8000'0001, 0x0007'd00d}

Fields are ordered canonically by least significant bit, so message<"m", a, b> and message<"m", b, a> are the same type. That order is also what structured bindings follow:

#include <msg/message_destructure.hpp>
auto const [len, id, split, status] = m;   // canonical order, not declaration order

Owning and views

TypeHoldsConstructed from
owning<Defn>std::arrayField values, another array, or a view — always explicitly (it copies)
const_view<Defn>span<T const>Implicitly from an owning message, array or span
mutable_view<Defn>span<T>Explicitly, from mutable storage

Storage is customizable: cmd_defn::owner_t over a std::array<std::uint16_t, 4> works, and views can be over a different element type or an oversized buffer. Use as_const_view(), as_mutable_view() and as_owning() to move between them.

Composition

CombinatorDoes
extend<Defn, "n", Fields...>Adds fields; same-named fields override, which is how you pin a header value
overlay<"n", Defns...>Merges definitions over the same storage (fields may overlap on purpose)
pack<"n", AlignTo, Defns...>Concatenates definitions, each aligned to AlignTo
relaxed_message<"n", Fields...>Lets the compiler place unlocated fields: largest first, byte aligned, after the fixed ones
rename_field<Defn, "old", "new">Renames one field

Matchers

four ways to say the same thing
msg::equal_to<id_f, 0x80>              // a matcher value
id_f::with_equal_to<0x80>              // a field carrying that matcher
"id"_f == msg::constant<0x80>          // resolved by name against the definition
id_f::with_required<0x80>              // matcher + const default

Matchers compose with and, or and not, and the match library simplifies as it builds: negating equal_to gives not_equal_to, contradictions collapse to never, and implications between relational matchers prune redundant terms. Every matcher can describe itself, which is what makes dispatch logs readable:

verified
describe()        ->  id == 0x80
describe_match()  ->  id (0x81) == 0x80

A callback’s matcher is combined with the message definition’s own matcher_t (the conjunction of its fields’ matchers) and converted to sum-of-products form. That conversion is what lets the indexer treat each or term as a separate dispatch entry.

Dispatch

a plain service
struct cmd_service : msg::service<msg::const_view<cmd_defn>> {};

constexpr auto cb = msg::callback<"cb", cmd_defn>(
    "id"_f == msg::constant<0x80>,
    [](msg::const_view<cmd_defn> v) { … });

cib::service<cmd_service>->handle(view);   // true if some callback claimed it
cib::service<cmd_service>->is_match(view);

The callback’s parameter may be a const view, a mutable view, an owning message, or the raw range — whichever compiles. It may also take the nexus as an explicit template parameter, which lets a handler run other services.

Logging is built in: a match logs Incoming message matched [cb], because [...], executing callback at INFO; a message nothing claims logs None of the registered callbacks (N) claimed this message: at ERROR, followed by each callback’s reason (both verified). Handling before nexus::init() panics with Attempting to handle msg (cmd) before service is initialized.

Indexed services

the only change is the service type
using indices_t = msg::index_spec<id_f, status_f>;
struct cmd_service : msg::indexed_service<indices_t, cmd_msg> {};

At build time, each indexed field gets a map from value to a bitset of callback indices, plus a default bitset for values not in the map:

  1. Walk each callback’s matcher; every positive equal_to term on an indexed field adds that callback’s index under that value.
  2. Any callback not mentioned under any value goes into the field’s default bitset.
  3. Walk again for negated terms: the entry for that value gets the defaults minus this callback, and this callback is added to every other entry.
  4. Propagate the “positive” defaults (defaults minus the negated callbacks) into every entry.

At runtime: extract each indexed field, look up its bitset, intersect them all, then run each candidate’s residual matcher — the original with the indexed terms removed — and call the ones that pass.

A contradictory matcher (id == 0x80 and id == 0x81) silently never fires. Opt into catching it at compile time:

turn never-matching callbacks into errors
template <> inline auto msg::matcher_validator<> = msg::never_matcher_validator{};
// -> Indexed callback has matcher that is never matched!

Index bench

Four callbacks, indexed on id and status; len is deliberately left out of the index. The tables are built here with the same algorithm as the library, and the outcomes match the verification run.

indexed dispatch · simulated

Pick the incoming field values and watch the lookups, the intersection, and which callbacks actually run.

Incoming id

Incoming status

Incoming len (not indexed)

Callbacks

Index lookups

Request-response

msg/send.hpp pairs an outgoing action with the reply that answers it, as one async sender.

send, then wait for the callback that fires the trigger
auto s = msg::send([&](auto id) { transmit(id); }, 0x80)
       | msg::then_receive<"cb", msg_view_t>([&](auto v) { got(v.get("id"_f)); });
async::sync_wait(s);

// the receiving callback fires the trigger:
constexpr auto cb = msg::callback<"cb", msg_defn>(
    "id"_f == msg::constant<0x80>,
    [](msg_view_t v) { async::run_triggers<"cb">(v); });

Needs the baremetal senders & receivers library, which cib_msg already links.

Rest of cib

LibraryCouplingWhat passes between them
matchlink Field matchers are match matchers. Simplification, negation, implication and sum-of-products come from there.
lookuplink Each index picks a lookup strategy at compile time: linear scan, direct array, or a “bad but fast” perfect hash.
nexusservices Services are cib services; callbacks are added with cib::extend, and can be wrapped in runtime conditions.
loglink Match and mismatch logging, with the matcher’s own description.
asyncsend msg::send / then_receive build senders over trigger schedulers.
callback servicesimpler callback::service<Args...> calls every handler with no matching. Use it when there’s nothing to route on.

Use cases

A command set with a shared header

Define the header once, then extend it per command with a different with_required opcode. Each command definition then matches its own opcode, and one indexed service on the opcode field routes the lot.

Parsing bytes that arrived from a bus

Hand the service a const_view over the received buffer. Views convert implicitly from arrays and spans, so no copy happens, and the callbacks still see typed fields.

Building a message to send

Construct the owning type with the fields you care about; required fields fill themselves in. data() gives the words to push at the hardware.

Prototyping a format

Use relaxed_message while the layout is still in flux: name the fields and their types and let the compiler place them. Pin locations later without touching the callbacks.

Feature-gated handlers

Wrap cib::extend<service>(cb) in a cib::runtime_condition. The callback stays in the table but its matcher carries the predicate (verified on and off).

Testing a dispatcher

assert on the effect and the return value
cib::nexus<project> nexus{};
nexus.init();

called = false;
CHECK(cib::service<cmd_service>->handle(cmd_msg{"len"_field = 1}));
CHECK(called);

// and the miss path, including the log
CHECK(not cib::service<cmd_service>->handle(const_view<cmd_defn>{other}));

Diagnostics

Captured from clang 21 on the repo’s compile-fail tests:

MessageCause
Message contains fields with duplicate namesTwo fields share a name
All fields must be initialized or defaultedA field has no default and wasn’t set
Field does not belong to this message!get/set with an unknown name
Equality is not defined for messages: consider using equivalent() instead.== on messages
Can't change a field with a required value!set on a with_required field
Fields overflow message storage!Storage too small
Attempted to construct owning message with incompatible storageWrong storage element type
Field size is smaller than sum of locations!Field type too small for its bits
Individual field location size cannot exceed 64 bits!One location wider than 64 bits
Field location is outside the range of argument!extract/insert on a too-small integer
Named field not in message!"name"_f isn’t in the callback’s definition
Indexed callback has matcher that is never matched!Contradictory matcher, with the validator enabled
rename_field: New field name already exists in messageBad rename_field
No call option for call_with_messageCallback signature matches none of the supported forms

Pitfalls

SymptomCauseFix
“Can’t change a field with a required value”Trying to write a pinned fieldUse a plain field plus a separate matcher
Matching raw data stopped working after composingpack moved the fieldsDefine matchers after packing; use field_t<"name">
Garbage field valuesA view outlived its buffer, or points at the wrong bytesKeep the storage alive; views don’t own or validate
Destructuring gives fields in a surprising orderCanonical (lsb) order, not declaration orderName the bindings accordingly
is_match true but nothing handledA callback constrains an unindexed fieldIndex that field, or don’t rely on is_match
A callback never fires and nothing complainsContradictory matcherEnable never_matcher_validator
Silence at startupService used before init(); default panic handler does nothingInstall stdx::panic_handler<>
Huge compile times or memoryIndexing a wide-valued fieldIndex small discrete sets; leave the rest as residual matchers