Skip to content

Bike Computer Part III

Introduction

Dynamic Scheduling

In part 1 of the BikeComputer codelab, we have implemented a version of the bike computer program that uses cyclic static scheduling. In part 2 of the BikeComputer codelab, an event-driven model was implemented to improve event handling. This improves the application’s performance because it guarantees that no event is ever missed. However, task scheduling is still implemented in a static way, meaning that tasks are always executed in the same order, regardless of their importance.

In this part of the codelab, we will study how to implement preemptive dynamic scheduling using the capabilities of Zephyr RTOS. With dynamic scheduling, tasks are scheduled on the fly based on their importance. Dynamic scheduling also simplifies the creation of tasks with different rates and preemption allows better prioritization and shorter response times.

Multi-tasking Application with periodic, event-driven and data-driven tasks

Another important capability of multi-tasking applications is to adapt the behavior of each task to its intrisic nature, namely:

  • periodic tasks: tasks such as reading sensor data are essentially periodic. In general, these tasks cannot be driven by specific events and they will run at a pre-defined interval.
  • event-driven tasks: tasks such as reacting to a user action cannot be predicted and are by nature not periodic. They are thus driven by external events and implemented using interrupt mechanisms.
  • data-driven tasks: some tasks need to receive data for accomplishing their job. They must thus wait for data and react when new data becomes available.

In this codelab, we will implement the different BikeComputer tasks using the appropriate type of task, namely:

  • reset task as an event-driven task, served by a dedicated thread.
  • gear and pedal tasks as data-driven tasks, where data is produced upon user action (using the buttons)
  • display and temperature tasks as periodic tasks that run at fixed periodic time intervals.

What you’ll build

In this codelab, you’re going to program our bike computer program in a multi-threaded approach using the Mbed OS scheduler capabilities. Tasks will be implemented either as periodic, event-driven or data-driven tasks.

What you’ll learn

  • How to develop a simple embedded program using multiple threads.
  • How to use the different multitasking mechanisms to implement the BikeComputer program.

What you’ll need

Prepare for integrating a multi-tasking program into your BikeComputer program

To implement a version of the BikeComputer program as a multi-tasking program, you must apply some changes to the program developed in part II of the codelab. The changes are documented below.

As a starting point you must duplicate the static_scheduling_with_event version and apply some changes. The steps for applying the changes are:

  • Copy the code from the “static_scheduling_with_event” folder in a new subfolder named “multi_tasking”. The version under “static_scheduling_with_event” contains the version that you delivered for phase 2 of the project.

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

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

Reduce Event Response Times and Improve Concurrency

In the Bike computer - part II codelab, we implemented an event-driven mechanism for registering events such as button actions. This improved the program behavior by making sure that no event was ever missed, but this change did not yet improve the event response time. This is due to the fact that event-driven tasks are still executed at periodic time intervals with a static schedule.

Another major drawback of the way devices are implemented in the Bike computer - part II codelab relates to concurrency issues. In this implementation, devices have an internal state that is modified by ISRs. This internal state may be queried concurrently by the BikeSystem tasks. This means that the internal state variables must be protected against concurrent access, which makes its implementation more complex. In the new implementation, the internal state of the devices will be communicated to the BikeSystem class each time a change occurs. This will improve response times and simplify concurrent access protection.

Start the changes by modifying the BikeSystem and ResetDevice classes as follows:

  • Create a WorkQueue instance dedicated to serving ISRs in your BikeSystem class.
  • Create a Work instance that represents the work to be accomplished upon reset. This instance will be used for submitting the work to the ISR WorkQueue instance.
  • Create and start a dedicated thread for dispatching events on this WorkQueue instance. This thread should be started in the BikeSystem::start() method.
  • Modify the BikeSystem::onReset() method for deferring the call to the BikeSystem::resetTask() method using the WorkQueue instance - rather than setting a flag.
  • Clean up the code - remove periodic call to BikeSystem::resetTask(), remove useless attributes, etc.
  • At this point, the ResetDevice class should be a stateless class without any getter method.

Once the changes are correctly implemented, test that the reset mechanism works properly.

Question 1

Try to modify the priority of the thread used for serving ISRs. This can be done at thread creation time`.

Test with different priorites set to zpp_lib::PreemptableThreadPriority::PriorityBelowNormal, zpp_lib::PreemptableThreadPriority::PriorityNormaland zpp_lib::PreemptableThreadPriority::PriorityAboveNormal and observe the behavior of the response time in each case. Make sure that you understand what threads are running in your application and what the priority of each thread is.

How can you explain the observed response times? If you observe a difference depending on the priority of the _deferredISRThread thread, explain why. If you cannot observe any difference, also give an explanation and explain a scenario where different priorities would produce different results.

Make the Gear and Pedal tasks data-driven

In the Bike computer - part II codelab, both gear and pedal tasks are implemented as periodic tasks. Similar to the reset mechanism, the gear and rotation speed are modified in an event-based manner using interrupts whenever the user presses the button. However, this does not make these tasks event-driven or data-driven, as they are still implemented as periodic tasks in the BikeSystem class.

This implementation has significant drawbacks:

  • Both tasks are executed periodically, regardless of any changes to gear or rotation speed. This uses CPU even when no changes have been made.

  • Similarly to the reset task, the response time is not optimal and exhibits a large jitter.

  • Both GearDevice and PedalDevice classes must maintain an internal state and protect concurrent access to this internal state.

To improve this implementation, both the gear and pedal tasks must be made data-driven. This means that data will be generated whenever the user presses the button, which will then be consumed almost immediately by the thread serving the work queue. To implement this change, you must choose one of the following options:

  • Create a zpp_lib::Work upon user action and call the BikeSystem zpp_lib::WorkQueue for executing the work.
  • Share a MessageQueue instance with the BikeSystem. The gear and pedal tasks will add data to the MessageQueue, while the BikeSystem will retrieve data from it.

If you choose to implement your solution using the zpp_lib::Work class, note that the zpp_lib::Work class is a template class where the template parameter specifies the callback function to execute when the event is dispatched.

To help you in the implementatio, we illustrate the required changes for the GearDevice class:

  • Define the callback type (in GearDevice class) using CallbackFunction = std::function<void(uint8_t, uint8_t)>;
  • Modify the GearDevice constructor for passing a callback at construction.
  • Declare the callback (in BikeSystem class, when constructing the GearDevice instance) using std::bind(&BikeSystem::onGearChanged, this, _1, _2) or a lambda expression such as [this](uint8_t currentGear, uint8_t currentGearSize){ onGearChanged(currentGear, currentGearSize); }
  • In the BikeSystem::onGearChanged, defer the work to WorkQueueinstance. See the note below for a correct implementation.
  • Add two public constant variables in the GearDevice that document how the internal state of the device is initialized and that allow the BikeSystem class to get the internal state of the device at startup. This enables a proper initialization of the devices and this enables the BikeSystem to update the various devices and the display at startup.

How to defer a work properly from an ISR

If you need to defer work from an ISR method, there are two important points to consider:

  • Memory cannot be allocated in an ISR method. For this reason, creating a std::function or a closure from a lambda with capture is unsafe in the ISR method. Using a lambda without capture would be an option, but we need at least to capture the reference to the instance (this), which makes this approach unsafe.
  • The zpp_lib::Work instance that is passed to the zpp_lib::WorkQueue instance using the WorkQueue::call() method must remain valid until the WorkQueue thread has completed the call to the Work instance’s handler. Therefore, you cannot create a Work instance on the stack, pass it to the WorkQueue and then leave the ISR method, since the Work instance will be destroyed when you leave the ISR method.
  • The zpp_lib::Work allows to deal with these two problems:

    • When you create a zpp_lib::Work instance for the gear task, you must declare as zpp_lib::Work<BikeSystem, uint8_t, uint8_t>. The class is templated on a object type (here BikeSystem) and on a variable number of parameters (here uint8_t and uint8_t). This allows to create lambdas without capture as instances of zpp_lib::Work,
    • An instance of zpp_lib::Work can be created as attribute named _gearWork of the BikeSystem class and initialized in the constructor’s initializer list as _gearWork(zpp_lib::Work<BikeSystem, uint8_t, uint8_t>(this, &BikeSystem::updateGear, 0, 0)).
    • In the ISR method, the parameters to be used in the zpp_lib::Work instance can be updated using the zpp_lib::Work.setParams(...) method. This method can be safely called since new parameters will be rejected if there is still a pending work instance. In this case, rejecting new parameters is acceptable and simulates a “greyed out” button.
  • If you follow these guidelines, you should be able to properly defer the work from the ISR method to the work queue.

Once you made the documented changes, you must of course:

  • Remove the periodic calls to BikeSystem::gearTask() and BikeSystem::speedDistanceTask().
  • Remove all unused attributes from the BikeSystem class, such as current gear or speed.
  • Remove all getter methods from the GearDevice and PedalDevice classes.

At this point, the BikeSystem::start() method schedule only three periodic tasks, using the BikeSystem::temperatureTask(), BikeSystem::displayTask1() and BikeSystem::displayTask2() methods.

Prepare for Testing

Your final version of the BikeComputer program must of course be tested. The following test cases must be implemented:

  • test_multi_tasking_bike_system: It is identical to the test_bike_system_ttce test and it validates that the periodic tasks run according to the defined periods.

  • test_reset_multi_tasking_bike_system: It validates that resetting the BikeSystem works properly, with the expected response time (below \(100 us\) with a jitter smaller than \(3 us\)).

test_reset_multi_tasking_bike_system
bike_computer/tests/bike_computer/bike_system_part3/src/test_bike_system_part3.cpp
// test_reset_multi_tasking_bike_system handler function
static std::chrono::microseconds resetTime = std::chrono::microseconds::zero();
static zpp_lib::Events resetEvents;
static constexpr uint32_t kResetEventFlag = (1UL << 0);
static void resetCallback() {
  resetTime = zpp_lib::Time::getUpTime();
  resetEvents.set(kResetEventFlag);
}

ZTEST(bike_system_part3, test_reset_multi_tasking_bike_system) {
  // create the BikeSystem instance
  bike_computer::multi_tasking::BikeSystem bikeSystem;

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

  // let the bike system run for 10 secs
  // this will test whether scheduling is ok
  static constexpr std::chrono::milliseconds initialTestDuration = 10s;
  zpp_lib::ThisThread::sleep_for(10s);

  // test reset on BikeSystem
  bikeSystem.getSpeedometer().setOnResetCallback(resetCallback);

  // instantiate the reset button
  zpp_lib::InterruptIn<zpp_lib::PinName::BUTTON1> resetButton;

  // check for reset response time
  constexpr uint8_t kNbrOfResets             = 10;
  std::chrono::microseconds lastResponseTime = std::chrono::microseconds::zero();
  for (uint8_t i = 0; i < kNbrOfResets; i++) {
    // take time before reset
    auto startTime = zpp_lib::Time::getUpTime();

    // reset the BikeSystem by simulating button press
    resetButton.write(zpp_lib::kPolarityPressed);

    // wait for resetCallback to be called
    resetEvents.wait_any(kResetEventFlag);

    // get the response time and check it
    std::chrono::microseconds responseTime = resetTime - startTime;

    // check that the response time is within the expected margin
    // cppcheck generates an internal error with 20us
    constexpr std::chrono::microseconds kMaxExpectedResponseTime(100);
    zassert_true(responseTime <= kMaxExpectedResponseTime, "Response time is too long: %lld usecs", responseTime.count());

    printf("Reset task: response time is %lld usecs\n", responseTime.count());

    constexpr std::chrono::microseconds kMaxExpectedJitter(3);
    if (i > 0) {
      std::chrono::microseconds jitter = responseTime - lastResponseTime;
      zassert_true(std::abs(jitter.count()) <= kMaxExpectedJitter.count(), "Response time jitter is too high");
    }
    lastResponseTime = responseTime;

    // unpress the button
    resetButton.write(! zpp_lib::kPolarityPressed);

    // let the bike system run for 2 secs
    zpp_lib::ThisThread::sleep_for(2s);
  }

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

  res = thread.join();
  zassert_true(res, "Could not join thread");
}

Some changes are required in different classes for the test program to compile and work properly:

  • Add the public method void setOnResetCallback(std::function<void()> cb) to the Speedometer class. This method must allow to register a callback with the proper signature and this callback function must be called (without additional delay) upon reset.
  • Add the public method bike_computer::Speedometer &getSpeedometer() to the BikeSystem class.
  • These changes must be implemented using #if CONFIG_TEST == 1 preprocessor directives.

Write your own GearDevice Test

To validate the correct implementation of the GearDevice class, you must also validate that changing the gear (up and down) works properly. Your test program must simulate button press by invoking the zpp_lib::InterruptIn::write() method as demonstrated in the test_reset_multi_tasking_bike_system test program.

The test program must validate the following:

  • When pressing button 4 (with button 2 pressed), the current gear and gear size are updated correctly. When the maximal value for the gear is reached, then pressing the up button should not update the gear any more.

  • When pressing button 3 (with button 2 pressed), the current gear and gear size are updated correctly. When the minimal value for the gear is reached, then pressing the down button should not update the gear any more.

Expected Deliverables

  • Deliverable: the ResetDevice, GearDevice and PedalDevice classes developed for the multi-tasking BikeComputer program react to button press with ISRs and do NOT provide any getter methods.

  • Deliverable: The BikeSystem program works the the different tasks implemented as documented in this codelab.

  • Deliverable: You have added a test case for the GearDevice class.

  • Deliverable: the code passes all test cases successfully, for the three different implementations of the BikeSystem class and for individual components.