Logging is one of the most important tools in any embedded developer’s toolbox. When you’re working on a microcontroller, debugging often means staring at a serial console and hoping the right message appears before the system locks up. Unlike desktop systems, you don’t have a rich OS with debugging tools, crash dumps, or stack traces. If something goes wrong, the only trace you might have is a few bytes printed out over UART.
That’s why, when building MicrOS — a lightweight embedded operating system I’m developing for Cortex-M microcontrollers — I knew I needed a logging framework. Not just printf, but a system that’s:
- Configurable: set global and per-module verbosity.
- Readable: color-coded, structured messages with severity levels.
- Efficient: compile-time filtering so unused logs cost nothing.
- Extensible: ready for timestamps, runtime configuration, and multiple backends.
This article walks through the full design of the MicrOS logging system: from the simplest foundations to a colorful, flexible framework you can use on real hardware (or in QEMU). Along the way, I’ll show design trade-offs, implementation details, and real log output captured from the MicrOS samples.
1. Why Not Just printf?
The simplest way to get feedback from a microcontroller is:
printf("Hello, world!\n");
That works fine for tiny projects, but it doesn’t scale. Some problems with raw printf:
- No severity levels: you can’t distinguish debug noise from a fatal error.
- No filtering: everything prints, even if you only care about errors.
- Runtime overhead: every
printfcall consumes flash, cycles, and UART bandwidth. - Poor readability: long messages quickly become a wall of text.
On an embedded OS, especially one that runs multiple modules (kernel, scheduler, drivers, app code), you need something better. You need structured, filterable logs.
2. Step One: Defining Log Levels
At the core of MicrOS logging is a simple enum:
typedef enum {
MICROS_LOG_LEVEL_NONE = 0,
MICROS_LOG_LEVEL_ERROR = 1,
MICROS_LOG_LEVEL_WARN = 2,
MICROS_LOG_LEVEL_INFO = 3,
MICROS_LOG_LEVEL_DEBUG = 4
} micros_log_level_t;
This gives us a hierarchy:
NONE: silent.ERROR: only critical problems.WARN: problems that don’t halt the system.INFO: normal operational messages.DEBUG: detailed tracing for development.
At build time, you select a global ceiling:
#define CONFIG_MICROS_LOG_LEVEL MICROS_LOG_LEVEL_DEBUG
This ensures that higher-verbosity logs (DEBUG, INFO) can be completely compiled out if you don’t need them.
3. Module Registration
Each module (source file) needs to “introduce itself” to the logging system. For that, we use:
#define MICROS_LOG_REGISTER(module_name, level) \
static const char* _micros_log_module_name = #module_name; \
static const micros_log_level_t _micros_log_level = level
For example, in the MicrOS boot code:
MICROS_LOG_REGISTER(boot, MICROS_LOG_LEVEL_INFO);
This means:
- Logs from this file will be tagged with
boot. - The default verbosity for this file is
INFO.
Another module could set itself to DEBUG if it’s more chatty.
4. The Logging Function
All log macros eventually funnel into _log:
void _log(micros_log_level_t level,
const char* module_name,
const char* file,
int line,
const char* fmt,
...);
This function is responsible for:
- Formatting the message.
- Adding severity tags and module names.
- Coloring the output.
- Writing to
stdoutorstderr.
Here’s a simplified version of the implementation:
#define MICROS_LOG_ESCAPE_CODE_COLOR_RED "\033[31m"
#define MICROS_LOG_ESCAPE_CODE_COLOR_GREEN "\033[32m"
#define MICROS_LOG_ESCAPE_CODE_COLOR_YELLOW "\033[33m"
#define MICROS_LOG_ESCAPE_CODE_COLOR_BLUE "\033[34m"
#define MICROS_LOG_ESCAPE_CODE_COLOR_RESET "\033[0m"
#define MICROS_LOG_LEVEL_STRING_ERROR "E"
#define MICROS_LOG_LEVEL_STRING_WARN "W"
#define MICROS_LOG_LEVEL_STRING_INFO "I"
#define MICROS_LOG_LEVEL_STRING_DEBUG "D"
void _log(micros_log_level_t level,
const char* module_name,
const char* file,
int line,
const char* fmt,
...) {
const char* level_str = "";
const char* color_escape_str = "";
FILE* output = (level == MICROS_LOG_LEVEL_ERROR) ? stderr : stdout;
switch (level) {
case MICROS_LOG_LEVEL_ERROR:
color_escape_str = MICROS_LOG_ESCAPE_CODE_COLOR_RED;
level_str = MICROS_LOG_LEVEL_STRING_ERROR;
break;
case MICROS_LOG_LEVEL_WARN:
color_escape_str = MICROS_LOG_ESCAPE_CODE_COLOR_YELLOW;
level_str = MICROS_LOG_LEVEL_STRING_WARN;
break;
case MICROS_LOG_LEVEL_INFO:
color_escape_str = MICROS_LOG_ESCAPE_CODE_COLOR_GREEN;
level_str = MICROS_LOG_LEVEL_STRING_INFO;
break;
case MICROS_LOG_LEVEL_DEBUG:
color_escape_str = MICROS_LOG_ESCAPE_CODE_COLOR_BLUE;
level_str = MICROS_LOG_LEVEL_STRING_DEBUG;
break;
default:
return; // Invalid log level
}
fprintf(output, color_escape_str);
if (level != MICROS_LOG_LEVEL_DEBUG) {
fprintf(output, "[%s] [%s] ", level_str, module_name);
} else {
fprintf(output, "[%s] [%s] --%s:%d-- ", level_str, module_name, file, line);
}
va_list args;
va_start(args, fmt);
vfprintf(output, fmt, args);
va_end(args);
fprintf(output, MICROS_LOG_ESCAPE_CODE_COLOR_RESET "\n");
}
5. Logging Macros
To make logging ergonomic, we define macros:
#if CONFIG_MICROS_LOG_LEVEL >= MICROS_LOG_LEVEL_DEBUG
#define D(...) do { \
if (_micros_log_level >= MICROS_LOG_LEVEL_DEBUG) { \
_log(MICROS_LOG_LEVEL_DEBUG, _micros_log_module_name, __FILE__, __LINE__, __VA_ARGS__); \
} \
} while (0)
#else
#define D(...) do {} while (0)
#endif
The same pattern applies to I(), W(), and E().
- The global level (
CONFIG_MICROS_LOG_LEVEL) controls which macros even exist. - The module level (
_micros_log_level) controls runtime filtering within that range.
This two-layer system gives you fine-grained control with zero overhead for disabled logs.
6. Real Output from QEMU
Here’s a real run of the init_functions sample in QEMU:
[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
- Blue debug logs show full
--file:line--context. - Green info logs are concise.
- Errors (red) and warnings (yellow) would stand out immediately.
Even in a wall of logs, your eye is drawn to the right severity.
7. Why File and Line for Debug?
One design decision: only DEBUG logs include the full --file:line-- prefix.
- Debug sessions demand pinpoint accuracy.
- Normal runtime logs should remain short and readable.
This keeps output lean, but detailed when you need it.
8. Future Extensions
The current MicrOS logging framework is simple and efficient, but there’s plenty of room to grow.
Runtime Log Level Control
Add a UART shell command:
log set <module> <level>
This would let you crank up debug logging on a single module without reflashing.
Multiple Backends
Support backends like:
- UART (default).
- SEGGER RTT (fast debugging).
- Ring buffer in RAM (post-mortem analysis).
A simple function pointer swap can choose the backend:
typedef void (*log_backend_fn)(const char* formatted);
Timestamps
Prefix logs with a tick counter:
[I] [main] [123 ms] System is up and running!
Structured Logs
Output JSON or CBOR for automated testing:
{
"level": "INFO",
"module": "boot",
"file": "boot.c",
"line": 120,
"time_ms": 456,
"message": "Main function returned with code 0"
}
9. Conclusion
Logging in an embedded OS isn’t a luxury — it’s your primary debugging tool. By designing MicrOS logging around levels, modules, compile-time filtering, and VT100 colors, we now have a system that’s:
- Readable: clear severity tags and colors.
- Configurable: global and per-module control.
- Efficient: disabled logs vanish at compile time.
- Extensible: ready for runtime tuning, timestamps, and structured output.
From a bare-bones enum to a colorful structured system, MicrOS logging has grown into a framework that makes debugging and development far smoother — whether on real hardware or inside QEMU.
And the best part: the whole thing is still small, simple, and easy to adapt.
👉 Want to dive deeper? The full MicrOS logging code is available in the project repository, along with examples like hello_world, init_functions, and the boot code.