Building a Lightweight Logging Framework for MicrOS – Part 3: Compile-Time and Per-Module Filtering

In Part 2, we added colors, severity tags, and file/line information to MicrOS logs. Now we’re going to take it further: instead of just printing everything, we’ll give developers fine-grained control over what gets logged.

This is crucial in embedded systems, where every byte of flash and every CPU cycle counts. We want debug detail when we need it, but zero overhead when we don’t.


The Two Layers of Filtering

MicrOS logging supports two filters:

  1. Global filter
    • Controlled by CONFIG_MICROS_LOG_LEVEL.
    • Defines the absolute maximum verbosity compiled into the system.
    • Example: if you set it to INFO, then all D() logs vanish at compile time.
  2. Per-module filter
    • Defined by MICROS_LOG_REGISTER(module, level).
    • Lets each source file decide how chatty it should be.
    • Example: boot might default to INFO, while scheduler runs at DEBUG.

The effective log level is the intersection of both.


The Global Ceiling

At build time, you choose the global level:

#define CONFIG_MICROS_LOG_LEVEL MICROS_LOG_LEVEL_DEBUG

This controls which macros are compiled in. For example, if the global level is INFO, then the D() macro literally expands to nothing:

#if CONFIG_MICROS_LOG_LEVEL >= MICROS_LOG_LEVEL_DEBUG
#define D(...) _log(MICROS_LOG_LEVEL_DEBUG, _micros_log_module_name, __FILE__, __LINE__, __VA_ARGS__)
#else
#define D(...) do {} while (0)
#endif

So debug logs don’t even exist in the binary.


Registering a Module

Each source file declares itself like this:

MICROS_LOG_REGISTER(main, MICROS_LOG_LEVEL_INFO);

This expands to:

static const char* _micros_log_module_name = "main";
static const micros_log_level_t _micros_log_level = MICROS_LOG_LEVEL_INFO;

So now the macros know both which module is speaking and how chatty it’s allowed to be.


The Logging Macros

Here’s how an info log works:

#if CONFIG_MICROS_LOG_LEVEL >= MICROS_LOG_LEVEL_INFO
#define I(...) \
    do { \
        if (_micros_log_level >= MICROS_LOG_LEVEL_INFO) { \
            _log(MICROS_LOG_LEVEL_INFO, _micros_log_module_name, __FILE__, __LINE__, __VA_ARGS__); \
        } \
    } while (0)
#else
#define I(...) do {} while (0)
#endif
  • First, we check the global level (CONFIG_MICROS_LOG_LEVEL).
  • Then, at runtime, we check the module’s level (_micros_log_level).

This way, you can build with DEBUG globally but keep certain modules quieter by registering them with INFO or WARN.


Real Output: init_functions in QEMU

Here’s what it looks like when running the init_functions sample:

[D] [main] --/home/grzegorz/projects/micros/samples/init_functions/main.c:22-- System clock initialized - simulated
[D] [main] --/home/grzegorz/projects/micros/samples/init_functions/main.c:26-- UART initialized - simulated
[D] [main] --/home/grzegorz/projects/micros/samples/init_functions/main.c:31-- System initialized successfully - simulated
[D] [main] --/home/grzegorz/projects/micros/samples/init_functions/main.c:35-- Very early init function in section .init_array.50 - simulated
[I] [main] MicrOS Initialization Framework Example
[I] [main] System is up and running!
[I] [main] Main function is exiting now.
[I] [boot] Main function returned with code 0
[D] [main] --/home/grzegorz/projects/micros/samples/init_functions/main.c:39-- Very late fini function in section .fini_array - simulated
[I] [boot] System halted after main return

Notice:

  • main is set to DEBUG, so we see detailed traces with file/line.
  • boot is set to INFO, so only info-level logs are printed — no debug noise.

This is the power of per-module filtering: you see the details where you need them, but keep the rest of the system quiet.


Efficiency Matters

Why not just check levels at runtime? Because:

  • Logs above the global level are compiled out completely → zero code size.
  • The runtime check only applies inside enabled macros → minimal cost.
  • Disabled logs don’t even reach _log()no wasted formatting.

This makes the system suitable even for tiny Cortex-M0 devices with <32 KB of flash.


Where We Stand

At this point, MicrOS logging provides:

  • Severity levels with colors (from Part 2).
  • Global filtering to keep the binary lean.
  • Per-module filtering for fine-grained verbosity.
  • Minimal runtime overhead when logs are disabled.

This is already a professional-grade logging system for an embedded OS.


Coming Up Next

In Part 4, we’ll look at how to extend the framework:

  • Changing log levels at runtime (via UART shell or debug CLI).
  • Routing logs to different backends (UART, RTT, memory buffer, network).
  • Adding timestamps for precise sequencing.
  • Even exploring structured logs for automated testing.

That’s where logging goes from debugging tool to system-level instrumentation.


👉 Stay tuned for Part 4: Extending the Framework — where we make MicrOS logs dynamic, flexible, and even more powerful.

Grzegorz Grzęda
Grzegorz Grzęda

At G2Labs, Grzegorz Grzęda develops IoT and embedded platforms while building MicrOS in public and sharing hands-on tutorials.

Articles: 10

Newsletter Updates

Enter your email address below and subscribe to our newsletter