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

CIB Guide Chapter 6 of 8

Logging

How library code logs without choosing a backend, how each call site carries a compile-time environment, and how the binary logger ships IDs instead of strings.

Headers
include/log/
Namespace
logging
Backends
null · fmt · binary
Links
stdx (+ fmt, msg, conc)
Wire format
MIPI Sys-T

How it fits together

A log call in cib is split in two. Library code says what happened through a macro; the application decides where it goes by specializing one variable template. Library headers compile identically whether the program ends up printing text, emitting binary packets, or discarding everything.

Library code Macro
CIB_INFO("byte {}", b) formats everything it can at compile time and gathers the call site’s environment.
One per program config
logging::config<> selects the backend. Unspecialized, the null logger accepts everything and emits nothing.
Runtime Backend
Gets the environment as a template parameter, plus file, line, and a format_result holding the string type and runtime args.
CALL SITE CIB_INFO("byte {}", b) module, level, unit, flavor… compile time Env + format_result str: "byte {}" args: (b) log<Env> logging::config <flavor of Env> BACKEND null nothing fmt 134us INFO [uart]: byte 42 binary 0x01030043 0x00000abc 0x0000002a
One call, three outcomes. The string never reaches the binary backend: it was turned into a catalog ID at build time, and only the ID and the runtime argument go on the wire.

Quick start

uart.hpp library code: no backend decision
#include <log/log.hpp>

namespace uart {
CIB_LOG_MODULE("uart");          // every log in this namespace reports module "uart"

inline auto send(std::uint8_t b) -> void {
    CIB_INFO("byte {}", b);
}
} // namespace uart
logging.hpp the application picks a backend, exactly once
#include <log_fmt/logger.hpp>
#include <iostream>
#include <iterator>

template <>
inline auto logging::config<> =
    logging::fmt::config{std::ostream_iterator<char>(std::cout)};
CMakeLists.txt
target_link_libraries(uart_lib PUBLIC cib_log)       # library: interface only
target_link_libraries(app      PUBLIC cib_log_fmt)   # app: the chosen backend

Macros & levels

MacroDoes
CIB_TRACE CIB_INFO CIB_WARN CIB_ERRORLog at that level.
CIB_LOG_WITH_LEVEL(L, …)Log at any level value, including one from your own enumeration.
CIB_LOG(…)Log at whatever level the environment already carries. Without one it doesn’t compile.
CIB_FATAL(msg, …)Log at FATAL, then call stdx::panic with the formatted message. Arguments beyond the format specifiers go to the panic handler.
CIB_ASSERT(expr, …)When expr is false, CIB_FATAL("Assertion failure: expr"). Nothing when true.
CIB_LOG_VERSION(), CIB_LOG_V(flavor)Log the build version (§9).

The levels are the MIPI Sys-T severities: MAX=0, FATAL=1, ERROR=2, WARN=3, INFO=4, USER1=5, USER2=6, TRACE=7. Nothing in cib filters by level — every call reaches the backend, which decides what to keep.

naming the user levels, keeping the type
struct app_level {
    using enum logging::level;
    constexpr static auto APP1 = USER1;
};
#define LOG_APP1(...) CIB_LOG_WITH_LEVEL(app_level::APP1 __VA_OPT__(, ) __VA_ARGS__)

// a wholly separate enum works too, but teach the fmt backend to print it:
namespace logging {
template <custom::level L>
constexpr auto format_as(level_wrapper<L>) -> std::string_view { /* … */ }
}
// or rename a stock level:
template <> constexpr std::string_view logging::level_text<MY_LEVEL> = "MY_LEVEL";

The environment

Every call site carries a compile-time map from query objects to values. The backend reads what it needs from it. Because the environment is a local typedef that the macros redefine, it follows ordinary scoping: namespace, class, function, block.

QueryDefaultSet with
get_levelnoneA leveled macro, or CIB_LOG_ENV(logging::get_level, …)
get_module"default"CIB_LOG_MODULE("uart")
get_flavordefault_flavor_tCIB_LOG_ENV(logging::get_flavor, stdx::type_identity<secure_t>{})
get_unit[]{ return 0; }CIB_LOG_ENV(logging::get_unit, []{ return id; }) — a runtime value
get_string_id-1 (assigned by the catalog)CIB_WITH_LOG_ENV(logging::get_string_id, 1337)
get_module_id-1CIB_WITH_LOG_ENV(logging::get_module_id, 6)
get_tagno_tag_tCIB_LOG_TAG(logging::tag_t<"k", "v">{})
binary::get_buildermipi::default_builder<>CIB_LOG_ENV(logging::binary::get_builder, my_builder{})
binary::get_writerthe config’s destinationsCIB_LOG_ENV(logging::binary::get_writer, my_writer{})
scoping (verified)
namespace uart {
CIB_LOG_MODULE("uart");                       // this namespace

struct rx {
    CIB_LOG_MODULE("uart.rx");                // this class

    static auto irq() -> void {
        CIB_LOG_MODULE("uart.rx.irq");        // this function
        CIB_INFO("overrun");                  // module: uart.rx.irq
    }
};

auto f() -> void {
    CIB_INFO("open");                         // module: uart
    CIB_WITH_LOG_ENV(logging::get_module, "probe") {
        CIB_INFO("probing");                  // module: probe
    }
    CIB_INFO("done");                         // module: uart again
}
} // namespace uart

Call-site bench

Pick what the environment carries and see what each backend produces. The MIPI field positions and the fmt line format are those the verification build emitted.

one log call · three backends

Message: CIB_INFO("byte {}", b) with a runtime b, or CIB_INFO("link up") with none.

Level

Module (scope)

Unit (runtime)

Message

fmt
binary
layout
null
(nothing)

What’s formatted when

Everything that can be folded into the string at compile time is. Only genuinely runtime values travel as arguments. This is what keeps binary log payloads small, and it means a constexpr value costs nothing extra to log.

CallString the backend seesRuntime args
CIB_INFO("The answer is: {}", 42)The answer is: 420
CIB_INFO("The answer is: {}", "42")The answer is: 420
CIB_INFO("{} is an {}", 42, int)42 is an int0
auto y = 42;
CIB_INFO("The answer is: {}", y)
The answer is: {}1
CIB_INFO("{} and {}", y, y){} and {}2

Types are formatted too, as their names. The backend receives a stdx::format_result: fr.str is a type carrying the string, fr.args a tuple of what’s left.

Backends

BackendLinkEmitsGood for
null (default)nothingLibraries built without a logging decision; stripping logs entirely
logging::fmt::configcib_log_fmtText, to any number of output iteratorsHost tests, dev boards, anything with a console
logging::binary::configcib_log_binaryMIPI Sys-T packets of IDs and packed argsConstrained targets: no string data in the image
your owncib_logWhatever you writeRTT, ITM, ring buffers, test doubles

fmt

verified output
     134us INFO [default]: hello 42

Microseconds since the logger’s first use, right-aligned in eight columns, then level, module, and the message, one line each. Construct the config with several destinations to fan out; each gets identical bytes. The repo’s tests assert that logging performs no dynamic allocation. Runtime enum arguments print as their underlying value unless you give them a format_as.

Your own

the whole customization point
struct my_config {
    struct {
        template <typename Env, typename File, typename Line, typename FR>
        auto log(File file, Line line, FR const &fr) -> void {
            constexpr auto level = logging::get_level(Env{});
            constexpr auto module = logging::get_module(Env{});
            constexpr auto fmtstr = std::string_view{decltype(fr.str)::value};
            fr.args.apply([&](auto const &...args) {
                ::fmt::print(fmtstr, args...);   // or write to RTT, a ring buffer…
            });
        }
    } logger;
};
template <> inline auto logging::config<> = my_config{};

Add an optional log_version<Env, BuildId, String>() member to handle version messages yourself.

The binary logger

On a constrained target, the strings are the expensive part of logging. The binary backend keeps each string’s characters in a type, asks a function template for that type’s ID, and sends only the ID and the runtime arguments. The image contains no log text at all.

Packets

MessageSent whenStorage
short32No runtime argumentsOne word: (string_id << 4) | 1
catalogWith runtime argumentsWords: header, string ID, packed args
compact32 / compact64 buildCIB_LOG_VERSION, empty version stringWords
long buildVersion string not emptyBytes
CATALOG HEADER (dword 0) subtype29:24 = 1 module_id22:16 (7 bits) unit15:12 (4 bits) severity6:4 type3:0 = 3 FOLLOWED BY string ID (dword 1) packed arguments (dword 2…)
Field positions verified against emitted packets. The module ID field is seven bits and the unit field four, which is what MODULE_ID_MAX exists to respect.

Runtime arguments must be logging::packable: integral, floating point or enum, at most eight bytes. Strings and pointers are rejected at compile time — format them at compile time, or send an ID. Each destination is written inside conc::call_in_critical_section, so freestanding targets must specialize conc::injected_policy<>.

The catalog build

catalog<Message>() and module<Module>() are declared but never defined. An application that logs therefore has undefined symbols whose mangled names contain the string data. The build turns those into IDs:

app_lib.a undefined catalog<…> nm -uC gen_str_catalog assigns IDs strings.cpp catalog<M>() { return 7; } strings.json / .xml collateral firmware.elf stub main + libs, LTO keep for decoding
The executable’s own translation unit contains no logging: main.cpp is a stub that calls into the library, so every string lives in a library the generator can scan.
CMakeLists.txt
add_library(app_lib lib.cpp)
target_link_libraries(app_lib PRIVATE cib_log_binary)

gen_str_catalog(
    OUTPUT_CPP  ${CMAKE_CURRENT_BINARY_DIR}/strings.cpp
    OUTPUT_JSON ${CMAKE_CURRENT_BINARY_DIR}/strings.json
    OUTPUT_XML  ${CMAKE_CURRENT_BINARY_DIR}/strings.xml
    INPUT_LIBS  app_lib
    STABLE_JSON stable_strings.json    # usually the previous build's strings.json
    OUTPUT_LIB  app_strings)

add_executable(app main.cpp)                    # stub: just calls into app_lib
target_link_libraries(app app_lib app_strings)
ArgumentMeaning
INPUT_LIBSRequired. Static or object libraries to scan for undefined symbols.
OUTPUT_CPP/JSON/XMLRequired. Generated specializations and collateral.
STABLE_JSONPrevious IDs to preserve. Feed last build’s strings.json.
FORGET_OLD_IDSIgnore STABLE_JSON and renumber.
RESERVED_IDSIDs to keep free, e.g. "1,1000-1005,1010".
MODULE_ID_MAXUpper bound for module IDs (the wire field is 7 bits).
INPUT_JSON, INPUT_HEADERSExtra JSON copied into the output; headers included by the generated C++ (needed for enum arguments).
CLIENT_NAME, VERSION, GUID_ID, GUID_MASKMIPI Sys-T XML fields.
OUTPUT_LIB, OUTPUTS_TARGETMake a static library of the generated C++, or a target that produces the collateral.

To pin one call site’s ID, put it in the environment: CIB_WITH_LOG_ENV(logging::get_string_id, 1337) { CIB_INFO("Hello"); }. The generator honors it. The same works for get_module_id.

Decoding

turning captured bytes back into text
python3 python/cib/log_decode.py --input log.bin --json strings.json

The decoder reads short32, catalog and short64 messages, and restores module names, argument values and enum names from the collateral. Archive strings.json with every image you ship, or its logs become unreadable.

Flavors: several backends at once

logging::config<> takes type arguments. A flavor in the environment selects which specialization a call site uses, so secure logs can go somewhere else entirely.

a second, separate channel
struct secure_t;

template <> inline auto logging::config<> =
    logging::fmt::config{std::ostream_iterator<char>(std::cout)};
template <> inline auto logging::config<secure_t> =
    logging::fmt::config{secure_file_output()};

#define SECURE_LOG_WITH_LEVEL(LEVEL, ...)                                      \
    logging::log<stdx::extend_env_t<cib_log_env_t, logging::get_level, LEVEL,  \
                                    logging::get_flavor,                       \
                                    stdx::type_identity<secure_t>{}>>(         \
        __FILE__, __LINE__, STDX_CT_FORMAT(__VA_ARGS__))
#define SECURE_INFO(...) SECURE_LOG_WITH_LEVEL(logging::level::INFO __VA_OPT__(, ) __VA_ARGS__)

Flavors are also how one test binary can drive several backends: the verification program runs a capturing logger, the fmt logger and the binary logger side by side.

Version logging

build identity in the log stream
struct my_version_config {
    constexpr static auto build_id = std::uint64_t{0x1234};
    constexpr static auto version_string = stdx::ct_string{"v1.2.3"};
};
template <> inline auto version::config<> = my_version_config{};

CIB_LOG_VERSION();      // default flavor
CIB_LOG_V(secure_t);    // a specific flavor

If the backend provides log_version<Env, BuildId, String>(), that is called; the binary backend builds a compact32 message (build ID up to 22 bits, empty string), a compact64 (up to 54 bits) or the byte-based long message that carries the string. Otherwise the message is logged as ordinary text at level MAX; the verification build produced Version: 4660 (v1.2.3), with the build ID in decimal.

Working with the rest of cib

LibraryCouplingWhat passes between them
flowuses Named flows log flow.start, flow.action, flow.milestone, flow.end at TRACE. Levels come from flow::log_env; flow::log_policies::none silences a flow.
interruptvia flow ISR flows log inside the ISR. Silence them, or give them a flavor whose destination is a ring buffer.
cib::topuses Logs its phase transitions with CIB_INFO.
conclink The binary writer takes a critical section per destination.
msglink MIPI messages are msg field definitions; a custom binary format is just another message type.
stdxlink ct_format and ct_string for compile-time formatting, env for the environment, panic for CIB_FATAL.
CMake / pythontooling gen_str_catalog plus log_decode.py and mipi_messages.py.

Use cases

Asserting on logs in tests

a capturing test double
struct capture_handler {
    template <typename Env, typename File, typename Line, typename FR>
    auto log(File, Line, FR const &fr) -> void {
        caps.push_back({std::string_view{decltype(fr.str)::value},
                        std::string_view{logging::get_module(Env{})},
                        logging::get_level(Env{})});
    }
};
struct capture_config { capture_handler logger; };
template <> inline auto logging::config<> = capture_config{};

Simpler still for text assertions: point the fmt backend at a std::string with std::back_inserter and search it.

A module per subsystem

Put CIB_LOG_MODULE("uart") at the top of the subsystem’s namespace. Every log inside inherits it, the decoder groups by it, and no call site has to repeat itself.

Telling instances apart

Module is compile-time. For “which of the four SPI controllers is this”, use the unit query, which takes a callable evaluated at log time: CIB_LOG_ENV(logging::get_unit, [] { return my_unit; });. It rides in four bits of the packet.

Logging from an ISR

Prefer the binary backend: no formatting and no allocation at runtime, and one short critical section per destination. Keep arguments packable. If the destination can block, give ISR logs their own flavor writing into a lock-free buffer that a lower-priority flow drains.

Keeping IDs stable across releases

Commit each build’s strings.json and feed it back as STABLE_JSON. Unchanged strings keep their IDs, so an old decoder still reads new logs. Use RESERVED_IDS to protect ranges that other tools assign.

Two channels, different destinations

Flavors (§8): normal logs to the console, secure logs to encrypted storage, with separate macros so a call site can’t pick the wrong one by accident.

Diagnostics

You’ll seeCauseFix
no matching function for call to object of type 'const struct get_level_t'CIB_LOG or a custom macro with no level in the environmentUse a leveled macro, or add CIB_LOG_ENV(logging::get_level, …)
no matching function for call to object of type 'your destination'The destination can’t accept the span the builder produced (usually the byte-based version message)Add the missing operator() overload
constraints not satisfied for alias template 'encode_as_t' [with T = const char *]An unpackable runtime argument to the binary loggerFormat it at compile time, or send an ID
Writer cannot write the log message built by the builderCustom builder and writer disagreeMake the writer accept the builder’s message type
undefined reference to catalog<…>Expected before catalog generationRun gen_str_catalog and link the generated library

Pitfalls

SymptomCauseFix
Logs from one library vanish, or the program behaves oddlyTwo translation units saw different logging::config<> specializations (an ODR violation)One header, included by every logging TU
Nothing is logged at allNo specialization: the null logger is in useSpecialize logging::config<> and link the backend
CIB_FATAL doesn’t stop anythingDefault panic handler is a no-opSpecialize stdx::panic_handler<>
Every decoded string is the sameThe catalog() primary template was defined in production codeOnly do that in host tests; let the generator specialize it
Decoded logs are garbage after a releaseThe image’s collateral doesn’t match the binaryArchive strings.json per build; use STABLE_JSON
Module names collide or overflowModule ID field is 7 bitsSet MODULE_ID_MAX and keep modules coarse
Unit value seems truncatedUnit field is 4 bits in the catalog messageKeep unit small; use module for coarser grouping
Binary build fails on a freestanding targetNo conc policySpecialize conc::injected_policy<>
Log line has no level text you recognizeCustom level enum without format_asProvide logging::format_as(level_wrapper<L>)