Skip to content

Multi-tasking under Zephyr OS

Introduction

Multi-tasking Programs

In this codelab, we will learn basic principle for creating multi-tasking application using Zephyr RTOS, namely:

  • How to define threads for specific tasks of a program.
  • How to synchronize multiple threads and control access to shared resources.
  • Study the influence of shared resources on task scheduling.

Ultimately, we will transform the BikeComputer program into a multi-tasking concurrent application.

What you’ll build

In this codelab, you’re going to program simple programs using various multi-tasking primitives. Programs will be presented as exercices that run independently from each other.

What you’ll learn

  • How to use threads for performing different tasks in a program.
  • How to share resources among different threads.
  • How to synchronize tasks based on resources or events, including interrupt-based events.
  • What the influence of shared resources can have on task scheduling.

What you’ll need

Threads in Zephyr RTOS

Zephyr RTOS runs on single-microcontroller embedded systems. In this context, a thread is an independent segment of a program that executes within the single process running on the microcontroller. Threading enables multiple tasks to run concurrently using a scheduler. It is important to point out that, while threads provide developers with flexibility, they require resources for themselves and the scheduler.

Your application (main function) starts execution in the main thread, but it’s not the only thread running in Zephyr RTOS. When starting an Zephyr RTOS application, there are other threads running system services:

  • Main: The default thread that executes the application’s main function, after all RTOS initializations. The main thread has 1KiB of stack space by default. The application can configure it in “prj.conf” by defining the MAIN_STACK_SIZE parameter, as follows:
multi_tasking/prj.conf
CONFIG_MAIN_STACK_SIZE=2046 // stack size in bytes
  • Idle: The scheduler runs this thread when there is no other activity in the system (for example, when all other threads are waiting for an event). This ensures that the program does not burn empty processor cycles and is put to sleep for as long as possible.

  • Logging: The thread that’s run to log all information. It is is created only when CONFIG_LOG is enabled and when log mode is not immediate (CONFIG_LOG_MODE_IMMEDIATE=n).

Using the Utils Helper Class for Logging Thread Info

The Utils class is available in the zpp_lib repository. You may use this class in your BikeSystem implementation to log statistics related to each thread running in your application.

If you add a call to the zpp_lib::Utils::logThreadsSummary() method at the start of the BikeComputer::start() method, you may observe the three different threads, as shown below:

Thread statistics logging
[00:00:00.241,166] <inf> zpp_rtos:  # |      Thread ID | Name       | State     | Prio | Stack (used/total)
[00:00:00.252,166] <inf> zpp_rtos: ---+----------------+------------+-----------+------+-------------------
[00:00:00.252,502] <inf> zpp_rtos:  1 |     0x20000170 | logging    | queued    |   14 |   48 / 768
[00:00:00.252,593] <inf> zpp_rtos:  2 |     0x20000848 | idle       | ready     |   15 |   48 / 320
[00:00:00.253,082] <inf> zpp_rtos:  3 |     0x20000918 | main       | queued    |   11 |  704 / 4096
[00:00:00.253,082] <inf> zpp_rtos: ---+----------------+------------+-----------+------+-------------------

Zephyr RTOS Scheduling Principles

Thread Priorities

Threads are assigned an integer value to indicate their priority, which can be negative or non-negative. A lower numerical value indicates a higher priority.

Under Zephyr RTOS, threads are either cooperative or preemptible threads:

  • Cooperative threads run until they either complete their task or perform an action that renders them unready. Under Zephyr RTOS, these threads have a negative priority value.
  • Preemptible threads may become the running thread and may be preempted by a cooperative thread or another preemptible thread with a higher or equal priority. Under Zephyr RTOS, these threads have a non-negative priority value.

The diagram below illustrates the different thread priorities with Zephyr RTOS. Note that CONFIG_NUM_COOP_PRIORITIES and CONFIG_NUM_PREEMPT_PRIORITIES are configurable parameters.

Thread priorities

Thread Priorities

(source: https://academy.nordicsemi.com/courses/nrf-connect-sdk-intermediate/lessons/lesson-1-zephyr-rtos-advanced/topic/scheduler-in-depth/)

Scheduling Based on Priority

The scheduler is the part of the kernel responsible for dispatching tasks. At specific points in time, the scheduler will decide which task is to be run.

For the purposes of this lecture, we will only use preemptible threads. With these threads, the scheduler selects the highest-priority ready thread to be the current running thread. When there are multiple threads with the same priority that are ready, the scheduler chooses the one that has been waiting the longest. Thread priorities are established at creation time and they may be modified using the zpp_lib::Thread::setPriority() method.

With Zephyr RTOS, the queue of ready threads can be implemented using different mechanisms. The choice depends on various criteria. However, given the limited number of threads we will use, the simplest mechanism, CONFIG_SCHED_SIMPLE, is appropriate.

Preemptive Time Slicing

Once a preemptive thread becomes the current thread, it remains so until a thread with a higher priority becomes ready or the current thread performs an action that renders it unready. Consequently, if a preemptive thread performs lengthy computations, it may cause unacceptable delays in scheduling other threads, including those with the same priority. This is the reason why most schedulers, including the Zephyr RTOS scheduler, implement round-robin or time slicing. This allows threads with the same priority to run concurrently.

In time slicing, the scheduler divides time into time slices. At the end of each time slice, the scheduler checks if there are other threads with the same priority as the current one. If so, it preempts the current preemptible thread and dispatches another one.

Under Zephyr RTOS, time slicing can be configured as follows:

  • CONFIG_TIMESLICING enables or disables time slicing among preemptible threads of equal priority. Time slicing is enabled by default.

  • CONFIG_TIMESLICE_SIZE specifies the amount of time (in milliseconds) that a thread can execute before being preempted by a thread of equal priority. The default value is 20 milliseconds.

  • CONFIG_TIMESLICE_PRIORITY specifies the thread priority level at which time slicing takes effect. Time slicing is not applied on threads with a higher priority than this ceiling. The default ceiling value is 0.

Priorities Values

It is important to recall that with Zephyr RTOS, higher priority means a smaller priority value. This is made explicit in zpp_lib, where priorities are defined as enum class PreemptableThreadPriority.

Main Thread Default Priority

The main thread is created by the system with a default priority that can be configured using CONFIG_MAIN_THREAD_PRIORITY. The default value is 0, meaning that by default the main thread has the highest priority among preemptible threads. Using zpp_lib, the default main thread priority must be configured because zpp_lib::PreemptableThreadPriority::PriorityRealtime has a value that is higher than 0 in any case. The default value should be set to zpp_lib::PreemptableThreadPriority::PriorityNormal, which means a value of CONFIG_NUM_PREEMPT_PRIORITIES - 4 (by default 11).

Rescheduling points

The scheduler needs to decide at specific points in time which task should run. The Zephyr RTOS is a tickless kernel by default. This means that it eliminates the periodic timer interrupts, or “ticks”, generated by the system tick hardware. This provides significant power-saving advantages over traditional kernels:

  • Tickful kernels use a periodic timer interrupt known as the ‘system tick’ that is generated at fixed intervals, regardless of the system’s workload. This ticker serves as a time reference for the scheduler and other kernel functions.

  • Tickless kernels do not rely on fixed intervals to determine which thread to run next. Instead, the scheduler dispatches tasks at rescheduling points. These points include:

  • When a thread gives up its task by calling k_yield() and changing state from Running to Ready.

  • When a thread goes to sleep by calling k_sleep().
  • When a synchronization object such as a semaphore or mutex is released/unblocked, causing the state of the waiting thread to change from Blocked to Ready.
  • When time slicing is enabled and the running thread has been running for the time slice time duration.

Zephyr RTOS can be converted into to a tickful kernel by setting CONFIG_TICKLESS_KERNEL=n.

Using zpp_lib::Events For Waiting For an Event

It is very common in a multi-tasking environment to wait for a specific event to happen before performing a given task. An obvious example is waiting for a user to press for a button. For this purpose, Zephyr RTOS provides the Events API, that is encapsulated in the zpp_lib::Events class.

For demonstrating the use of this API, we create a WaitOnButton class defined as follows:

wait_on_button.hpp
multi_tasking/src/wait_on_button.hpp
// Copyright 2025 Haute école d'ingénierie et d'architecture de Fribourg
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/****************************************************************************
 * @file wait_on_button.hpp
 * @author Serge Ayer <serge.ayer@hefr.ch>
 *
 * @brief Declaration of the WaitOnButton class
 *
 * @date 2025-07-01
 * @version 1.0.0
 ***************************************************************************/

#pragma once

// stl
#include <chrono>

// zpp_lib
#include "zpp_include/events.hpp"
#include "zpp_include/interrupt_in.hpp"
#include "zpp_include/thread.hpp"

namespace multi_tasking {

class WaitOnButton {
 public:
  explicit WaitOnButton(const char* threadName);

  [[nodiscard]] zpp_lib::ZephyrResult start();
  void wait_started();
  void wait_exit();

 private:
  void waitForButtonEvent();
  void buttonPressed();

  static constexpr uint8_t kPressedEvent = BIT(0);
  static constexpr uint8_t kStartedEvent = BIT(1);

  zpp_lib::Thread _thread;
  std::chrono::microseconds _pressedTime;
  zpp_lib::Events _events;
  zpp_lib::InterruptIn<zpp_lib::PinName::BUTTON1> _pushButton;
};

}  // namespace multi_tasking
wait_on_button.cpp
multi_tasking/src/wait_on_button.cpp
// Copyright 2025 Haute école d'ingénierie et d'architecture de Fribourg
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/****************************************************************************
 * @file wait_on_button.cpp
 * @author Serge Ayer <serge.ayer@hefr.ch>
 *
 * @brief Implementation of the WaitOnButton class
 *
 * @date 2025-07-01
 * @version 1.0.0
 ***************************************************************************/
#include "wait_on_button.hpp"

// zephyr
#include <zephyr/logging/log.h>

// zpp_lib
#include "zpp_include/time.hpp"

LOG_MODULE_DECLARE(multi_tasking, CONFIG_APP_LOG_LEVEL);

namespace multi_tasking {

WaitOnButton::WaitOnButton(const char* threadName)
    : _thread(zpp_lib::PreemptableThreadPriority::PriorityNormal, threadName),
      _pressedTime(std::chrono::microseconds::zero()) {
  _pushButton.fall(std::bind(&WaitOnButton::buttonPressed, this));
  LOG_DBG("WaitOnButton initialized");
}

zpp_lib::ZephyrResult WaitOnButton::start() {
  auto res = _thread.start(std::bind(&WaitOnButton::waitForButtonEvent, this));
  if (!res) {
    LOG_ERR("Failed to start thread: %d", (int)res.error());
    return res;
  }
  LOG_DBG("Thread started successfully");
  return res;
}

void WaitOnButton::wait_started() { _events.wait_any(kStartedEvent); }

void WaitOnButton::wait_exit() {
  auto res = _thread.join();
  if (!res) {
    LOG_ERR("join() failed: %d", (int)res.error());
  }
}

void WaitOnButton::waitForButtonEvent() {
  LOG_DBG("Waiting for button press");
  _events.set(kStartedEvent);

  while (true) {
    _events.wait_any(kPressedEvent);
    std::chrono::microseconds time    = zpp_lib::Time::getUpTime();
    std::chrono::microseconds latency = time - _pressedTime;
    LOG_DBG("Button pressed with response time: %lld usecs", latency.count());
    LOG_DBG("Waiting for button press");
  }
}

void WaitOnButton::buttonPressed() {
  _pressedTime = zpp_lib::Time::getUpTime();
  _events.set(kPressedEvent);
}

}  // namespace multi_tasking

In the WaitOnButton class, we create and start an additional thread in the WaitOnButton::start() method. This thread then executes the WaitOnButton::waitForButtonEvent() method, which waits in an infinite loop for a specific event to occur. This event is triggered by a button press (Button 1) in the WaitOnButton::buttonPressed() method.

To experiment the WaitOnButton and the following multi-tasking examples, create a new “multi_tasking” application and use the code below. Ensure that the application configuration includes all necessary configuration parameters.

Note that the code includes all the demos presented in this codelab, and that you may need to comment out some of the demos for the code to compile.

main.cpp
multi_tasking/src/main.cpp
// Copyright 2025 Haute école d'ingénierie et d'architecture de Fribourg
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/****************************************************************************
 * @file main.cpp
 * @author Serge Ayer <serge.ayer@hefr.ch>
 *
 * @brief Main function of the Multi-Tasking program
 *
 * @date 2025-07-01
 * @version 1.0.0
 ***************************************************************************/

// std
#include <ostream>

// zephyr
#include <zephyr/kernel.h>
#include <zephyr/logging/log.h>
#include <zephyr/random/random.h>

// zpp_lib
#include "zpp_include/this_thread.hpp"
#include "zpp_include/utils.hpp"

// local
#include "buffer_solution.hpp"
#include "clock_with_mutex.hpp"
#include "consumer.hpp"
#include "deadlock.hpp"
#include "producer.hpp"
#include "wait_on_button.hpp"

LOG_MODULE_REGISTER(multi_tasking, CONFIG_APP_LOG_LEVEL);

class RandomIntGenerator {
 public:
  static constexpr uint8_t kMaxRandomValue = 20;

  static uint32_t produceNextValue() { return sys_rand32_get() % kMaxRandomValue; }
};

class RandomDoubleGenerator {
 public:
  static constexpr double randomValues[] = {1.1, 2.2, 3.3, 4.4, 5.5};
  static double produceNextValue() {
    return randomValues[sys_rand32_get() %
                        (sizeof(randomValues) / sizeof(randomValues[0]))];
  }
};

struct Rect {
  int32_t x;
  int32_t y;
};

std::ostream& operator<<(std::ostream& os, const Rect& rect) {
  os << "(" << rect.y << ", " << rect.x << ")";
  return os;
}

class RandomRectGenerator {
 public:
  static constexpr Rect randomValues[] = {{1, 1}, {2, 2}, {3, 3}, {4, 4}, {5, 5}};
  static Rect produceNextValue() {
    return randomValues[sys_rand32_get() %
                        (sizeof(randomValues) / sizeof(randomValues[0]))];
  }
};

int main(void) {
  using namespace std::literals;

  LOG_DBG("Multi-tasking program started");

  // check which button is pressed
  zpp_lib::InterruptIn<zpp_lib::PinName::BUTTON1> button1;
  zpp_lib::InterruptIn<zpp_lib::PinName::BUTTON2> button2;
  zpp_lib::InterruptIn<zpp_lib::PinName::BUTTON3> button3;
  if (button1.read() == zpp_lib::kPolarityPressed) {
    // log thread statistics
    zpp_lib::Utils::logThreadsSummary();

    LOG_DBG("Starting WaitOnButton demo");
    // create the WaitOnButton instance and start it
    multi_tasking::WaitOnButton waitOnButton("ButtonThread");
    auto res = waitOnButton.start();
    if (!res) {
      LOG_ERR("Cannot start waitOnButton: %d", static_cast<int>(res.error()));
      return -1;
    }

    // wait that the WaitOnButton thread started
    LOG_DBG("Calling wait_started()");
    waitOnButton.wait_started();
    LOG_DBG("wait_started() unblocked");

    // log thread statistics
    zpp_lib::Utils::logThreadsSummary();

    // wait for the thread to exit (will not because of infinite loop in WaitOnButton)
    waitOnButton.wait_exit();
    // or do busy waiting
    while (true) {
    }
  } else if (button2.read() == zpp_lib::kPolarityPressed) {
    LOG_DBG("Starting Clock demo");
    // create and start a clock
    multi_tasking::Clock clock;
    clock.start();
  } else if (button3.read() == zpp_lib::kPolarityPressed) {
    LOG_DBG("Starting Deadlock demo");

    // create a first deadlock instance
    multi_tasking::Deadlock deadlock0(0, "Thread0");
    deadlock0.start();

    // create a second deadlock instance
    multi_tasking::Deadlock deadlock1(1, "Thread1");
    deadlock1.start();

    // wait for both threads to terminate (will not because of deadlock)
    deadlock0.wait();
    deadlock1.wait();
  } else {
    LOG_DBG("Starting Consumer/Producer demo");

    using BufferType     = Rect;
    using ValueGenerator = RandomRectGenerator;
    multi_tasking::Buffer<BufferType> buffer;
    multi_tasking::Producer<BufferType, ValueGenerator> producer(buffer);
    multi_tasking::Consumer<BufferType> consumer(buffer);

    producer.start();
    consumer.start();

    // wait for threads to terminate (will not)
    producer.wait();
    consumer.wait();
  }

  return 0;
}

In this example, we can observe the following:

  • The use of the WaitOnButton instance in the main function is self-documented. Make sure that you understand it in detail.
  • After starting the WaitOnButton instance, the main thread waits for the thread to exit. This will never happen, so the main thread will wait forever in the waitOnButton.wait_exit() method. Recall that the main thread must never exit the main() function for a Zephyr RTOS program to behave properly.
  • Each time the button is pressed, the kPressedEvent event is set on the _events instance in the WaitOnButton::buttonPressed() method. When this event is set, the “ButtonThread” thread transitions move from the Waiting to the Ready state. The kPressedEvent event is reset on the _events instance and the “ButtonThread” thread starts waiting again. You can observe this behavior by pressing the button multiple times.

To better understand the program, compile and run it. When you start the program, you should see the following in the console:

  • Before the WaitOnButton instance is created and started, the application runs the three “main”, “idle” and “logging” threads, as illustrated above. Note that if the “CONFIG_LOG_IMMEDIATE` option is set, then the “logging” thread is not created.
  • After the WaitOnButton instance is started, the application creates and runs an additional thread named “ButtonThread”.
  • While the program is running, the “ButtonThread” thread will transition from the “Waiting” state to the “Ready” state each time the button is pressed. Then, it will immediately return to the “Waiting” state after printing its message on the console.

Note that it is possible to wait for multiple events at once, as documented in the Events API. This is a very useful mechanism in some situations where a task must be executed when multiple events arise. It is also possible to wait for a specific event as demonstrated in the WaitOnButton class.

Exercice Multi-tasking under Zephyr OS/1

It is also useful to measure the interrupt latency time. The WaitOnButton class implements this mechanism by registering the press time in the ISR method, and by computing the time interval until the waitForButtonEvent thread is signaled. With this mechanism, we can measure the interrupt latency.

You may notice that the interrupt latency varies depending on some multi-tasking behaviors. Test the following scenarios:

  • The main thread is in a Waiting state, waiting for the WaitOnButton thread to terminate using the Thread::join() method. This is the default behavior as documented above. Try to change the priority of the WaitOnButton thread.
  • Replace the call to waitOnButton.wait_exit() in the main function with a busy infinite wait while (true) {}.
  • Using the busy wait, modify the priority of the WaitOnButton thread to PriorityAboveNormal (in the constructor)
  • Using the busy wait, modify the priority of the WaitOnButton thread to PriorityBelowNormal (in the constructor).

Report the interrupt latency times for each scenario and explain the possible reasons for the observed values.

Solution

For the different scenarios:

  • Thread::join(): observed latency times should be around \(30-60\,\mathsf{us}\), with a very low jitter. The reason is that the main thread is in waiting state and it will never be running. The ISR is thus served very fast and with very little jitter. Changing the priority of the WaitOnButton thread does not influence the behavior, because the WaitOnButton thread is the only non idle thread.
  • Busy wait (WaitOnButton thread with normal priority): the observed latency time should be around \(10\,\mathsf{ms}\) with values between \(0\,\mathsf{ms}\) and \(20\,\mathsf{ms}\). The reason is that the main thread is always running and context switching to the routine serving the deferred ISR happens through round-robin/time slicing (set by default to \(20\,\mathsf{ms}\)).
  • Busy wait (button thread with higher priority): the routine serving the deferred ISR prempts the main thread and the observed latency time should be around \(30\,\mathsf{us}\), with very small jitter.
  • Busy wait (button thread with lower priority): the routine serving the deferred _ISR is never executed, because the main thread is running with higher priority and no time slicing takes place with a lower priority thread.

Shared Resources and Mutual Exclusion

On a uniprocessor, multiple threads can run concurrently with interleaved or time-shared execution. On multiprocessor systems, multiple threads can also run simultaneously. In both cases, managing resources such as access to shared memory/variables by multiple threads is an issue. Multitasking or multithreaded programs must ensure that resources are accessed properly, i.e. in the correct sequence as if the program were being executed by a single thread. In other words, the program must sometimes enforce mutually exclusive access to shared resources for only one thread at a time. To this end, most kernels provide a mutex mechanism that can be implemented at either the hardware or software level.

We will first illustrate this problem using an example that simulates a clock keeping track of the current time. To update the clock, a ticker with an interrupt interval of 1 second is used. The structures for defining the time and the ISR to update it are given below:

clock.hpp
multi_tasking/src/clock.hpp
// Copyright 2025 Haute école d'ingénierie et d'architecture de Fribourg
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/****************************************************************************
 * @file clock.hpp
 * @author Serge Ayer <serge.ayer@hefr.ch>
 *
 * @brief Declaration of the Clock class
 *
 * @date 2025-07-01
 * @version 1.0.0
 ***************************************************************************/

#pragma once

// stl
#include <chrono>
#include <functional>

// zpp_lib
#include "zpp_include/thread.hpp"
#include "zpp_include/ticker.hpp"
#include "zpp_include/work_queue.hpp"

namespace multi_tasking {

using namespace std::literals;

class ClockUnsafe {
 public:
  struct DateTimeType {
    uint32_t day;
    uint32_t hour;
    uint32_t minute;
    uint32_t second;
  };

  ClockUnsafe();

  // method called for starting the clock demo
  zpp_lib::ZephyrResult start();

 private:
  void displayFromTicker();
  void displayCurrentTime();
  void updateFromTicker();
  void updateCurrentTime();

  // type definition used by tickers and queues
  using TickerFunction    = std::function<void()>;
  using WorkQueueFunction = std::function<void()>;
  // used for display the current time
  zpp_lib::WorkQueue _displayQueue;
  zpp_lib::Ticker<TickerFunction> _displayTicker;
  zpp_lib::Work _displayWork;
  // used for updating _currentTime
  zpp_lib::WorkQueue _updateQueue;
  zpp_lib::Thread _updateThread;
  zpp_lib::Ticker<TickerFunction> _updateTicker;
  zpp_lib::Work _updateWork;
  DateTimeType _currentTime{.day = 0, .hour = 10, .minute = 59, .second = 58};
  static constexpr std::chrono::milliseconds clockUpdateTimeout  = 1000ms;
  static constexpr std::chrono::milliseconds clockDisplayTimeout = 1000ms;
};

}  // namespace multi_tasking
clock.cpp
multi_tasking/src/clock.hpp
// Copyright 2025 Haute école d'ingénierie et d'architecture de Fribourg
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/****************************************************************************
 * @file clock.cpp
 * @author Serge Ayer <serge.ayer@hefr.ch>
 *
 * @brief Implementation of the Clock class
 *
 * @date 2025-07-01
 * @version 1.0.0
 ***************************************************************************/

#include "clock.hpp"

// zephyr
#include <zephyr/logging/log.h>

// zpp_lib
#include "zpp_include/this_thread.hpp"

LOG_MODULE_DECLARE(multi_tasking, CONFIG_APP_LOG_LEVEL);

namespace multi_tasking {

ClockUnsafe::ClockUnsafe()
    : _displayQueue("CDQueue"),
      _displayWork(std::bind(&ClockUnsafe::displayCurrentTime, this)),
      _updateQueue("TQueue"),
      _updateThread(zpp_lib::PreemptableThreadPriority::PriorityNormal, "TThread"),
      _updateWork(std::bind(&ClockUnsafe::updateCurrentTime, this)) {}

zpp_lib::ZephyrResult ClockUnsafe::start() {
  // Start a thread for running the _tickerQueue work queue.
  // Events are dispatched to the queue in the tickerUpdate() method called by the ticker.
  auto res = _updateThread.start(std::bind(&zpp_lib::WorkQueue::run, &_updateQueue));
  if (!res) {
    LOG_ERR("Cannot start ticker thread: %d", (int)res.error());
    return res;
  }

  // Call the updateFromTicker() method every second (from ISR context)
  TickerFunction updateFromTickerFunction = std::bind(&ClockUnsafe::updateFromTicker, this);
  res = _updateTicker.attach(updateFromTickerFunction, clockUpdateTimeout);
  if (!res) {
    LOG_ERR("Cannot attach update ticker: %d", (int)res.error());
    return res;
  }

  // Call the displayFromTicker() method every second (from ISR context)
  TickerFunction displayFromTickerFunction = std::bind(&ClockUnsafe::displayFromTicker, this);
  res = _displayTicker.attach(displayFromTickerFunction, clockDisplayTimeout);
  if (!res) {
    LOG_ERR("Cannot attach display ticker: %d", (int)res.error());
    return res;
  }

  // run the displayQueue from the calling thread
  _displayQueue.run();

  // should not get here
  __ASSERT(false, "Should not get here");

  return res;
}

void ClockUnsafe::displayFromTicker() {
  // this method runs in ISR mode -> we cannot allocate memory or perform other forbidden
  // operations
  auto res = _displayQueue.call(_displayWork);
  __ASSERT(res, "Cannot call display on queue: %d", (int)res.error());
}

void ClockUnsafe::displayCurrentTime() {
  DateTimeType dt = {0};

  dt.day  = _currentTime.day;
  dt.hour = _currentTime.hour;
  zpp_lib::ThisThread::busyWait(1s);
  dt.minute = _currentTime.minute;
  dt.second = _currentTime.second;

  printk("Day %u Hour %u min %u sec %u\n", dt.day, dt.hour, dt.minute, dt.second);
}

void ClockUnsafe::updateFromTicker() {
  // this method runs in ISR mode -> we cannot allocate memory or perform other forbidden
  // operations
  auto res = _updateQueue.call(_updateWork);
  __ASSERT(res, "Cannot call update on queue: %d", (int)res.error());
}

void ClockUnsafe::updateCurrentTime() {
  _currentTime.second +=
      std::chrono::duration_cast<std::chrono::seconds>(clockUpdateTimeout).count();

  if (_currentTime.second > 59) {
    _currentTime.second = 0;
    _currentTime.minute++;
    if (_currentTime.minute > 59) {
      _currentTime.minute = 0;
      _currentTime.hour++;
      if (_currentTime.hour > 23) {
        _currentTime.hour = 0;
        _currentTime.day++;
      }
    }
  }
}

}  // namespace multi_tasking

It is important to point out that both displayFromTicker() and updateFromTicker() methods are the callback function of the tickers and are both executed within the ISR. Since ISR methods must be short and cannot contain blocking code, both works must be deferred to separate WorkQueue instances.

the update of the current time (method updateCurrentTime()) is not executed within the ISR but it is rather deferred to the _tickerQueue event queue - so it is executed by the _tickerThread thread. The main reason is that blocking behaviors are forbidden in ISRs - so acquiring a mutex in an ISR is not possible. Acquiring the mutex in the updateCurrentTime() method is not currently done, but we will show below that it is required.

If you execute the program, you should get the following output:

Clock times

Day 0 Hour 10 min 59 sec 59
Day 0 Hour 11 min 0 sec 0
Day 0 Hour 11 min 0 sec 1
Day 0 Hour 11 min 0 sec 2
Day 0 Hour 11 min 0 sec 3
Day 0 Hour 11 min 0 sec 5
Day 0 Hour 11 min 0 sec 6
...

So everything looks fine and it seems that the above program will always execute correctly.

However, there is a race condition issue with this program. The problem is that an interrupt at the wrong time may result in values being update only partially in the displayCurrentTime() method. The issue can be explained as follows:

  • Say the current time is day: 0, hour: 10, minute: 59, second: 59 or {0, 10, 59, 59}.
  • The first call to displayCurrentTime() is executed from the main() function as an event in the WorkQueue instance that is run by the main thread. The first two instructions of the function are then executed, copying the values { 0, 10 } to dt.day/hour.
  • A timer interrupt occurs, prompting the other WorkQueue instance to update the current time to {0, 11, 0, 0}.
  • Later, the displayCurrentTime() function resumes execution and copies the remaining _currentTime fields to dt, thus dt.minute/second = {0, 0}.
  • The value of dt is thus {0, 10, 0, 0} and the program believes that the time just has jumped backwards by one hour!

Although the failure case described above can happen, it is unlikely. To make it happen easily, it is enough for our displayCurrentTime() method to go to sleep for \(1\,\mathsf{s}\) after the instruction updating the hour is executed. In this case, the other ticker will definitely execute before the execution of displayCurrentTime() resumes and the failure will happen. If you modify the displayCurrentTime() function as follows:

clock_with_wait.hpp
multi_tasking/src/clock.cpp
...
void getAndPrintDateTime() 
{
    DateTimeType dt = {0};

    dt.day = _currentTime.day;
    dt.hour = _currentTime.hour;

    static constexpr std::chrono::microseconds waitTime = 1s;
    zpp_lib::ThisThread::busyWait(waitTime);

    dt.minute = _currentTime.minute;  
    dt.second = _currentTime.second;

    printk("Day %d Hour %d min %d sec %d\n", dt.day, dt.hour, dt.minute, dt.second);
  }
...

you should see an output on the console similar to the one below. This shows a jump in the clock time:

Clock times

Clock update program started
Day 0 Hour 10 min 0 sec 0
Day 0 Hour 11 min 0 sec 2
Day 0 Hour 11 min 0 sec 3

Time slicing must be enabled

Note that for this problem only occurs when time slicing is active (CONFIG_TIME_SLICING=y). Otherwise, if the threads running the work queues have the same priority, the main thread will never be preempted. The waiting time must also be at least as large as the ticker period.

The above output exhibits a typical race condition problem. Preemption enables the ISR to interrupt the execution of other code and potentially overwrite data. To resolve this problem, one must ensure that the object — the ‘_currentTime’ object in our case — is accessed in an atomic or indivisible way. For uniprocessor systems, one solution would be to disable or turn off interrupts at the beginning of the displayCurrentTime() function and re-enable them on later. While this solves the problem, it may lead to interrupt events to be missed - in this particular case, the current displayed time may become incorrect.

The solution that disables interrupts is given below:

clock_with_irq_disabled.hpp
multi_tasking/src/clock.cpp
void getAndPrintDateTime() {
    ...

    auto key = irq_lock();

    dt.day  = _currentTime.day;
    dt.hour = _currentTime.hour;

    static constexpr std::chrono::microseconds waitTime = 500ms;
    zpp_lib::ThisThread::busyWait(waitTime);

    dt.minute = _currentTime.minute;
    dt.second = _currentTime.second;

    irq_unlock(key);

    ...
  }

In the code above, since we know that an ISR can write to our shared data object, we disable the interrupt and save the current interrupt masking state. We then restore the masking state at the end of the function. The same code needs to be inserted in the updateCurrentTime() method to protect access to _currentTime. Again, although this solves the problem, disabling interrupts should be avoided since it may affect other processing requests.

Another way of protecting the critical section is with the use of mutual exclusion objects or mutex. The goal of a mutex is to ensure that only one thread is executing in a critical section at the same time. Mutex can thus synchronize the execution of threads and protect access to a shared resource as illustrated below:

Mutex and threads

Mutex and threads

Exercice Multi-tasking under Zephyr OS/2

Remove the code for disabling interrupts and modify the Clock class for implementing a thread safe class using a Mutex instance.

Note that the use of a Mutex in our example is only possible only because the displayCurrentTime() and updateCurrentTime() are not executed within an ISR context, but rather are deferred to separate work queues.

Indeed, Mutex objects cannot be used from ISR context, since acquiring a mutex is a blocking operation.

Solution

The following steps are required:

  • Remove the code for disabling interrupts in all methods.
  • Add a Mutex as a data member of the Clock class.
  • Add the code for protecting access to _currentTime in the displayCurrentTime() and updateCurrentTime() methods.

The solution is given here:

clock_with_mutex.hpp
multi_tasking/src/clock.cpp
// Copyright 2025 Haute école d'ingénierie et d'architecture de Fribourg
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/****************************************************************************
 * @file main.cpp
 * @author Serge Ayer <serge.ayer@hefr.ch>
 *
 * @brief Implementation of the Clock class
 *
 * @date 2025-07-01
 * @version 1.0.0
 ***************************************************************************/

#include "clock_with_mutex.hpp"

// zephyr
#include <zephyr/logging/log.h>

// zpp_lib
#include "zpp_include/this_thread.hpp"

LOG_MODULE_DECLARE(multi_tasking, CONFIG_APP_LOG_LEVEL);

namespace multi_tasking {

Clock::Clock()
    : _displayQueue("CDQueue"),
      _displayWork(this, &Clock::displayCurrentTime),
      _updateQueue("TQueue"),
      _updateThread(zpp_lib::PreemptableThreadPriority::PriorityNormal, "TThread"),
      _updateWork(this, &Clock::updateCurrentTime) {}

zpp_lib::ZephyrResult Clock::start() {
  // Start a thread for running the _tickerQueue work queue.
  // Events are dispatched to the queue in the tickerUpdate() method called by the ticker.
  auto res = _updateThread.start(std::bind(&zpp_lib::WorkQueue::run, &_updateQueue));
  if (!res) {
    LOG_ERR("Cannot start ticker thread: %d", (int)res.error());
    return res;
  }

  // Call the updateFromTicker() method every second (from ISR context)
  TickerFunction updateFromTickerFunction = std::bind(&Clock::updateFromTicker, this);
  res = _updateTicker.attach(updateFromTickerFunction, clockUpdateTimeout);
  if (!res) {
    LOG_ERR("Cannot attach update ticker: %d", (int)res.error());
    return res;
  }

  // Call the displayFromTicker() method every second (from ISR context)
  TickerFunction displayFromTickerFunction = std::bind(&Clock::displayFromTicker, this);
  res = _displayTicker.attach(displayFromTickerFunction, clockDisplayTimeout);
  if (!res) {
    LOG_ERR("Cannot attach display ticker: %d", (int)res.error());
    return res;
  }

  // run the displayQueue from the calling thread
  _displayQueue.run();

  // should not get here
  __ASSERT(false, "Should not get here");

  return res;
}

void Clock::displayFromTicker() {
  // this method runs in ISR mode -> we cannot allocate memory or perform other forbidden
  // operations
  auto res = _displayQueue.call(_displayWork);
  __ASSERT(res, "Cannot call display on queue: %d", (int)res.error());
}

void Clock::displayCurrentTime() {
  DateTimeType dt = {0};

  auto res = _mutex.lock();
  __ASSERT(res, "Cannot lock mutex: %d", (int)res.error());

  dt.day  = _currentTime.day;
  dt.hour = _currentTime.hour;

  static constexpr std::chrono::microseconds waitTime = 1000ms;
  zpp_lib::ThisThread::busyWait(waitTime);

  dt.minute = _currentTime.minute;
  dt.second = _currentTime.second;

  res = _mutex.unlock();
  __ASSERT(res, "Cannot unlock mutex: %d", (int)res.error());

  printk("Day %u Hour %u min %u sec %u\n", dt.day, dt.hour, dt.minute, dt.second);
}

void Clock::updateFromTicker() {
  // this method runs in ISR mode -> we cannot allocate memory or perform other forbidden
  // operations
  //updateCurrentTime();
  auto res = _updateQueue.call(_updateWork);
  __ASSERT(res, "Cannot call update on queue: %d", (int)res.error());
}

void Clock::updateCurrentTime() {
  auto res = _mutex.lock();
  __ASSERT(res, "Cannot lock mutex: %d", (int)res.error());

  _currentTime.second +=
      std::chrono::duration_cast<std::chrono::seconds>(clockUpdateTimeout).count();

  if (_currentTime.second > 59) {
    _currentTime.second = 0;
    _currentTime.minute++;
    if (_currentTime.minute > 59) {
      _currentTime.minute = 0;
      _currentTime.hour++;
      if (_currentTime.hour > 23) {
        _currentTime.hour = 0;
        _currentTime.day++;
      }
    }
  }

  res = _mutex.unlock();
  __ASSERT(res, "Cannot unlock mutex: %d", (int)res.error());
}

}  // namespace multi_tasking

Mutex not allowed from ISR context

To experience the unallowed use of the mutex mechanism in an ISR, you may modify the callback used by the ticker as follows:

multi_tasking/src/clock.cpp
...

void Clock::updateFromTicker() {
  // this method runs in ISR mode -> we cannot allocate memory or perform other forbidden operations
  updateCurrentTime();
}...

With this change, the updateCurrentTime() method becomes the ISR of the ticker and is thus executed in ISR context. In this case, your program should halt with a message stating that using a mutex is not allowed in the ISR context.

Deadlock

Deadlocks can arise in situations where two or more threads wait for each other to complete the task. A deadlock is thus an infinite wait and it occurs when a thread enters a wait state because a shared resource that was requested is being held by another waiting thread, which in turn is waiting for another resource held by another waiting thread.

A number of solutions have been developed for analyzing and preventing deadlock situations. For analyzing situations where deadlocks may arise, Coffman proved that deadlock situation on a resource can arise if and only if all of the following conditions illustrated in the figure below hold simultaneously:

  1. Mutex: exclusive resource, non-shareable (Resource A and B in the figure below)
  2. Resource holding: request additional resources while holding one (Requesting resource B while holding Resource A, Resource B held by another process)
  3. No preemption: resource can not be de-allocated or forcibly removed (Resource B cannot be deallocated)
  4. Circular wait: circular dependency or a closed chain of dependency (A vs B, Process 1 vs Process 2).

Deadlock conditions

Deadlock conditions

Deadlock prevention works by preventing one of the four Coffman conditions from occurring:

  1. Removing the mutual exclusion condition means that no process will have exclusive access to a resource (called non-blocking synchronization algorithms).
  2. The hold and wait or resource holding conditions are changed by making processes ask for all the resources they will need before starting up (difficult to implement).
  3. Allowing the preemption condition will cause difficulty and the processing outcome may be inconsistent or thrashing may occur.
  4. The final condition is the circular wait condition. Approaches that avoid circular waits include disabling interrupts during critical sections.

A very simple example for demonstrating a deadlock condition is shown below:

multi_tasking/src/deadlock.cpp
multi_tasking/src/deadlock.cpp
// Copyright 2025 Haute école d'ingénierie et d'architecture de Fribourg
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/****************************************************************************
 * @file deadlock.cpp
 * @author Serge Ayer <serge.ayer@hefr.ch>
 *
 * @brief Implementation of the Deadlock class
 *
 * @date 2025-07-01
 * @version 1.0.0
 ***************************************************************************/

#include "deadlock.hpp"

// zephyr
#include <zephyr/logging/log.h>

LOG_MODULE_DECLARE(multi_tasking, CONFIG_APP_LOG_LEVEL);

namespace multi_tasking {

// static data member allocation
zpp_lib::Mutex Deadlock::_mutex[kNbrOfMutexes];

Deadlock::Deadlock(int index, const char* threadName)
    : _index(index),
      _thread(zpp_lib::PreemptableThreadPriority::PriorityNormal, threadName) {}

void Deadlock::start() {
  auto res = _thread.start(std::bind(&Deadlock::execute, this));
  __ASSERT(res, "Cannot start deadlock thread: %d", (int)res.error());
}

void Deadlock::wait() {
  auto res = _thread.join();
  __ASSERT(res, "Cannot join deadlock thread: %d", (int)res.error());
}

void Deadlock::execute() {
  // enter the first critical section
  auto res = _mutex[_index].lock();
  __ASSERT(res, "Cannot lock mutex: %d", (int)res.error());
  LOG_DBG("Thread %d entered critical section %d", _index, _index);

  // perform some operations
  zpp_lib::ThisThread::busyWait(kProcessingWaitTime);
  LOG_DBG("Thread %d processing in mutex %d done", _index, _index);

  // enter the second critical section
  int secondIndex = (_index + 1) % kNbrOfMutexes;
  LOG_DBG("Thread %d trying to enter critical section %d", _index, secondIndex);
  res = _mutex[secondIndex].lock();
  __ASSERT(res, "Cannot lock mutex: %d", (int)res.error());
  LOG_DBG("Thread %d entered critical section %d", _index, secondIndex);

  // perform some operations
  zpp_lib::ThisThread::busyWait(kProcessingWaitTime);
  LOG_DBG("Thread %d processing in mutex %d and %d done", _index, _index, secondIndex);

  // exit the second critical section
  res = _mutex[secondIndex].unlock();
  __ASSERT(res, "Cannot unlock mutex: %d", (int)res.error());

  // perform some operations
  zpp_lib::ThisThread::busyWait(kProcessingWaitTime);
  LOG_DBG("Thread %d processing in mutex %d done", _index, _index);

  // exit the first critical section
  res = _mutex[_index].unlock();
  __ASSERT(res, "Cannot unlock mutex: %d", (int)res.error());
}

}  // namespace multi_tasking
multi_tasking/src/deadlock.hpp
multi-tasking/main_deadlock.hpp
// Copyright 2025 Haute école d'ingénierie et d'architecture de Fribourg
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/****************************************************************************
 * @file deadlock.hpp
 * @author Serge Ayer <serge.ayer@hefr.ch>
 *
 * @brief Declaration of the Deadlock class
 *
 * @date 2025-07-01
 * @version 1.0.0
 ***************************************************************************/

#pragma once

// stl
#include <chrono>

// zpp_lib
#include "zpp_include/mutex.hpp"
#include "zpp_include/this_thread.hpp"
#include "zpp_include/thread.hpp"

namespace multi_tasking {

using namespace std::literals;

class Deadlock {
 public:
  Deadlock(int index, const char* threadName);

  void start();
  void wait();

 private:
  void execute();

  // time that the threads should spend processing (e.g. wait in our case)
  static constexpr std::chrono::microseconds kProcessingWaitTime = 1000000us;
  static constexpr int kNbrOfMutexes                             = 2;
  uint8_t _index;
  zpp_lib::Thread _thread;
  // the mutex must be declared as static for being a class instance
  static zpp_lib::Mutex _mutex[kNbrOfMutexes];
};

}  // namespace multi_tasking

In this example, we use two Mutex instances for synchronizing the execution of two threads. The program implements the circular wait as described in the conditions and in the figure above. The output of the program is shown below. As you can observe Thread 0 is blocked waiting for mutex 1 (owned by Thread 1) and Thread 1 is blocked waiting for mutex 0 (owned by Thread 0), since no thread can enter the second critical section.

Deadlock program

[DBG ][main]: Deadlock program started
[DBG ][Deadlock]: Thread Thread0 started with status 0
[DBG ][Deadlock]: Thread Thread1 started with status 0
[DBG ][Deadlock]: Thread 0 entered critical section 0
[DBG ][Deadlock]: Thread 1 entered critical section 1
[DBG ][Deadlock]: Thread 0 processing in mutex 0 done
[DBG ][Deadlock]: Thread 0 trying to enter critical section 1
[DBG ][Deadlock]: Thread 1 processing in mutex 1 done
[DBG ][Deadlock]: Thread 1 trying to enter critical section 0

Shared Data and Semaphore

Is a Mutex a Binary Semaphore?

Like a mutex, a semaphore can manage and control access to shared resources. Unlike a mutex, it does not have the concept of an owner (a mutex is owned by the thread that entered the critical section). On the contrary, a semaphore defines the maximum number of threads that are allowed to use a particular resource at the same time. A mutex is a locking mechanism while a semaphore is a signaling mechanism.

It is important to mention that mutex and semaphore are different concepts and that it is incorrect to think of a mutex as a binary semaphore. As explained in Mutexes and Semaphores Demystified, a mutex is meant to be taken and released, always in that order, by each task that uses the shared resource. By contrast, tasks that use semaphores either signal or wait - not both. In such, they are often related to producer-consumer problems. Also, mutex objects cannot be used within an ISR, while semaphores can be used to signal from ISR context to another thread - signaling is not a blocking mechanism. This makes semaphores a powerful mechanism for signaling from ISR context.

Semaphore for Access to Shared Resources

For illustrating the use of semaphores, we demonstrate a typical producer-consumer problem. The problem can be solved using different Mbed OS concurrency primitives and we will also evaluate their advantages and disadvantages of each approach.

The problem is stated as follows:

  • The scenario is executed by two separate tasks/threads: the producer thread implemented in the Producer class (_producerThread) and the consumer thread implemented in the Consumer class (_consumerThread).
  • Both threads share a FIFO buffer implemented in the Buffer class (_buffer). The Buffer class is a C++ template class that can store elements of type T.
  • One producer repeatedly produces values at a random rate (Producer::produce() method). It then appends the produced value to the FIFO buffer (Buffer::append() method).
  • One consumer repeatedly extracts values from the FIFO buffer (Buffer::extract() method) and consume them (Consumer::consume() method).
  • The FIFO buffer has no mutual exclusion and no under- or overflow protection. This means that the consumer may consume values that have not yet been produced, and that the producer may overwrite values that have not yet been consumed.
  • To simulate computing time required for producing/appending and consuming/extracting data to/from the buffer, calls to the busyWait() method are added to the Buffer::extract() and Buffer::append() methods.
  • LED1 is used to indicate when data is being appended to the buffer, while LED2 is used to indicate when data is being extracted from the buffer. Both LEDs are on when either event occurs.

The basic implementation of the Buffer class is given below:

buffer.hpp
buffer.hpp
// Copyright 2025 Haute école d'ingénierie et d'architecture de Fribourg
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/****************************************************************************
 * @file buffer.hpp
 * @author Serge Ayer <serge.ayer@hefr.ch>
 *
 * @brief Declaration/Implementation of the Buffer class
 *
 * @date 2025-07-01
 * @version 1.0.0
 ***************************************************************************/

#pragma once

// zephyr
#include <zephyr/random/random.h>

// zpp_lib
#include "zpp_include/digital_out.hpp"
#include "zpp_include/this_thread.hpp"

namespace multi_tasking {

using namespace std::literals;

static constexpr uint8_t kLedOff = 0;
static constexpr uint8_t kLedOn  = 1;

template <typename T>
class Buffer {
 public:
  Buffer()
      : _producerLed(zpp_lib::DigitalOut::PinName::LED0, kLedOff),
        _consumerLed(zpp_lib::DigitalOut::PinName::LED1, kLedOff) {}

  uint32_t append(const T& data) {
    _producerLed = kLedOn;

    _buffer[_producerIndex] = data;
    uint32_t index          = _producerIndex;
    _producerIndex          = (_producerIndex + 1) % kBufferSize;
    zpp_lib::ThisThread::busyWait(computeRandomWaitTime(kApppendWaitTime));
    _producerLed = kLedOff;

    return index;
  }

  uint32_t extract(T& data) {
    _consumerLed = kLedOn;

    data           = _buffer[_consumerIndex];
    uint32_t index = _consumerIndex;
    _consumerIndex = (_consumerIndex + 1) % kBufferSize;
    zpp_lib::ThisThread::busyWait(computeRandomWaitTime(kExtractWaitTime));
    _consumerLed = kLedOff;

    return index;
  }

  std::chrono::milliseconds computeRandomWaitTime(
      const std::chrono::milliseconds& waitTime) {
    return std::chrono::milliseconds((sys_rand32_get() % waitTime.count()) +
                                     waitTime.count());
  }

 private:
  static constexpr std::chrono::milliseconds kApppendWaitTime = 500ms;
  static constexpr std::chrono::milliseconds kExtractWaitTime = 500ms;
  static constexpr uint8_t kBufferSize                        = 10;
  zpp_lib::DigitalOut _producerLed;
  zpp_lib::DigitalOut _consumerLed;
  T _buffer[kBufferSize]  = {0};
  uint32_t _producerIndex = 0;
  uint32_t _consumerIndex = 0;
};

}  // namespace multi_tasking

The code of the Producerclass that uses the Buffer class is:

producer.hpp
producer.hpp
// Copyright 2025 Haute école d'ingénierie et d'architecture de Fribourg
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/****************************************************************************
 * @file buffer.hpp
 * @author Serge Ayer <serge.ayer@hefr.ch>
 *
 * @brief Declaration/Implementation of the Producer class
 *
 * @date 2025-07-01
 * @version 1.0.0
 ***************************************************************************/
#pragma once

// zephyr
#include <zephyr/random/random.h>

// stl
#include <iostream>

// zpp_lib
#include "zpp_include/thread.hpp"

// local
#include "buffer_solution.hpp"

namespace multi_tasking {

template <typename T, class DataGenerator>
class Producer {
 public:
  explicit Producer(Buffer<T>& buffer)
      : _buffer(buffer),
        _producerThread(zpp_lib::PreemptableThreadPriority::PriorityNormal,
                        "ProducerThread") {}

  void start() {
    auto res = _producerThread.start(std::bind(&Producer::producerMethod, this));
    __ASSERT(res, "Cannot start producer thread: %d", (int)res.error());
  }

  void wait() {
    auto res = _producerThread.join();
    __ASSERT(res, "Cannot join producer thread: %d", (int)res.error());
  }

 private:
  void producerMethod() {
    while (true) {
      T producerData = DataGenerator::produceNextValue();
      uint32_t index = _buffer.append(producerData);
      std::cout << "Producer data is " << producerData << " (index in buffer " << index
                << ")" << std::endl;
    }
  }

 private:
  static constexpr std::chrono::milliseconds kProduceWaitTime = 500ms;
  Buffer<T>& _buffer;
  zpp_lib::Thread _producerThread;
};

}  // namespace multi_tasking

The code of the Consumerclass that uses the Buffer class is:

consumer.hpp
consumer.hpp
// Copyright 2025 Haute école d'ingénierie et d'architecture de Fribourg
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/****************************************************************************
 * @file buffer.hpp
 * @author Serge Ayer <serge.ayer@hefr.ch>
 *
 * @brief Declaration/Implementation of the Producer class
 *
 * @date 2025-07-01
 * @version 1.0.0
 ***************************************************************************/
#pragma once

// stl
#include <iostream>

// zpp_lib
#include "zpp_include/thread.hpp"

// local
#include "buffer_solution.hpp"

namespace multi_tasking {

template <typename T>
class Consumer {
 public:
  explicit Consumer(Buffer<T>& buffer)
      : _buffer(buffer),
        _consumerThread(zpp_lib::PreemptableThreadPriority::PriorityNormal,
                        "ConsumerThread") {}
  void start() {
    auto res = _consumerThread.start(std::bind(&Consumer::consumerMethod, this));
    __ASSERT(res, "Cannot start consumer thread: %d", (int)res.error());
  }
  void wait() {
    auto res = _consumerThread.join();
    __ASSERT(res, "Cannot join consumer thread: %d", (int)res.error());
  }

 private:
  void consume(T data) {
    // does nothing
  }
  void consumerMethod() {
    while (true) {
      T consumerData;
      uint32_t index = _buffer.extract(consumerData);
      consume(consumerData);
      std::cout << "Consumer data is " << consumerData << " (index in buffer " << index
                << ")" << std::endl;
    }
  }

 private:
  Buffer<T>& _buffer;
  zpp_lib::Thread _consumerThread;
};

}  // namespace multi_tasking

The code to use all classes and simulate a producer/consumer scenario is given in the main program.

To gain an in-depth understanding of the program, proceed as follows and note the following:

  • Start the program with the producer running only (comment the call to consumer.start() in the main function). Make sure to run the program, describe what you observe and give an explanation of your observations. In particular, answer the following questions:

    1. Which led is blinking?
    2. How is the _producerIndex value of the Buffer class changing?
    3. Can the producer overwrite values that have not be consumed?
  • Do the opposite and start the program with the consumer running only. Answer the same corresponding questions.

  • Then, start the program with both the producer and consumer running. Answer the same questions again. Experiment with different time values, simulating both cases where production is faster than consumption and where consumption is faster than production, by making kApppendWaitTime or kExtractWaitTime much smaller than the other waiting times.

Exercice Multi-tasking under Zephyr OS/3

Answer all questions for each different scenario above.

Solution

Answers for each scenario

  • Producer only:

    • The producer led only.
    • _producerIndex is increasing by 1 at each append, modulo the buffer size.
    • Values that have not been consumed yet may be overwritten.
  • Consumer only:

    • The consumer led only.
    • _consumerIndex is increasing by 1 at each extract.
    • Values that have not been produced yet may be consumed.
  • Producer and consumer, producer faster:

    • Both leds.
    • Over-production may be observed.
  • Producer and consumer, consumer faster:

    • Both leds.
    • Over-consumption may be observed.

From the experiments and observations made above, one should be able to make the following conclusions:

  • The access to the buffer should be protected and one should ensure that only one thread can access the buffer at the same time. This mechanism can be implemented with a mutex as seen in a previous section of this codelab.
  • There should be a mechanism for handling buffer over-consumption or over-production. In other words, the consumer should not be able to consume an element when the buffer is empty and the producer should not be able to produce an element when the buffer is full. This control mechanism should move the threads in a waiting state until the requested operation becomes possible. This is where semaphores enter the game!

The Semaphore`` mechanism allows the producer thread to signal when an element is added to the buffer and the consumer thread to wait for at least one element to be present in the buffer. TheSemaphore` also implements a counting mechanism.

For implementing the changes documented above, proceed with the following steps:

  • Add a Mutex in the Buffer class and make sure that accesses to _buffer and indices are protected.

    1. Run the program after implementing this change and observe whether the buffer over-consumption or -production issues that you encountered in the previous version still arise.
    2. Observe the led for checking that the access to the critical sections is exclusive - this can be observed more easily only when using large constant time values.
  • Implement a mechanism for making sure that there will be at least one element in the buffer before the consumer can consume it. This can be done by using a Semaphore with the initial value corresponding to no element produced. It is important to place the code for signaling and waiting for the semaphore in the correct places:

    1. The producer and consumer must either signal or wait but not both.
    2. The semaphore calls must be placed correctly with respect to the mutex lock/unlock calls.
  • Modify the waiting times again and verify whether the buffer underflow and overflow issues have been solved.

  • You should have observed that the over-consumption issue is solved - no consumption possible when the buffer is empty. For solving the over-production issue, make sure that no element can be produced when the buffer is full. This can be implemented with another Semaphore`` initialized at the buffer capacity and with reversed roles - the producer waits for the semaphore while the consumer signals it. Add another semaphore in theBuffer` class and make sure that both over-consumption and -production problems are resolved.

Exercice Multi-tasking under Zephyr OS/4

Implement a fully functional Buffer class, based on the instructions above.

Solution
multi_tasking/src/buffer.hpp
// Copyright 2025 Haute école d'ingénierie et d'architecture de Fribourg
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/****************************************************************************
 * @file buffer.hpp
 * @author Serge Ayer <serge.ayer@hefr.ch>
 *
 * @brief Declaration/Implementation of the Buffer class
 *
 * @date 2025-07-01
 * @version 1.0.0
 ***************************************************************************/

#pragma once

// zephyr
#include <zephyr/random/random.h>

// zpp_lib
#include "zpp_include/digital_out.hpp"
#include "zpp_include/mutex.hpp"
#include "zpp_include/semaphore.hpp"
#include "zpp_include/this_thread.hpp"

namespace multi_tasking {

using namespace std::literals;

static constexpr uint8_t kLedOff = 0;
static constexpr uint8_t kLedOn  = 1;

template <typename T>
class Buffer {
 public:
  Buffer()
      : _producerLed(zpp_lib::DigitalOut::PinName::LED0, kLedOff),
        _consumerLed(zpp_lib::DigitalOut::PinName::LED1, kLedOff) {}

  uint32_t append(const T& data) {
    // make sure that we can produce without overflow
    auto res = _inSemaphore.acquire();
    __ASSERT(res, "Cannot acquire inSemaphore: %d", (int)res.error());

    // lock buffer
    res = _producerConsumerMutex.lock();
    __ASSERT(res, "Cannot lock mutex: %d", (int)res.error());

    _producerLed            = kLedOn;
    _buffer[_producerIndex] = data;
    uint32_t index          = _producerIndex;
    _producerIndex          = (_producerIndex + 1) % kBufferSize;

    zpp_lib::ThisThread::busyWait(computeRandomWaitTime(kApppendWaitTime));
    _producerLed = kLedOff;

    // unlock buffer
    res = _producerConsumerMutex.unlock();
    __ASSERT(res, "Cannot unlock mutex: %d", (int)res.error());

    // tell that one element is available for consumer
    res = _outSemaphore.release();
    __ASSERT(res, "Cannot release outSemaphore: %d", (int)res.error());

    return index;
  }

  uint32_t extract(T& data) {
    // make sure that we can consume without underflow
    auto res = _outSemaphore.acquire();
    __ASSERT(res, "Cannot acquire outSemaphore: %d", (int)res.error());

    // lock buffer
    res = _producerConsumerMutex.lock();
    __ASSERT(res, "Cannot lock mutex: %d", (int)res.error());

    _consumerLed   = kLedOn;
    data           = _buffer[_consumerIndex];
    uint32_t index = _consumerIndex;
    _consumerIndex = (_consumerIndex + 1) % kBufferSize;

    zpp_lib::ThisThread::busyWait(computeRandomWaitTime(kExtractWaitTime));
    _consumerLed = kLedOff;

    // unlock buffer
    res = _producerConsumerMutex.unlock();
    __ASSERT(res, "Cannot unlock mutex: %d", (int)res.error());

    // tell that one element is available for producer
    res = _inSemaphore.release();
    __ASSERT(res, "Cannot release inSemaphore: %d", (int)res.error());

    return index;
  }

  std::chrono::milliseconds computeRandomWaitTime(
      const std::chrono::milliseconds& waitTime) {
    return std::chrono::milliseconds((sys_rand32_get() % waitTime.count()) +
                                     waitTime.count());
  }

 private:
  static constexpr std::chrono::milliseconds kApppendWaitTime = 500ms;
  static constexpr std::chrono::milliseconds kExtractWaitTime = 500ms;
  static constexpr uint8_t kBufferSize                        = 10;
  zpp_lib::DigitalOut _producerLed;
  zpp_lib::DigitalOut _consumerLed;
  zpp_lib::Mutex _producerConsumerMutex;
  zpp_lib::Semaphore _outSemaphore{0, kBufferSize - 1};
  zpp_lib::Semaphore _inSemaphore{kBufferSize - 1, kBufferSize - 1};
  T _buffer[kBufferSize]  = {0};
  uint32_t _producerIndex = 0;
  uint32_t _consumerIndex = 0;
};

}  // namespace multi_tasking

Exercice Multi-tasking under Zephyr OS/5

Once you have implemented a fully functional Buffer class, with the correct use of semaphores, consider removing the call to Semaphore::release() or Semaphore::acquire(). Explain what would happen in each case.

Solution

There are two calls to Semaphore::release() or Semaphore::acquire():

  • remove call to Semaphore::acquire() in Buffer::append(): this call make sure that there is no over-production. Removing the call allows it.
  • remove call to Semaphore::release() in Buffer::append(): this call make sure that the production of an element is signaled. Removing the call will prevent consumption of elements.
  • remove call to Semaphore::acquire() in Buffer::extract(): this call make sure that there is no over-consumption. Removing the call allows it.
  • remove call to Semaphore::release() in Buffer::extract(): this call make sure that the consumption of an element is signaled. Removing the call will prevent production of elements after it has been filled once.

Built-in Mechanisms for Passing Data

It is worth mentioning at this point that RTOS usually provide other built-in mechanisms for passing data between several tasks/threads. Several mechanisms are implemented in Zephyr RTOS:

For the purposes of this codelab, zpp_lib encapsulates the message queue mechanism into a C++ class. The zpp_lib::MessageQueue class is a template class that allows you to create message queues with T as the type of messages being passed and queueSize as the size of the queue.

Shared Data and MessageQueue

As mentioned previously, RTOS typically offer higher-level abstractions for implementing buffers and data-passing scenarios. Therefore, it is thus possible to implement the same Buffer class as in the previous step using these higher-level mechanisms.

The goal here is to implement the Buffer with a MessageQueue thus replacing all internal buffer, mutex and semaphore related fields.

Interestingly, all the changes required to make the implementation correct were made solely in the Buffer class. This shows that encapsulating functionality within a well-designed API facilitates the development, debugging, and maintenance of a well-designed program.

Furthermore, template classes allow one class to be specified for passing data of any type, as demonstrated in the demo program:

  • In the “main.cpp” file, several classes are defined for generating data (i.e. RandomIntGenerator, RandomDoubleGenerator and RandomRectGenerator). This demonstrates how a template class can be used to implement specific behaviour, such as the produceNextValue() method within the Producer class.
  • In the ‘main.cpp’ file, the operator for printing ‘Rect’ values to the console is also defined (i.e. ‘std::ostream& operator« (std::ostream& os, const Rect& rect)’). This enables the ‘Producer’ and ‘Consumer’ classes to print data of different types without amending the classes’ code.

Exercice Multi-tasking under Zephyr OS/6

Modify the Buffer class implementation to use the zpp_lib::MessageQueue mechanism.

Solution

The Buffer class rewritten to use zpp_lib::MessageQueue is given below:

multi_tasking/src/buffer.hpp
    // Copyright 2025 Haute école d'ingénierie et d'architecture de Fribourg
    //
    // Licensed under the Apache License, Version 2.0 (the "License");
    // you may not use this file except in compliance with the License.
    // You may obtain a copy of the License at
    //
    //     http://www.apache.org/licenses/LICENSE-2.0
    //
    // Unless required by applicable law or agreed to in writing, software
    // distributed under the License is distributed on an "AS IS" BASIS,
    // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    // See the License for the specific language governing permissions and
    // limitations under the License.

    /****************************************************************************
     * @file buffer_queue.hpp
     * @author Serge Ayer <serge.ayer@hefr.ch>
     *
     * @brief Declaration/Implementation of the Buffer class (using MessageQueue)
     *
     * @date 2025-07-01
     * @version 1.0.0
     ***************************************************************************/

    #pragma once
    // zephyr
    #include <zephyr/random/random.h>

    // zpp_lib
    #include "zpp_include/digital_out.hpp"
    #include "zpp_include/message_queue.hpp"
    #include "zpp_include/this_thread.hpp"

    namespace multi_tasking {

    using namespace std::literals;

    static constexpr uint8_t kLedOff = 0;
    static constexpr uint8_t kLedOn  = 1;

    class Buffer {
     public:
      Buffer()
          : _producerLed(zpp_lib::DigitalOut::PinName::LED0, kLedOff),
            _consumerLed(zpp_lib::DigitalOut::PinName::LED1, kLedOff) {}

      uint32_t append(uint32_t data) {
        _producerLed = kLedOn;

        zpp_lib::ThisThread::busyWait(computeRandomWaitTime(kApppendWaitTime));

        std::chrono::milliseconds timeout = std::chrono::milliseconds::max();
        auto res                          = _messageQueue.try_put_for(timeout, data);
        if (res.has_error()) {
          __ASSERT(false, "Error getting message from queue: %d", (int)res.error());
          return 0;
        }
        if (!res) {
          __ASSERT(false, "Timeout when getting message from queue");
          return 0;
        }

        _producerLed = kLedOff;

        return _producerIndex++;
      }

      uint32_t extract(uint32_t& data) {
        _consumerLed = kLedOn;

        zpp_lib::ThisThread::busyWait(computeRandomWaitTime(kExtractWaitTime));
        std::chrono::milliseconds timeout = std::chrono::milliseconds::max();
        auto res                          = _messageQueue.try_get_for(timeout, data);
        if (res.has_error()) {
          __ASSERT(false, "Error getting message from queue: %d", (int)res.error());
          return 0;
        }
        if (!res) {
          __ASSERT(false, "Timeout when getting message from queue");
          return 0;
        }

        _consumerLed = kLedOff;
        return _consumerIndex++;
      }

      std::chrono::milliseconds computeRandomWaitTime(
          const std::chrono::milliseconds& waitTime) {
        return std::chrono::milliseconds((sys_rand32_get() % waitTime.count()) +
                                         waitTime.count());
      }

     private:
      static constexpr std::chrono::milliseconds kApppendWaitTime = 500ms;
      static constexpr std::chrono::milliseconds kExtractWaitTime = 500ms;
      static constexpr uint8_t kBufferSize                        = 10;
      zpp_lib::DigitalOut _producerLed;
      zpp_lib::DigitalOut _consumerLed;
      zpp_lib::MessageQueue<uint32_t, kBufferSize> _messageQueue;
      uint32_t _producerIndex = 0;
      uint32_t _consumerIndex = 0;
    };

    }  // namespace multi_tasking

Wrap-Up

By the end of this codelab, you should have achieved the following:

  • You understand the basic principles behind the Zephyr RTOS schedulers.
  • You know how to monitor the different threads of a multi-tasking application.
  • You understand how threads of different or identical priorities interact with each other.
  • You understand the principles of deadlock.
  • You understand the following multi-tasking mechanisms:

    • Mutex
    • Semaphore
    • Data passing mechanisms
    • You understand how template classes can be used to create a single class for exchanging data of different types.