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.
Quick start
#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));
}; 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
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}>; 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.| Alias | Effect |
|---|---|
with_default<V> | Default on construction; still writable |
with_const_default<V> | Default, not writable |
without_default | Writable, 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
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:
auto const [len, id, split, status] = m; // canonical order, not declaration order Owning and views
| Type | Holds | Constructed from |
|---|---|---|
owning<Defn> | std::array | Field 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
| Combinator | Does |
|---|---|
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
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:
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
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
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:
- Walk each callback’s matcher; every positive
equal_toterm on an indexed field adds that callback’s index under that value. - Any callback not mentioned under any value goes into the field’s default bitset.
- 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.
- 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:
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.
Request-response
msg/send.hpp pairs an outgoing action with the reply that answers it, as one async sender.
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
| Library | Coupling | What passes between them |
|---|---|---|
| match | link | Field matchers are match matchers. Simplification, negation, implication and sum-of-products come from there. |
| lookup | link | Each index picks a lookup strategy at compile time: linear scan, direct array, or a “bad but fast” perfect hash. |
| nexus | services | Services are cib services; callbacks are added with cib::extend, and can be wrapped in runtime conditions. |
| log | link | Match and mismatch logging, with the matcher’s own description. |
| async | send | msg::send / then_receive build senders over trigger schedulers. |
| callback service | simpler | 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
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:
| Message | Cause |
|---|---|
Message contains fields with duplicate names | Two fields share a name |
All fields must be initialized or defaulted | A 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 storage | Wrong 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 message | Bad rename_field |
No call option for call_with_message | Callback signature matches none of the supported forms |
Pitfalls
| Symptom | Cause | Fix |
|---|---|---|
| “Can’t change a field with a required value” | Trying to write a pinned field | Use a plain field plus a separate matcher |
| Matching raw data stopped working after composing | pack moved the fields | Define matchers after packing; use field_t<"name"> |
| Garbage field values | A view outlived its buffer, or points at the wrong bytes | Keep the storage alive; views don’t own or validate |
| Destructuring gives fields in a surprising order | Canonical (lsb) order, not declaration order | Name the bindings accordingly |
is_match true but nothing handled | A callback constrains an unindexed field | Index that field, or don’t rely on is_match |
| A callback never fires and nothing complains | Contradictory matcher | Enable never_matcher_validator |
| Silence at startup | Service used before init(); default panic handler does nothing | Install stdx::panic_handler<> |
| Huge compile times or memory | Indexing a wide-valued field | Index small discrete sets; leave the rest as residual matchers |