CONSUMER ELECTRONICS

Zbus: A Reliable Partner for Event-Driven Embedded Design

July 7, 2026

by

Agustin Perez

Zbus: A Reliable Partner for Event-Driven Embedded Design

As MCUs have grown more powerful, embedded firmware has evolved from simple super-loops into RTOS-based systems composed of multiple, interacting subsystems. Modern devices are inherently busy with sensors that update continuously, connections that change, timers that expire, and background tasks that never fully stop. In this context, event-driven design feels inevitable, but without structure, it quickly devolves into fragile, tightly coupled systems that are hard to understand, debug, and scale.

In our work on large Nordic semiconductor nRF54-based projects at Focus, we've seen that defining how subsystems and threads communicate is as important as defining what they do. One-to-one mechanisms are no longer sufficient, and modern systems require clear, many-to-many communication schemes with predictable behavior. Sensors, motors, connectivity, audio, and control logic often need to operate concurrently, and without a clear communication model, these interactions can easily become tangled across the system.

A diagram of the popular Wonder Workshop Dash robot

Of course, some RTOS systems, such as Zephyr, already provide powerful kernel primitives (FIFOs, message queues, LIFOs, signals, and more). For simple cases, these are often sufficient. But as systems grow, so does the complexity. Once you start dealing with many threads, evolving communication patterns, and higher-level concepts like publishers, listeners, or state observers, you quickly find yourself building abstractions on top of abstractions:

  • Polling shared state (the worst implementation)
  • Registering callbacks when something changes
  • Registering multiple callbacks reacting to the same event, sometimes triggering different actions (need to define a lot of code lines)

Before you know it, you've written hundreds of lines of glue code just to avoid polling, carefully validating synchronization, protecting shared data, and rethinking the design every time requirements change.

So the real question becomes: What if the Zephyr ecosystem already included a more structured way to organize inter-thread communication? And what if that solution followed a familiar and proven paradigm, similar to D-Bus in Linux systems? That solution exists, and it's called Zbus.

In the following sections, we'll explore how Zbus enables our developers, and therefore our clients, to achieve a clean, event-driven architecture in modern embedded systems and why it's a powerful tool for building scalable firmware on the new generation of Nordic MCUs.

Zbus overview

Zbus provides a publish–subscribe infrastructure for Zephyr-based systems built around typed channels. Each channel defines a message type, an initial value, and a set of observers interested in updates. Messages are published to channels and automatically dispatched to all registered observers, while the most recent message can be read at any time, providing a consistent and thread-safe view of system state.

Zbus decouples producers from consumers. Publishers interact only with channels and do not encode behavioral decisions or knowledge of who consumes the data. Listeners, subscribers, and message subscribers allow different reaction and processing models, while internal dispatching and fan-out are handled entirely by the zbus infrastructure.

Minimal Zbus Configuration and Usage

As with all Zephyr libraries, use Kconfig to enable zbus by adding the following to your project's prj.conf file:

CONFIG_ZBUS=y

Then just add the appropriate header file whenever you need to call zbus functions:

#include <zephyr/zbus/zbus.h>

Define a Zbus channel

Each zbus channel has a defined message type, so this definition includes a struct to use as the message. The state of the message must be initialized, so we fill sensor readings with obviously invalid data that may be checked for by the consumers.

/* Message type carried by the channel */
struct temperature_data {
   struct sensor_value value;
};

/* Invalid default value used to detect uninitialized data */
#define TEMP_INVALID 999

/* Define the zbus channel */
ZBUS_CHAN_DEFINE(
   temperature_chan,                 /* Channel name */
   struct temperature_data,           /* Message type */
   NULL,                              /* Optional validation function */
   NULL,                              /* Optional user data */
   ZBUS_OBS_DECLARE(),                /* Observers declared elsewhere */
   ZBUS_MSG_INIT(                     /* Initial message value */
       .value.val1 = TEMP_INVALID,
       .value.val2 = 0
   )
);

We're not using a validator function or user data, so both of those params have been set to NULL. In this example, there are no observers, so an empty initializer is used. The final parameter is a macro that initializes the channel with the obviously invalid data that indicates a sensor error. This will be replaced as soon as the first message is published.

Publishing a Zbus channel

Publishing to Zbus is a simple one-liner with an error check:

int err;
struct temperature_data reading;

/* Fill the message with a new sensor reading */
reading.value.val1 = 23;
reading.value.val2 = 500000; /* 23.5 °C */

err = zbus_chan_pub(&temperature_chan, &reading, K_MSEC(100));
if (err != 0) {
   LOG_ERR("Failed to publish temperature data: %d", err);
   return;
}

The first argument to zbus_chan_pub() identifies the channel, while the second points to the message being published. Zbus copies the message data internally, so the buffer does not need to remain valid after the call returns. The final argument is a timeout used to arbitrate concurrent access, ensuring safe and deterministic publishing without requiring explicit locking in application code.

Reading a Zbus message

Reading from zbus is very similar to publishing to it.

int err;
struct temperature_data data;

err = zbus_chan_read(&temperature_chan, &data, K_MSEC(50));
if (err != 0) {
   LOG_ERR("Failed to read temperature data: %d", err);
   return;
}

The first argument to zbus_chan_read() identifies the channel, while the second provides a buffer where zbus copies the current message, giving consumers a local and consistent snapshot. The final argument is a timeout used to arbitrate concurrent access, ensuring thread-safe reads without explicit locking. Reading a channel does not imply ownership or subscription, any thread may read the latest value at any time, making this approach suitable for state queries and diagnostics, while subscriptions and listeners are preferred for reactive behavior.

Decoupling application logic from hardware-dependent code

As embedded systems grow, keeping application logic independent from hardware details becomes essential. In this architecture, application-level behavior communicates exclusively through a common, message-based interface provided by zbus.

Zbus acts as a clear boundary: application logic publishes intent and state, while services, whether hardware-dependent code or complex algorithms, subscribe and react independently. This keeps responsibilities well defined, reduces coupling, and allows systems to scale and evolve without rewriting core logic.

Event-driven architectures with Zbus

In an event-driven architecture built on top of Zbus, services are organized around clear boundaries that separate system behavior from hardware and implementation details. Application and domain logic interact with services through well-defined ports, while zbus provides asynchronous, decoupled message delivery. Services publish and consume events without knowledge of their producers or consumers, and adapters translate abstract interactions into concrete driver operations, keeping hardware-specific code isolated and allowing system behavior to evolve independently of platform constraints.

In this architecture, each service exposes ports that define its external contract and act as the only entry and exit points for messages. Ports remain agnostic of producers and consumers, while Zbus sits between services as a neutral messaging infrastructure responsible for asynchronous delivery and fan-out. Adapters connect service logic to concrete drivers, translating abstract interactions into hardware-specific operations. Zbus provides a complete, production-ready implementation of this model, handling dispatching, synchronization, and message delivery through a simple API, allowing services to focus on behavior rather than communication infrastructure.

When (and when not) using zbus

By now, we've seen how zbus provides a clean and structured foundation for building event-driven embedded systems, from simple state propagation to full service-oriented architectures. However, like any architectural choice, this approach comes with intentional trade-offs. Understanding these trade-offs helps clarify when zbus is the right tool for coordinating system behavior, and when it is more appropriate to rely on lower-level Zephyr primitives for performance-critical or timing-sensitive paths.

Pros

  • Temporal, spatial, and synchronization decoupling: publishers and subscribers are fully decoupled, they do not need to execute at the same time, as publication is asynchronous and non-blocking, they have no direct knowledge of each other and communicate only through named channels. Publisher execution is independent of subscriber scheduling, with data consistency handled by the channel and execution order managed by the scheduler.
  • Predictable RAM usage: as each channel maintains a single shared message instance whose size is defined at compile time and does not grow with the number of subscribers.

Cons

  • Scheduler-dependent delivery latency and listener trade-off: delivery latency depends on the scheduler because subscriber execution is asynchronous and driven by thread priorities. Using a listener can reduce latency, but at the cost of tighter coupling, since the publisher executes in the consumer context during listener execution.
  • Lack of delivery guarantee: channels store only the most recent value, so if a producer publishes faster than a consumer can process, intermediate updates may be overwritten and lost. Mitigation requires additional design effort, such as separating publication and acknowledgment, which improves flow awareness but increases architectural complexity and shifts responsibility to the application designer.
  • Inefficiency for high-frequency continuous data streams: zbus is not optimized for high-frequency or zero-copy data streams, as it relies on memcpy-based publication, channel locking, and scheduler-driven subscriber execution.

In the end, zbus works best as a design tool rather than a generic messaging mechanism. It excels at structuring systems around events and state changes, while high-rate or loss-sensitive data flows are better served by lower-level Zephyr primitives such as FIFOs or message queues. Used with the right expectations, Zbus doesn't just move data, it helps shape clearer and more maintainable system architectures.