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
BikeComputerprogram.
What you’ll need
- Zephyr Development Environment for developing and debugging your program in C++.
- The Bike computer - part I, Bike computer - part II and Multi-tasking codelabs are prerequisites to this codelab.
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_eventtomulti_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
WorkQueueinstance dedicated to serving ISRs in yourBikeSystemclass. - Create a
Workinstance that represents the work to be accomplished upon reset. This instance will be used for submitting the work to the ISRWorkQueueinstance. - Create and start a dedicated thread for dispatching events on this
WorkQueueinstance. This thread should be started in theBikeSystem::start()method. - Modify the
BikeSystem::onReset()method for deferring the call to theBikeSystem::resetTask()method using theWorkQueueinstance - rather than setting a flag. - Clean up the code - remove periodic call to
BikeSystem::resetTask(), remove useless attributes, etc. - At this point, the
ResetDeviceclass 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
GearDeviceandPedalDeviceclasses 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::Workupon user action and call theBikeSystemzpp_lib::WorkQueuefor executing the work. - Share a
MessageQueueinstance with theBikeSystem. The gear and pedal tasks will add data to theMessageQueue, while theBikeSystemwill 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
GearDeviceclass)using CallbackFunction = std::function<void(uint8_t, uint8_t)>; - Modify the
GearDeviceconstructor for passing a callback at construction. - Declare the callback (in
BikeSystemclass, when constructing theGearDeviceinstance) usingstd::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 toWorkQueueinstance. See the note below for a correct implementation. - Add two public constant variables in the
GearDevicethat document how the internal state of the device is initialized and that allow theBikeSystemclass to get the internal state of the device at startup. This enables a proper initialization of the devices and this enables theBikeSystemto 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::functionor 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::Workinstance that is passed to thezpp_lib::WorkQueueinstance using theWorkQueue::call()method must remain valid until theWorkQueuethread has completed the call to theWorkinstance’s handler. Therefore, you cannot create aWorkinstance on the stack, pass it to theWorkQueueand then leave the ISR method, since theWorkinstance will be destroyed when you leave the ISR method. -
The
zpp_lib::Workallows to deal with these two problems:- When you create a
zpp_lib::Workinstance for the gear task, you must declare aszpp_lib::Work<BikeSystem, uint8_t, uint8_t>. The class is templated on a object type (hereBikeSystem) and on a variable number of parameters (hereuint8_tanduint8_t). This allows to create lambdas without capture as instances ofzpp_lib::Work, - An instance of
zpp_lib::Workcan be created as attribute named_gearWorkof theBikeSystemclass 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::Workinstance can be updated using thezpp_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.
- When you create a
-
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()andBikeSystem::speedDistanceTask(). - Remove all unused attributes from the
BikeSystemclass, such as current gear or speed. - Remove all getter methods from the
GearDeviceandPedalDeviceclasses.
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 thetest_bike_system_ttcetest and it validates that the periodic tasks run according to the defined periods. -
test_reset_multi_tasking_bike_system: It validates that resetting theBikeSystemworks properly, with the expected response time (below \(100 us\) with a jitter smaller than \(3 us\)).
test_reset_multi_tasking_bike_system
// 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 theSpeedometerclass. 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 theBikeSystemclass. - These changes must be implemented using
#if CONFIG_TEST == 1preprocessor 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,GearDeviceandPedalDeviceclasses developed for the multi-taskingBikeComputerprogram react to button press with ISRs and do NOT provide any getter methods. -
Deliverable: The
BikeSystemprogram works the the different tasks implemented as documented in this codelab. -
Deliverable: You have added a test case for the
GearDeviceclass. -
Deliverable: the code passes all test cases successfully, for the three different implementations of the
BikeSystemclass and for individual components.