May 29 / Sebastian Helmut

Beyond FreeRTOS: Mastering Zephyr's Advanced IPC Toolkit (zbus, Pipes, and Poll)

Zephyr's IPC Toolkit: The Right Primitive for Every Problem
 


1. Introduction

FreeRTOS gives you four IPC primitives: queues, semaphores, mutexes, and event groups. These cover most use cases but only if your application is simple enough to fit that model. As soon as you need a thread that waits on two different queues simultaneously, or a component that broadcasts data to multiple independent consumers without knowing who they are, or a state machine that waits for a complex condition tied to a mutex, FreeRTOS forces you to build workarounds on top of primitives that were not designed for the job.

Zephyr takes a different approach. It ships a complete IPC toolkit that includes everything FreeRTOS has, plus a set of primitives that address the cases FreeRTOS handles poorly or not at all. These are not just API wrappers, they are fundamentally different communication models.

This article covers three layers of Zephyr-native IPC, each solving a different class of problem: condition variables and event objects for thread synchronization, pipes and the Poll API for data passing, and zbus for the publish-subscribe pattern.


2. Thread Synchronization: Condition Variables and Event Objects

2.1 Condition Variables

A condition variable k_condvar is a synchronization primitive that lets a thread wait until a specific condition becomes true, tied to a mutex that protects the shared state being tested. This is the POSIX pthread_cond_wait model, brought to bare-metal.

The pattern is: lock the mutex, check the condition, if not satisfied wait on the condvar (which atomically releases the mutex and sleeps), wake up when signalled, recheck the condition.

/* shared state */

static K_MUTEX_DEFINE(data_mutex);

static K_CONDVAR_DEFINE(data_ready);

static struct sensor_data shared_data;

static bool data_available = false;



/* producer thread - writes sensor data and signals */

void producer_thread(void *p1, void *p2, void *p3) {

while (1) {

struct sensor_data d = read_sensor();



k_mutex_lock(&data_mutex, K_FOREVER);

shared_data = d;

data_available = true;

k_condvar_signal(&data_ready); /* wake one waiting thread */

k_mutex_unlock(&data_mutex);



k_msleep(100);

}

}



/* consumer thread - waits until data is actually ready */

void consumer_thread(void *p1, void *p2, void *p3) {

while (1) {

k_mutex_lock(&data_mutex, K_FOREVER);



/* k_condvar_wait atomically releases the mutex and sleeps */

while (!data_available) {

k_condvar_wait(&data_ready, &data_mutex, K_FOREVER);

}



struct sensor_data local = shared_data;

data_available = false;

k_mutex_unlock(&data_mutex);



process_data(&local);

}

}

The key difference from a semaphore: the condition variable check and the mutex release are atomic. With a bare semaphore you always risk a missed signal between checking the condition and blocking. The condvar eliminates that race entirely.

FreeRTOS has no equivalent. The closest workaround is a semaphore plus a flag, but the atomic release is missing and the code is error-prone.

2.2 Event Objects

A k_event is a 32-bit bitmask where each bit represents an independent event. Multiple threads can wait on different combinations of bits simultaneously. A producer posts bits, consumers wake when their specific bit pattern is satisfied.

static K_EVENT_DEFINE(system_events);



/* Event bit definitions */

#define EVT_SENSOR_READY BIT(0)

#define EVT_BUTTON_PRESSED BIT(1)

#define EVT_UART_RX BIT(2)

#define EVT_ALARM BIT(3)



/* Thread 1 - waits for sensor OR button */

void thread_ui(void *p1, void *p2, void *p3) {

while (1) {

uint32_t events = k_event_wait(&system_events,

EVT_SENSOR_READY | EVT_BUTTON_PRESSED,

false, /* do not reset all bits */

K_FOREVER);



if (events & EVT_SENSOR_READY) { handle_sensor(); }

if (events & EVT_BUTTON_PRESSED) { handle_button(); }



k_event_clear(&system_events,

EVT_SENSOR_READY | EVT_BUTTON_PRESSED);

}

}



/* Thread 2 - waits for alarm only */

void thread_alarm(void *p1, void *p2, void *p3) {

while (1) {

k_event_wait(&system_events, EVT_ALARM, false, K_FOREVER);

trigger_alarm();

k_event_clear(&system_events, EVT_ALARM);

}

}



/* ISR - posts events from interrupt context */

void button_isr(const struct device *dev,

struct gpio_callback *cb, uint32_t pins) {

k_event_post(&system_events, EVT_BUTTON_PRESSED);

}

FreeRTOS has event groups which are similar, but Zephyr event objects integrate cleanly with the Poll API (covered in section 3.2) and with the rest of the kernel object model.


Struggling to implement this for a professional project? If you need to master full-scale firmware architecture, security, and build automation, join our 1-D or 4-Day Live Implemnetation Workshops. I'll show you the exact direct path to production-ready firmware without the trial and error.


3. Data Passing: Pipes and the Poll API

3.1 Pipes

A k_pipe is a streaming byte channel between threads. Unlike a message queue which transfers fixed-size discrete messages, a pipe transfers an arbitrary-length byte stream,exactly like a UNIX pipe. The producer writes bytes in,the consumer reads bytes out. The pipe handles buffering internally.

/* Define a pipe with a 256-byte internal buffer */

K_PIPE_DEFINE(uart_pipe, 256, 4);



/* Producer - writes variable-length frames into the pipe */

void uart_rx_thread(void *p1, void *p2, void *p3) {

uint8_t frame[64];

size_t bytes_written;



while (1) {

int len = receive_uart_frame(frame, sizeof(frame));



k_pipe_write(&uart_pipe,

frame, len,

&bytes_written,

1, /* min bytes to write before returning */

K_FOREVER);

}

}



/* Consumer - reads and processes the stream */

void protocol_thread(void *p1, void *p2, void *p3) {

uint8_t buf[64];

size_t bytes_read;



while (1) {

k_pipe_read(&uart_pipe,

buf, sizeof(buf),

&bytes_read,

1, /* min bytes to read before returning */

K_FOREVER);



parse_protocol_frame(buf, bytes_read);

}

}

Pipes are ideal for streaming protocols (UART, USB CDC, SPI DMA transfers) where data arrives in variable-size chunks and the consumer processes it as a continuous stream. FreeRTOS has no pipe equivalent you would need to fragment the data into fixed-size queue items, which wastes memory and adds complexity.

3.2 The Poll API

k_poll lets a single thread wait on multiple kernel objects simultaneously - semaphores, FIFOs, message queues, pipes, and event objects - and wake up when any one of them becomes ready. This is the POSIX poll() /select() model for embedded.

/* A thread that handles UART data OR a button press OR a timer */

void dispatcher_thread(void *p1, void *p2, void *p3)

{

struct k_poll_event events[3] = {

K_POLL_EVENT_STATIC_INITIALIZER(

K_POLL_TYPE_MSGQ_DATA_AVAILABLE,

K_POLL_MODE_NOTIFY_ONLY,

&uart_msgq, 0),



K_POLL_EVENT_STATIC_INITIALIZER(

K_POLL_TYPE_SEM_AVAILABLE,

K_POLL_MODE_NOTIFY_ONLY,

&button_sem, 0),



K_POLL_EVENT_STATIC_INITIALIZER(

K_POLL_TYPE_FIFO_DATA_AVAILABLE,

K_POLL_MODE_NOTIFY_ONLY,

&sensor_fifo, 0),

};



while (1) {

/* Block until ANY of the three objects is ready */

k_poll(events, ARRAY_SIZE(events), K_FOREVER);



if (events[0].state == K_POLL_STATE_MSGQ_DATA_AVAILABLE) {

struct uart_msg msg;

k_msgq_get(&uart_msgq, &msg, K_NO_WAIT);

handle_uart(&msg);

}



if (events[1].state == K_POLL_STATE_SEM_AVAILABLE) {

k_sem_take(&button_sem, K_NO_WAIT);

handle_button();

}



if (events[2].state == K_POLL_STATE_FIFO_DATA_AVAILABLE) {

struct sensor_data *d = k_fifo_get(&sensor_fifo, K_NO_WAIT);

handle_sensor(d);

}



/* Reset states before next poll */

events[0].state = K_POLL_STATE_NOT_READY;

events[1].state = K_POLL_STATE_NOT_READY;

events[2].state = K_POLL_STATE_NOT_READY;

}

}

Without k_poll, the only way to wait on multiple sources in FreeRTOS is to create a dedicated "aggregator" task that reads all sources and forwards to a single queue, or to use a shared event group - both add complexity and latency. k_poll handles this directly with a single blocking call.

Fig 1. Zephyr IPC primitives overview


4. The Publish-Subscribe Model: zbus

4.1 What zbus solves

Classic IPC primitives couple the producer and consumer: the producer must know which queue to write to, and the consumer must know which queue to read from. When you add a second consumer , both a logging thread and a BLE thread need to receive the same sensor data - you either duplicate the queue or build a dispatcher. Neither scales.

zbus solves this with the publish-subscribe pattern. A producer publishes to a channel. Any number of observers subscribe to that channel. The producer never knows who the observers are. The observers never know who the producer is. Adding a third consumer requires zero changes to the producer.

4.2 Core concepts

  • Channel: a typed, named shared memory slot. Every channel has a fixed message type (a C struct). Only one message exists per channel at any time - the latest published value.

  • Publisher any thread that calls zbus_chan_pub() on a channel.

  • Observer: any entity registered to watch a channel. Two types:

  • Listener: a callback function called synchronously in the publisher's thread context when the channel is updated. Zero latency, no thread needed, but must be fast and non-blocking.

  • Subscriber: a thread that receives a notification (via an internal message queue) when the channel is updated, then reads the channel at its own pace. Decoupled, runs in its own thread context.

Fig 2. zbus architecture

4.3 Real example: sensor data pipeline:

/* sensor_channel.h - shared between all files */

#include <zephyr/zbus/zbus.h>



struct sensor_msg {

int16_t temperature; /* °C * 100 */

uint16_t humidity; /* % * 100 */

uint32_t timestamp_ms;

};



/* Declare the channel - defined once in sensor_channel.c */

ZBUS_CHAN_DECLARE(sensor_chan);



/* sensor_channel.c - channel definition */

#include "sensor_channel.h"



ZBUS_CHAN_DEFINE(sensor_chan,

struct sensor_msg,

NULL, /* no validator */

NULL, /* no user data */

ZBUS_OBSERVERS(log_sub, ble_listener),

ZBUS_MSG_INIT(.temperature = 0,

.humidity = 0,

.timestamp_ms = 0)

);

/* producer_thread.c - reads sensor, publishes to channel */

#include "sensor_channel.h"



void sensor_thread(void *p1, void *p2, void *p3) {

struct sensor_msg msg;



while (1) {

msg.temperature = read_temperature_stm32();

msg.humidity = read_humidity_stm32();

msg.timestamp_ms = k_uptime_get_32();



zbus_chan_pub(&sensor_chan, &msg, K_MSEC(10));



k_msleep(1000);

}

}

/* log_subscriber.c - thread-based subscriber */

#include "sensor_channel.h"



ZBUS_SUBSCRIBER_DEFINE(log_sub, 4); /* message queue depth = 4 */



void log_thread(void *p1, void *p2, void *p3) {

const struct zbus_channel *chan;



while (1) {

/* block until a notification arrives */

zbus_sub_wait(&log_sub, &chan, K_FOREVER);



struct sensor_msg msg;

zbus_chan_read(chan, &msg, K_MSEC(10));



printk("Temp: %d.%02d C Hum: %d.%02d %%\n",

msg.temperature / 100, msg.temperature % 100,

msg.humidity / 100, msg.humidity % 100);

}

}

/* ble_listener.c - callback-based listener (no thread needed) */

#include "sensor_channel.h"



static void ble_cb(const struct zbus_channel *chan) {

/* Called in the publisher's thread context - must be fast */

struct sensor_msg msg;

zbus_chan_read(chan, &msg, K_NO_WAIT);

ble_update_characteristic(msg.temperature, msg.humidity);

}

ZBUS_LISTENER_DEFINE(ble_listener, ble_cb);

The producer calls zbus_chan_pub once. Both observers receive the data independently , the logging thread at its own pace via the subscriber queue, the BLE callback synchronously via the listener. Adding a third observer (say, a display thread) requires adding one line to the channel definition and writing the new observer. The producer code does not change.

4.4 zbus vs classic IPC

Use case

Best primitive

Signal one thread from ISR

k_sem

Pass fixed-size data to one consumer

k_msgq

Stream variable-length bytes

k_pipe

Wait on multiple sources simultaneously

k_poll

Wait for a complex condition with mutex

k_condvar

Wait for one of several independent events

k_event

Decouple producer from all consumers

zbus

Broadcast data to multiple independent consumers

zbus


5. Conclusion

Zephyr's IPC toolkit goes significantly beyond what FreeRTOS provides. Each primitive addresses a specific communication pattern that classic queues and semaphores handle poorly:

- Condition variables eliminate the race condition between checking a shared state and blocking, which a semaphore-plus-flag pattern always risks.

- Event objects let multiple threads wait on different subsets of a 32-bit event bitmask with a single API call.

- Pipes handle streaming byte data naturally, without forcing you to fragment streams into fixed-size queue items.

- The Poll API lets one thread wait on multiple kernel objects simultaneously - the missing primitive that FreeRTOS forces you to work around with aggregator tasks.

- zbus decouples producers from consumers entirely, making it trivial to add new observers without touching existing code.

The right primitive for the right problem is the difference between clean, maintainable firmware and a tangle of workarounds. Zephyr gives you the full toolkit.              Fig 3. IPC primitive decision flow


Want to master this? Here are your next steps:



Created with