Zephyr Logging Subsystem: Structured Logs That Don't Tank Performance

Configuring structured logging in Zephyr RTOS without sacrificing real-time performance

A single printk in the wrong spot can blow a 500-microsecond deadline by 10x. It’s a blocking UART write. No filtering. No way to compile it out. And yet it’s the default debugging tool for most Zephyr projects, until something breaks in production. Then you realize you’ve got zero visibility into what happened, because all your debug output was either too noisy to leave on or too expensive to keep running.

Zephyr ships a full structured logging subsystem that solves every one of these problems. But it only works if you configure it with intention. Misconfigure it and you’ll trade one set of timing problems for another, just with fancier macros.

Here’s how to set it up right: deferred processing, per-module compile-time filtering, properly sized buffers, and a backend that won’t bottleneck your application.

How Zephyr Logging Actually Works

The logging subsystem has three layers, and understanding the flow saves you from most configuration mistakes:

┌──────────────┐      ┌──────────────────┐      ┌──────────────┐
│  Application │      │    Log Core       │      │   Backend    │
│              │      │                   │      │              │
│  LOG_INF()  ─┼─────>│  Filter ──> Queue ├─────>│  UART / RTT  │
│  LOG_ERR()   │      │                   │      │  Flash / etc │
│  LOG_DBG()   │      │  [logging thread  │      │              │
│              │      │   drains queue]   │      │              │
└──────────────┘      └──────────────────┘      └──────────────┘

Your code calls LOG_INF(). The log core decides whether that message passes the filter (both compile-time and runtime). If it does, the message gets handled according to the processing mode. Then one or more backends actually output it.

The processing mode is the critical architectural decision:

ModeCall-site CostOutput ThreadISR SafeFootprint
DeferredLow (enqueue args)Yes (separate)YesMedium
ImmediateHigh (format + TX)No (inline)RiskySmall
MinimalVery lowNo (inline)LimitedSmallest

Deferred mode is the right default for anything with real-time requirements. The log call copies arguments into a ring buffer, and a background thread formats and outputs them later at low priority. Your hot path stays fast. Immediate mode formats and transmits inline, which behaves a lot like the printk you’re trying to escape.

Setting Up Proper Module Logging

Step 1: Enable the subsystem in prj.conf

CONFIG_LOG=y
CONFIG_LOG_MODE_DEFERRED=y
CONFIG_LOG_BUFFER_SIZE=2048
CONFIG_LOG_PROCESS_THREAD_STACK_SIZE=1024

CONFIG_LOG_MODE_DEFERRED=y is technically the default in most board configs, but set it explicitly. Future you will appreciate the clarity.

Step 2: Register a log module in your source file

#include <zephyr/logging/log.h>
LOG_MODULE_REGISTER(my_sensor, CONFIG_MY_SENSOR_LOG_LEVEL);

That second argument is the compile-time ceiling. Everything above that level gets stripped by the compiler, removed from the binary entirely.

Define the Kconfig symbol so it’s tuneable per-build:

config MY_SENSOR_LOG_LEVEL
    int "Log level for my_sensor module"
    default 3
    range 0 4

Level 0 is off, 1 is errors, 2 is warnings, 3 is info, 4 is debug. Setting 3 as default means LOG_DBG calls in this module compile to nothing unless someone explicitly cranks it up.

If your module spans multiple files, use LOG_MODULE_DECLARE(my_sensor) in the additional source files instead of LOG_MODULE_REGISTER.

Step 3: Use the macros

LOG_ERR("Sensor read failed: %d", rc);
LOG_WRN("Temperature out of range: %d C", temp);
LOG_INF("Initialized with %d samples", sample_count);
LOG_DBG("Raw ADC value: 0x%04x", raw);

For binary data, there’s LOG_HEXDUMP_INF:

LOG_HEXDUMP_INF(packet_buf, packet_len, "RX packet");

Step 4: Add runtime filtering (optional)

If you’ve enabled the Zephyr shell, you can change log levels on a running device:

uart:~$ log enable inf my_sensor

This requires CONFIG_LOG_RUNTIME_FILTERING=y, which adds a small RAM cost per module (a few bytes of filter state). Worth it during development. Consider disabling it in release builds to reclaim that memory.

The Performance Practices That Actually Matter

The macros are easy. The configuration is where the money is.

Rule 1: Set compile-time log levels aggressively

LOG_MODULE_REGISTER(my_sensor, LOG_LEVEL_INF) means every LOG_DBG() call in that module is removed by the compiler. On a module with 30 debug log statements, that’s 30 format strings that never land in your .rodata section and 30 function calls that never execute. Use it.

Rule 2: Use deferred mode

In deferred mode, a LOG_INF() call copies a handful of arguments into a ring buffer. That’s maybe 100-200 nanoseconds on a Cortex-M4 at 64 MHz. The formatting and UART output happen later in the logging thread, which typically runs at the lowest cooperative priority.

In immediate mode, that same LOG_INF() call formats the string and pushes it out the UART inline. Depending on baud rate and string length, that’s hundreds of microseconds to several milliseconds. If your control loop budget is 1 ms, a single immediate-mode log can eat the whole thing.

Rule 3: Don’t pass transient pointers to %s

This one bites people hard. In deferred mode, the log call captures the pointer, not the string content. The actual dereference happens later when the logging thread processes the message. If that pointer was to a stack buffer or a temporary allocation, you’ll get garbage output or a hard fault.

/* DANGEROUS in deferred mode */
char tmp[32];
snprintf(tmp, sizeof(tmp), "state_%d", id);
LOG_INF("Current: %s", tmp);  /* tmp may be gone by the time this prints */

Fix: use static or global strings. Zephyr 3.x and later can automatically copy strings in some configurations, but don’t rely on it without checking your specific release notes. Keep your %s arguments pointing to string literals or static buffers.

Rule 4: Size your log buffer for your burst, not your average

When the deferred mode ring buffer fills up, messages get dropped. You’ll see a “log dropped N messages” warning (assuming the warning itself doesn’t also get dropped).

The worst-case burst is usually boot, where every subsystem initializes and logs at once, or an error cascade where multiple modules report failures simultaneously.

Start with CONFIG_LOG_BUFFER_SIZE=1024. Watch for dropped message warnings during boot and stress scenarios. Bump to 2048 or 4096 if needed. On a Cortex-M4 with 64KB RAM, 2048 bytes for log buffers is a reasonable trade.

Rule 5: Choose your backend wisely

BackendLatencyBest For
UARTHigh (blocking drain)Simple dev boards
RTT (Segger)Very lowDebug with J-Link
Flash/FSVariableField logging, post-mortem
CustomDependsProduction telemetry

RTT is the gold standard for development. It pushes log data over the debug probe’s SWD connection at memory-access speeds. No UART bottleneck. If you have a J-Link (and on Nordic or NXP boards, you probably do), enable it:

CONFIG_LOG_BACKEND_RTT=y
CONFIG_USE_SEGGER_RTT=y
CONFIG_LOG_BACKEND_UART=n

UART backends are the most common source of “logging tanks my performance” complaints. Even in deferred mode, the logging thread still has to push bytes through the UART, and if the baud rate is low or the output volume is high, the thread can fall behind and start dropping messages.

Rule 6: Compile out logging entirely for release if you can

CONFIG_LOG=n removes everything. Zero overhead, zero flash, zero RAM. If you need some observability in production, set CONFIG_LOG_DEFAULT_LEVEL=1 to keep only LOG_ERR calls alive. Unlike printk, you don’t have to grep through your codebase and comment things out. The build system strips it cleanly.

Common Pitfalls and Quick Fixes

“My logs aren’t appearing.” Check three things in order: is LOG_MODULE_REGISTER present in the file? Is the compile-time level high enough for the messages you’re expecting? Is at least one backend enabled in prj.conf?

“Logging crashes in ISR context.” This usually means you’re in immediate mode, which tries to format and output inline. That can fail inside an interrupt handler. Switch to deferred mode; LOG_INF() from an ISR is perfectly fine when deferred, since it just enqueues.

“I see ’log dropped N messages.’” Your buffer is too small for the burst. Increase CONFIG_LOG_BUFFER_SIZE or reduce verbosity on noisy modules.

“Log output is garbled or interleaved.” Likely multiple backends or threads writing to the same UART without proper synchronization. Disable backends you’re not actively using.

A Sensible Starter prj.conf

Copy this, tune it, and you’ll have a solid foundation for a Cortex-M4 class target with 64KB+ RAM:

# --- Logging subsystem ---
CONFIG_LOG=y
CONFIG_LOG_MODE_DEFERRED=y
CONFIG_LOG_BUFFER_SIZE=2048
CONFIG_LOG_PROCESS_THREAD_STACK_SIZE=1024

# Default level: info (compile out debug globally)
CONFIG_LOG_DEFAULT_LEVEL=3

# Runtime filtering via shell (disable for release)
CONFIG_LOG_RUNTIME_FILTERING=y

# Backend: RTT for development, swap to UART if no J-Link
CONFIG_LOG_BACKEND_RTT=y
CONFIG_USE_SEGGER_RTT=y
CONFIG_LOG_BACKEND_UART=n

# Timestamp source for log entries
CONFIG_LOG_TIMESTAMP_64BIT=y

For release builds, create an overlay (prj_release.conf) that sets CONFIG_LOG_DEFAULT_LEVEL=1 and CONFIG_LOG_RUNTIME_FILTERING=n. You get error reporting with minimal overhead and no runtime filtering RAM cost.

If you’re building Zephyr-based firmware for IoT devices that need to transmit data beyond local debug output, the Hubble reference application for Zephyr shows how a properly structured Zephyr project integrates BLE communication alongside subsystem configuration like logging.

Budget Logging Like Any Other Subsystem

Logging in Zephyr has its own thread, its own memory budget, its own configuration surface. Give it the same attention you’d give your sensor driver or your networking stack. Budget the RAM. Pick the right mode. Set compile-time levels per module. Choose a backend that matches your workflow. Test that your timing still holds with logging enabled.

Do this once, early in the project, and you’ll have structured, filterable, production-safe observability that you can dial up for debugging and dial down for release, without touching a single line of application code.


Hubble Network enables direct-to-satellite connectivity for Zephyr-based IoT devices—no gateways, no ground infrastructure. See how it works →