Skip to content

Bike Computer Part II

Introduction

Event-driven model

In part 1 of this codelab, we have implemented a version of the bike computer program that uses cyclic static scheduling using a Super-Loop mechanism. This implementation used polling for checking the button status. The limitations of this approach have been demonstrated and the need for an event-driven approach has been made clear.

In this part of the codelab, we will study how to implement an event-driven model for non periodic tasks. We will compare the event response time to the ones obtained in part 1 of the codelab.

Before starting the work on this codelab, make sure you carefully read the deliverable sections of Project Phase 2.

What you’ll build

In this codelab, you’re going to program a simple bike computer program that is handling non periodic events using an event-driven approach, with static cyclic scheduling of the periodic tasks.

What you’ll learn

  • How to use and log statistics about CPU usage.
  • How to use the Work and WorkQueue API (from zpp_lib) for programming periodic tasks.
  • How to develop a simple embedded program using an event-driven approach.

What you’ll need

  • The Zephyr Development Environment for developing and debugging C++ code snippets.
  • The Bike computer - part I codelab is a prerequisite to this codelab.

Start from BikeComputer Part 1

To implement the next version of the BikeComputer, start from the version developed in Part 1 of the codelab:

  • Copy the code from the “static_scheduling” folder into a new subfolder named “static_scheduling_with_event”. The code in this new folder will contain the changes described in this codelab.

  • Modify the namespace in all classes from static_scheduling to static_scheduling_with_event.

  • Modify your main program to use this new implementation (e.g. include path or namespace usage).

  • Compile and run your BikeComputer program. The result should be the same as in Part 1.

Implement the BikeComputer program using Time-Triggered Cyclic Executive Scheduling

The first change compared to Part 1 relates to the implementation of cyclic scheduling. Rather than using a Super-Loop, the program will now be implemented using TTCE Cyclic Scheduling. Task scheduling remains the same as that implemented in the Super-Loop, but will now be triggered using timers.

The TTCE mechanism is implemented using the TTCE class below:

bike_computer/src/common/ttce.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 ttce.hpp
 * @author Serge Ayer <serge.ayer@hefr.ch>
 *
 * @brief TTCE implementation
 *
 * @date 2025-07-01
 * @version 1.0.0
 ***************************************************************************/

#pragma once

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

// std
#include <chrono>
#include <string>

// zpp_lib
#include "zpp_include/clock.hpp"
#include "zpp_include/non_copyable.hpp"
#include "zpp_include/zephyr_result.hpp"

namespace bike_computer {

template <typename F, uint16_t NbrOfMinorCycles, uint16_t MaxMinorCycleSize>
class TTCE : private zpp_lib::NonCopyable<TTCE<F, NbrOfMinorCycles, MaxMinorCycleSize>> {
 public:
  explicit TTCE(std::chrono::milliseconds minorCycle) : _minorCycle(minorCycle) {
    k_timer_init(&_timer, &TTCE::_thunk, nullptr);
    // specify this instance as user data
    // this cast is ugly but the only way to pass a reference to this instance to the
    // timer
    // cppcheck-suppress cstyleCast
    _timer.user_data = (void*)this;  // NOLINT(readability/casting)
    k_work_init(&_work, &TTCE::_workHandler);
    // initialize the work queue
    k_work_queue_init(&_workQueue);
  }

  void start() {
    // first start the timer
    k_timeout_t period = zpp_lib::milliseconds_to_ticks(_minorCycle);
    k_timer_start(&_timer, K_SECONDS(0), period);

    // then run the work queue
    struct k_work_queue_config cfg = {
        .name     = "TTCE Work Queue",
        .no_yield = true,
    };
    _isStarted = true;
    k_work_queue_run(&_workQueue, &cfg);
  }

  void stop() {
    // first stop the time
    k_timer_stop(&_timer);
    // drain the work queue
    auto rc = k_work_queue_drain(&_workQueue, true);
    if (rc < 0) {
      __ASSERT(false, "k_work_queue_drain failed with code %d", rc);
    }
    rc = k_work_queue_stop(&_workQueue, K_SECONDS(1));
    if (rc != 0) {
      __ASSERT(false, "k_work_queue_stop failed with code %d", rc);
    }
  }

  bool isStarted() { return _isStarted; }

  [[nodiscard]] zpp_lib::ZephyrResult addTask(uint16_t minorCycleIndex, F f) {
    zpp_lib::ZephyrResult res;
    if (minorCycleIndex >= NbrOfMinorCycles) {
      __ASSERT(false, "Invalid minor cycle index %d", minorCycleIndex);
      res.assign_error(zpp_lib::ZephyrErrorCode::k_inval);
      return res;
    }
    if (_nbrOfTasksInMinorCycle[minorCycleIndex] >= MaxMinorCycleSize) {
      __ASSERT(false,
               "Too many tasks in minor cycle %d: %d",
               minorCycleIndex,
               _nbrOfTasksInMinorCycle[minorCycleIndex] + 1);
      res.assign_error(zpp_lib::ZephyrErrorCode::k_inval);
      return res;
    }

    _tasks[minorCycleIndex][_nbrOfTasksInMinorCycle[minorCycleIndex]] = f;
    _nbrOfTasksInMinorCycle[minorCycleIndex]++;

    return res;
  }

 private:
  static void _thunk(struct k_timer* timer_id) {
    // submit the periodic TTCE task
    if (timer_id != nullptr) {
      // get instance from user data
      // this cast is ugly but the only way to pass a reference to this instance to the
      // timer
      // cppcheck-suppress cstyleCast
      TTCE* pTTCE = (TTCE*)timer_id->user_data;  // NOLINT(readability/casting)
      auto ret    = k_work_submit_to_queue(&pTTCE->_workQueue, &pTTCE->_work);
      if (ret != 0 && ret != 1 && ret != 2) {
        __ASSERT(false, "Failed to submit work: %d", ret);
        return;
      }
    }
  }

  static void _workHandler(struct k_work* item) {
    // this ugly casting is the simplest way of getting the information
    // we need in the _workHandler method
    // CASTING IS POSSIBLE ONLY WHEN k_work IS THE FIRST ATTRIBUTE IN THE CLASS
    // cppcheck-suppress dangerousTypeCast
    TTCE* pTTCE = (TTCE*)item;  // NOLINT(readability/casting)

    // execute tasks based on schedule table
    for (uint16_t taskIndex = 0; taskIndex < MaxMinorCycleSize; taskIndex++) {
      if (pTTCE->_tasks[pTTCE->_minorCycleIndex][taskIndex] != nullptr) {
        pTTCE->_tasks[pTTCE->_minorCycleIndex][taskIndex]();
      }
    }
    pTTCE->_minorCycleIndex = (pTTCE->_minorCycleIndex + 1) % NbrOfMinorCycles;
  }

  // _work MUST be the first attribute
  struct k_work _work;
  struct k_work_q _workQueue;
  bool _isStarted = false;
  struct k_timer _timer;
  std::chrono::milliseconds _minorCycle;
  uint16_t _minorCycleIndex                          = 0;
  F _tasks[NbrOfMinorCycles][MaxMinorCycleSize]      = {nullptr};
  uint16_t _nbrOfTasksInMinorCycle[NbrOfMinorCycles] = {0};
};

}  // namespace bike_computer

To understand how the TTCE class works, you need to understand the following:

  • TTCE is a template class with three template parameters:

    • typename F is a type template parameter. F is a callable type meaning that one can call f() when f is an instance of type F.
    • uint16_t NbrOfMinorCycles is a constant template parameter, which specifies the number of minor cycles in the major cycle.
    • uint16_t MaxMinorCycleSize is a constant template parameter, which specifies the maximum number of tasks in each minor cycle.
  • The TTCE constructor performs the following actions:

    • It initializes the _minorCycle data member. This data member represents the duration of the minor cycle in milliseconds.
    • It initializes the _timer data member. The timer will call the TTCE::_thunk static method at regular time intervals. The timer is started in the TTCE::start() method. The k_timer data structure contains a user_data field that is used here to store a reference to the TTCE instance.
    • It initializes the _work data member. It allows to set the handler function that will be called when a work is submitted to a work queue. Here, the handler function is the static method TTCE::_workHandler. See below the description of the TTCE::_thunk() and TTCE::_workHandler() methods.
    • It initializes the _workQueue data member.
  • The TTCE::start() method performs the following actions:

    • It starts the timer at the _minorCycle period.
    • It runs the _workQueue work queue. It is important to note that the work queue will be run forever and that the k_work_queue_run call will not return, unless the work queue is stopped by calling the TTCE::stop() method. This means that the thread executing the TTCE::start() method will run the work queue forever and will not execute anything else. TTCE::stop() must be executed from another thread.
  • The TTCE::stop() method performs the following actions:

    • It stops the timer _timer.
    • It drains and stops the work queue _workQueue.
    • As stated above, it is important to note that the TTCE::stop() method must be called from a different thread than the thread calling TTCE::start().
  • The TTCE::addTask() method enables the addition of a task to a specific minor cycle. The task is specified as an instance of F. Tasks are added in FIFO order.

  • The private TTCE::_thunk() method is called by the timer and it performs the following actions:

    • It gets the TTCE instance from the user_data void pointer. This pointer is set in the class constructor.
    • Using the TTCE instance, it does submit the work to the work queue. This will invoke the _workHandler static method.
  • The private TTCE::_workHandler() method is invoked by the work queue and it performs the following actions:

    • It gets the TTCE instance from the k_work* parameter that is passed as parameter. Each time the work queue invokes the work handler attached to the work item, a pointer to the work item is passed to the work handler function. Since _work is the first attribute, it is possible to cast the work item pointer to a pointer to the containing class.
  • It is important to note that the C-style castings used in the TTCE class are used because no more robust and elegant solution exists. This is imposed by the Zephyr RTOS timer and work queue APIs.

Before proceeding further, ensure that you understand the basic principles of the TTCE class. The next step is to integrate it into your BikeComputer::start() method to replace the Super-Loop mechanism, as follows:

  • In C++, a class template is not a compilation unit in itself; it needs to be instantiated. This means that you must declare a variable with the template parameters specified. The code below demonstrates the declaration of the variable gTTCE that instantiates the template class TTCE. In this instance, the template parameter F is defined as a function with the signature void(), and kNbrOfMinorCycles and kMaxNbrOfTasksInMinorCycles are constants that define the TTCE parameters.

    using VoidFunction = std::function<void()>;
    static TTCE<VoidFunction, kNbrOfMinorCycles, kMaxNbrOfTasksInMinorCycles> gTTCE(kMinorCycle);
    
    You must define such a variable with the correct TTCE parameters.

  • After creating the variable, replace each call to a task function with a call to TTCE::addTask. The parameter F to be passed to the addTask() method is the task function applied to the BikeSystem instance, which is declared as std::bind(&BikeSystem::gearTask, this) for instance. For correct calls to TTCE::addTask, the task distribution over minor cycles needs to be known and applied correctly.

  • After adding all the tasks, initialize the task manager phase using the _taskManager.initializePhase() method and start your TTCE instance.

At this point, your BikeComputer program should now behave in the same way as it did with the Super-Loop implementation.

Use zpp_lib::Utils::logCPULoad() to Log CPU statistics

In order to understand the behaviour of the BikeComputer program, it is helpful to understand its CPU load. The zpp_lib library provides a wrapper to the Zephyr RTOS cpu_load_get() function to compute and log cpu load in the console. You may call this wrapper function with:

#ifdef CONFIG_CPU_LOAD
#include "zpp_include/utils.hpp"
#endif
...
#ifdef CONFIG_CPU_LOAD
zpp_lib::Utils::logCPULoad();
#endif
Note that you must also modify your prj.conf file to define the CONFIG_CPU_LOAD=y symbol. With the TTCE implementation, the call to zpp_lib::Utils::logCPULoad() must be implemented by adding one additional task to one minor cycle.

Question 1

If you log CPU statistics at the end of every major cycle (in the super-loop), what CPU usage do you observe? How can you explain the observed CPU uptime?

Reduce CPU usage with sleep calls rather than busy wait

To match the expected task periods, the TaskManager::simulateComputationTime() method implements a busy wait mechanism. With this mechanism, the CPU remains active while waiting for a given time to elapse. This results in higher-than-necessary CPU usage. This can be improved by replacing the busy wait implementation with calls to the zpp_lib::ThisThread::sleep_for()method. The parameter passed to the method should specify the remaining time to be spent in the method specified as std::chrono::milliseconds.

Question 2

If you run the program after the change from busy wait to sleep calls, what CPU usage do you observe? How can you explain the observed CPU uptime?

Test your TTCE Implementation

To test your TTCE implementation, add the test program given below in the “bike_computer/tests/bike_computer/bike_system_part2” folder and run the corresponding west twister command. The test program now consists of two test cases and the two test cases should run successfully.

test TTCE Implementation
bike_computer/tests/bike_computer/bike_system_part2/src/test_bike_system_part2.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 test_bike_system_part2.cpp
 * @author Serge Ayer <serge.ayer@hefr.ch>
 *
 * @brief Test program for the BikeSystem class (codelab part 2)
 *
 * @date 2025-07-01
 * @version 1.0.0
 ***************************************************************************/

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

// std
#include <chrono>
#include <cstdio>

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

// bike computer
#include "static_scheduling_with_event/bike_system.hpp"

LOG_MODULE_REGISTER(bike_system, CONFIG_APP_LOG_LEVEL);

// for ms or s literals
using namespace std::literals;

static constexpr std::chrono::milliseconds testDuration = 10s;

// test_bike_system_event_queue handler function
ZTEST(bike_system_part2, test_bike_system_ttce) {
  // create the BikeSystem instance
  static bike_computer::static_scheduling_with_event::BikeSystem bikeSystem;

  // run the bike system in a separate thread
  zpp_lib::Thread thread(zpp_lib::PreemptableThreadPriority::PriorityNormal,
                         "Test BS TTCE");
  auto res = thread.start(std::bind(
      &bike_computer::static_scheduling_with_event::BikeSystem::start, &bikeSystem));
  zassert_true(res, "Could not start thread");

  // let the bike system run for the test duration
  zpp_lib::ThisThread::sleep_for(testDuration);

  // stop the bike system
  bikeSystem.stop();

  // wait for thread to terminate
  zpp_lib::ThisThread::sleep_for(5s);

#ifdef CONFIG_BOARD_QEMU_X86
  printk("Skipping join on QEMU\n");
#else
  res = thread.join();
  zassert_true(res, "Could not join thread");
#endif
}

ZTEST_SUITE(bike_system_part2, NULL, NULL, NULL, NULL, NULL);

Static Scheduling Event-Driven Implementation

One of the major drawback of the implementation of our BikeComputer program so far, is that events can be missed. Moreover, polling continuously for detecting the button state consumes useless CPU time.

On embedded systems, this can be improved by handling events using interrupts. Interrupts are implemented using special dedicated hardware in the MCU that detects the event and runs an Interrupt Service Routine (ISR) in response. For handling asynchronous events that are by nature non-predictable, using interrupts is the most appropriate approach:

  • An interrupt-based routine only runs the code when it is called/initiated by the interrupt-service routine. This routine directly executes the addressed code, sometimes without any CPU interaction.
  • Therefore, it is fully scalable, because it is independent from the number of monitored events. In other words, the interrupt supervision doesn’t consume CPU time.
  • Using interrupts, the controller is prevented from doing unnecessary tasks by operating in a passive mode for as long as possible, responding mostly just to events and triggers.

When the interrupt trigger occurs, the processor does some hard-wired processing and then executes the ISR, including return-from-interrupt processing at the end, as depicted in the figure below.

Interrupt processing

Interrupt processing

Using the zpp_lib InterruptIn API for detecting an event on an input pin is very easy. to detect an event on an input pin is easy. Instead of polling for input status periodically and continuously, you only need to create an instance of the InterruptIn class and trigger an event when a digital input pin changes. You can trigger interrupts on the falling edge of signals by setting a callback function with the InterruptIn::fall() method. Note that the InterruptIn class is a template class, and the template parameter is the PinName of the button. In this context, PinName is an enum class type that defines the four buttons present on your nrf5340dk/nrf5340/cpuapp device.

Implement the BikeComputer Program using an Event-driven Approach

To implement a version of your program with an event-driven detection of the button press, you must apply some changes to the program developed in the part I of the codelab. The major change is that you must register a function that is called upon button press.

The steps for applying the changes are:

  • Replace the constructor of the ResetDevice class with one that takes a callback argument, as illustrated below. Note the use of the std::function wrapper, which stores a reference to a callable object. This template class acts as an object-oriented version of a function pointer, allowing you to define callback methods with different signatures on different classes. In the example below, the use of void() in std::function<void()> means that the callback is a method that does not return a value (void) and takes no parameters (()).
static_scheduling_with_event/reset_device.hpp
#include <functional>
...
class ResetDevice {
  // constructor used for event-driven behavior
  ResetDevice(std::function<void()> cb);
  ...
}
  • Remove any unused features (data members or methods) from the ResetDevice class.

  • In the implementation of the new constructor of the ResetDevice class, modify the call to the fall() method of the InterruptIn pin instance. The callback passed as parameter must be used here.

  • To construct an instance of ResetDevice, one must pass an instance of a callback as a parameter, as shown below. In our code, this is done in the initializer list of the BikeSystem class constructor. Here we construct the ResetDevice instance with the BikeSystem::onReset() callback method. To accomplish this, the onReset() method must be added to the BikeSystem class.

static_scheduling_with_event/bike_system.cpp
BikeSystem::BikeSystem()
    : _resetDevice(std::bind(&BikeSystem::onReset, this))
...
  • In the BikeSystem class, implement the onReset() method called upon button press. In this implementation, register the press time and set an atomic data member that registers the occurrence of the reset. Optimally, the flag registering the existence of a reset should also be declared as volatile for reasons that are explained below. However, atomic functions do not expect volatile parameters and we may assume that wrong compiler optimizations will not happen in our case.

  • Modify the “BikeSystem::resetTask()method for checking the atomic data member and not calling theResetDevice::checkReset()` method any more.

Note that you must also modify the GearDevice and PedalDevice classes similarly for an event-driven implementation. Instead of polling to check the button state at regular intervals, register an event handler using the Button::fall() method. This will enable you to change the gear (GearDevice) or the pedal rotation speed (PedalDevice) without polling.

Once all classes have been changed accordingly, you may compile and test your application.

Run the test program

Once you have implemented all changes documented above, you can run the “bike_system_part2” test again.

Question 3

If you run the static_scheduling_with_event program, what CPU usage do you observe? How can you explain the observed CPU uptime?

Volatile data

At this stage, it is important to introduce the concept of volatile data. Volatile data is created in your program by adding the volatile keyword to a variable declaration. This is sometimes required because compilers assume that variables in memory don’t change spontaneously, and optimize the code based on that belief. The reasons for optimizing the code are:

  • Don’t reload a variable from memory if the current function hasn’t changed it.
  • Read a variable from memory into a register for faster access.
  • Write back to memory at the end of the procedure, or before a procedure call, or when the compiler runs out of free registers, but not before, for saving memory write.

This optimization can fail in some circumstances. For example, while reading from input port or polling for key press using while (status);. The compiler may generate code that reads from status only once and reuse that value. This will generate an infinite loop triggered by status being true.

The variables for which it fails in all circumstances are:

  • Memory-mapped peripheral register: register changes on its own.
  • Global variables modified by an ISR: the ISR changes the variable.
  • Global variables in a multithreaded application: another thread or ISR changes the variable.

This is the reason why you must declare the reset variable modified in the ISR as volatile.

Static scheduling with interrupts

This implementation of the bike computer program can be described as cyclic static scheduling with interrupts. It essentially has two operating modes:

  • The main application code runs in the foreground and schedule all period tasks as expected. Some period tasks check for device status stored in global volatile memory for executing portions of the main application code.
  • The interrupt service routines run in the background with high priority. These routines are executed when an event is triggered, they handle the most urgent work and modify global volatile variables that will be queried for processing in the main application code.

This implementation is an improvement over the implementation that does not use events, since it guarantees that no event is ever missed.

Question 4: Response time of the reset event

When you run multiple tests for computing the response time of the reset event, what do you observe? Is there an improvement as compared to the static_scheduling::BikeSystem implementation?

Based on the program itself and on the task scheduling, explain the observed behavior.

Wrap-Up

At the end of this codelab, make sure that you have accomplished the following:

  • The BikeSystem class has replace busy waits with sleep calls.
  • The ResetDevice, GearDevice and PedalDevice classes has been modified as specified, to support event-driven handling of the buttons.
  • The BikeSystem class has been modified for registering ISRs and handling events.
  • You have run all tests successfully.
  • You have answered all questions.
  • The pre-commit configuration file has been modified to include all files added in this codelab. All software quality tools succeed, changes are committed and pushed to your repository.