As my Arduino projects became more complex I started to realize the delay() function caused unforeseen problems. While in a delay, the Arduino/AVR can't process any other code (with a few exceptions). I came across this solution while researching another topic and I thought I would document it for my future projects. Alan Burlison created an eloquent Task Scheduler library that solves the delay issues ---but more importantly, it made me rethink how I design my Arduino/AVR projects. The original code was written some years back and still referenced obsolete things like WProgram.h. I have made some small changes and it is available on my GitHub repository along with Alan's original code. There are other Task Scheduler libraries available, but this one is extremely compact, straight forward, and does everything I need. The library uses pointers (eek!), but don't despair, if I can grasp how to use them, so can you. If you're like me, all of my Arduino projects seem to end up being "spaghetti" code by the time I'm finished. Using this library will enable you to structure your code more efficiently to make it more maintainable and easier to debug.
Updated Examples: I have added new examples to the GitHub repository regarding sleep functionality and working with external/pin change interrupts. (Last Update: April 3, 2019)
QuickstartIf you're experienced with C++ classes, objects and pointers feel free to get rolling on your own, you can always come back to this project for help. Simply download the Arduino code and wire up your breadboard per the schematic below. You'll find the generous commenting in the code and it covers all three types of TaskScheduler Tasks:
- Task - a simple task that repeats for the entire program
- TimedTask - a task that executes at a specific rate (100ms, 2000ms, etc.)
- TriggeredTask - a task that is "triggered" by an external event.
The files needed to utilize the Task Scheduler Library in your code are listed below and available in the code link at the end of this project:
- Task.h
- Task.cpp
- TaskScheduler.h
- TaskScheduler.cpp
Note: The example code for this project stores the library files in the same folder as the Arduino.ino file. They are also provided separately in their own "TaskSched" folder if you'd like to add it to your Arduino "libraries" folder later.
Even if you're unfamiliar with the concepts of Object Oriented Programming (OOP) and haven't moved passed the Blink program, never fear, I'm not an expert either and I figured it out (and I wish I would have been introduced to Task Scheduling sooner). Plus, I find it very useful to look at other's code to learn new concepts, especially when it's well commented. This project is part demonstration of task scheduling and an introduction to using C++ classes and objects to encapsulate your code in Arduino.
A Different Way To Look at Arduino CodeDiscovering this library has transformed the way I think, design and write Arduino (and similar AVR) programs. I have successfully used the TaskScheduler with the Arduino (code attached), ATTiny85 and the ATTiny84. I've found it much easier to work in an "object oriented" way. Everything the Arduino does is a task whether it's reading a sensor, lighting an LED, or communicating with the Serial Monitor. By wrapping these tasks into consistent C++ classes and managing each task in a task scheduler the code is much easier to maintain and debug.
Task Class - Block DiagramThe Task class is the base class for all other tasks in the library and is therefore the base class for every derivative task/class you create in your code. This means the TimedTask and TriggeredTask both inherit from the Task class as well.
Note: I used Visio© to crreate the block diagrams with a small font that's a little hard to read in.png format. I'd suggest downloading the full block diagram in pdf format from thelink provided at the bottom of this project.
As you can see, the block diagram above illustrates the underlying code of the Task Class almost exactly. This code can be found in the Task.h file:
/*
* A simple task - base class for all other tasks.
*/
class Task {
/*
* Newbie Note: The "= 0;" following each virtual method
* in the Task Class makes it an "abstract" or "pure virtual"
* method in C++. These methods *must* be defined/implemented in a
* derived class before they can be instantiated.
*/
public:
/*
* Can the task currently run?
* now - current time, in milliseconds.
*/
virtual bool canRun(uint32_t now) = 0; //<--ABSTRACT
/*
* Run the task,
* now - current time, in milliseconds.
*/
virtual void run(uint32_t now) = 0; //<--ABSTRACT
};
Abstract ClassesThe Task class is what is known as an "abstract class" which means in order to instantiate a Task object, both the run() and canRun() methods *must* be implemented. The TaskScheduler (that we'll cover later) uses these methods to determine whether to, or when to, run tasks. The TriggeredTask and TimedTask both override the canRun() method for their own specialized purposes, which we'll also cover later.
Note: Although both the TriggeredTask and the TimedTask override the canRun() method, neither override the run() method, and you will be required to override Task::run() when defining your unique task classes before they can be instantiated.
How do you know it's an Abstract Class?
Answer: If you look at the Task class code above you'll notice both methods end with "= 0;" In C++ this syntax signifies the method as being abstract, hence it is required to be overridden when inheriting from that class.
Creating a Custom Debugger TaskConsider the following code from the included example that sets up a class named Debugger and inherits directly from the Task class:
// ***
// *** Define the Debugger Class as type Task
// ***
class Debugger : public Task
{
public:
Debugger();
void debugWrite(String debugMsg); //Used for simple debugging
virtual void run(uint32_t now); //Override the run() method
virtual bool canRun(uint32_t now); //Override the canRun() method
};
// ***
// *** Debugger Constructor
// ***
Debugger::Debugger()
: Task()
{
Serial.begin(57600);
}
After the public: keyword in the Debugger class definition we see the prototypes for the Debugger constructor, a custom debugWrite() method, and finally the run() and canRun() methods that we inherited and will override.
The Debugger constructor Debugger::Debugger() has one entry in its initialization list to call the constructor for the Task class. The Debugger class uses the standard Arduino Serial method and it is initialized as well at 57600 baud.
Overriding the run() and canRun() methodsConsider the following code of the Debugger illustrating the run() and canRun() override methods:
// ***************************************************
// *** Debugger::canRun() <--checked by TaskScheduler
// ***************************************************
bool Debugger::canRun(uint32_t now)
{
return Serial.available() > 0;
}
// *******************************************************************************
// *** Debugger::run() <--executed by TaskScheduler, determined by canRun() = true
// *******************************************************************************
void Debugger::run(uint32_t now)
{
uint16_t byteCount = 0;
Serial.println("-----------------");
Serial.println("Input Received...");
Serial.println("-----------------");
while (Serial.available() > 0) {
int byte = Serial.read();
Serial.print("'") ;
Serial.print(char(byte));
Serial.print("' = ");
Serial.print(byte, DEC);
Serial.println(" ");
if (byte == '\r') {
Serial.print('\n', DEC);
}
byteCount++;
}
Serial.println("-----------------");
Serial.print("Bytes Received: "); Serial.println(String(byteCount));
Serial.println("-----------------");
}
The Debugger::canRun() method returns true if there is data available in the Tx/Rx buffer of the Arduino. If data is available, the TaskScheduler will call the Debugger::run() method and execute its code.
What is the Debugger task?The Debugger task is an expansion of some original example code provided by Alan Burilson. If you type characters into the send textbox of the Serial Monitor it will echo them back to you. I've expanded it to provide basic debug info from the other tasks during their execution to demonstrate the use of pointers.
Objects vs. ClassesAbove, we learned how to define a custom derived class from the base Task class. But we're not finished creating the Debugger task. So far it's just a "blueprint" of what we want the task to accomplish. In order to make our Debugger task useable in our code we need to instantiate it, or make it an object, like so:
Debugger debugger;
That's it. Our new debugger object of type Debugger is now stored somewhere in the Arduino's memory. When our TaskScheduler object takes control of our program it will reference the address of the debugger object in order to query the canRun() method. If query's result is true, the TaskScheduler will execute the code by calling the run() method, then move on to query another task.
A Quick Introduction to the TaskSchedulerRemember the canRun() method of our Debugger class? If there is data available, the Debugger::canRun() method will return true:
bool Debugger::canRun(uint32_t now)
{
return Serial.available() > 0;
}
The only method available in the TaskScheduler object is runTasks(). Consider the following code:
void TaskScheduler::runTasks() {
while (1) {
uint32_t now = millis();
Task **tpp = tasks;
for (int t = 0; t < numTasks; t++) {
Task *tp = *tpp;
if (tp->canRun(now)) {
tp->run(now);
break;
}
tpp++;
}
}
}
My confession: when I first opened the TaskScheduler.h and TaskScheduler.cpp file I expected a large mass of code. I was wrong. Which made me really appreciate the elegance of Alan Burlison's code technique and intrigued me to look at it in more detail.
I'm not going to get into the detail of an array-of-pointers that reference an array-of-pointers, but it's the C++ technique Alan Burlison used by the runTasks() method. There are numerous references to this technique online. All you need to know is how to supply the pointers to task objects as we'll discuss below,
Note: The runTasks() method was originally name "run()" but I changed it to differentiate it from the Task method.
Supplying the TaskScheduler with TasksIn order for the TaskScheduler to reference the address of the debugger object (or any other task object), we'll need to supply it with a reference to the address of the object.
The TaskScheduler expects an array of reference addresses of type Task (because every task is derived from this class, and the only methods the TaskScheduler accesses is canRun() and run()). We use the ampersand (&) symbol to reference the address of an object. Consider the following pseudo code:
Task *tasks[] = {
&debugger,
&anotherTask,
&someOtherTask,
...
};
The only thing left to do to execute our tasks is to instantiate a TaskScheduler and to call its only available method: runTasks():
//Instantiate the TaskScheduler
TaskScheduler scheduler(tasks, NUM_TASKS(tasks));
// GO! Run the scheduler
scheduler.runTasks();
TimedTask - Block DiagramRefer to the attached TaskScheduler pdf for this discussion:
As discussed earler, the TimedTask is an abstract class that inherits directly from the Task class and overrides the canRun() method.
In the derived class, we inherit and override the run() method to blink the on-board LED on pin 13 at a specified rate. I called this class Blinker:
// ***
// *** Define the Blinker Class as type TimedTask to blink the on-board LED
// ***
class Blinker : public TimedTask
{
public:
// Create a new blinker for the specified pin and rate.
Blinker(uint8_t _pin, uint32_t _rate, Debugger *_ptrDebugger);
virtual void run(uint32_t now);
private:
uint8_t pin; // LED pin.
uint32_t rate; // Blink rate.
bool on; // Current state of the LED.
Debugger *ptrDebugger; // Pointer to debugger
};
// ***
// *** Blinker Constructor
// ***
Blinker::Blinker(uint8_t _pin, uint32_t _rate, Debugger *_ptrDebugger)
: TimedTask(millis()),
pin(_pin),
rate(_rate),
on(false),
ptrDebugger(_ptrDebugger)
{
pinMode(pin, OUTPUT); // Set pin for output.
}
This class is a little different than the Debugger class we discussed earlier, but it follows the same principle. Some differences to note:
- The constructor takes multiple parameters
- The canRun() method isn't overridden, only the run() method
- The Blinker class has public and private members in the class definition.
- The private members include the pin, rate and status (on) of the LED we'll control, as well as a pointer to the Debugger object we instantiated previously.
- The constructor Blinker::Blinker() takes multiple parameters
- The initialization list initializes all the variables after making a call to the constructor of TimedTask class with a parameter of millis().
Everything else is similar to what we saw in the Debugger class. The initialization of the TimedTask constructor is important. This is what differentiates TimedTask from the Task class. The canRun() method of the TimedTask determines if it's "time" for the task to run(), which is contrary to the Debugger class, which depended on the availability of data in the Tx/Rx buffer.
When the Blinker class's constructor is called the millis() parameter "when" updates the protected member "runTime" in the TimedTask class:
Then, when the TaskScheduler makes a call to the Blinker canRun() method it makes the following comparison to the TimedTask's overridden canRun() method, where "now" is the current millis():
bool TimedTask::canRun(uint32_t now) {
return now >= runTime;
}
This comparison will work well for the first run() of the Blinker task, but we'll need to be able to update the "runTime" variable after each on or off cycle of the LED. That's where the TimedTask method incRunTime().
The "rate" variable is passed to the incRunTime() method to "increment" the runtime variable to update the next time the canRun() method will return true to the TaskScheduler. In the example code, the rate variable is set to 500ms:
Blinker blinker(LED_BLINKER, //Pin 13
RATE_BLINKER_BLINK, //Initially set to 500ms
&debugger //Address of the debugger task
);
Each time the run() method is executed it either turns on the LED, or turns it off. It also outputs the status of the LED to the Serial Monitor using the debugger task. Afterwards, it updates the runTime variable using the incRunTime() method as illustrated below:
void Blinker::run(uint32_t now)
{
// If the LED is on, turn it off and remember the state.
if (on) {
digitalWrite(pin, LOW);
on = false;
ptrDebugger->debugWrite("BLINKER: OFF");
// If the LED is off, turn it on and remember the state.
} else {
digitalWrite(pin, HIGH);
on = true;
//Send output to Serial Monitor via debugger
ptrDebugger->debugWrite("BLINKER: ON");
}
// Run again in the specified number of milliseconds.
incRunTime(rate);
}
The example code uses multiple custom TimedTask tasks that all work similarly. The TimedTask class has other methods available that aren't included in the sample code, including:
- setRunTime() - sets the runTime variable to a uint32_t value
- getRunTime() - returns the current runTime value
Like the TimedTask class the TriggeredTask is an abstract class that inherits directly from the Task class and overrides the canRun() method.
The example code provided with this project uses a TriggeredTask to turn on or off two LEDs, and is "triggered" to run by the PhotocellSensor Class using (yep, you guessed it, a pointer). The TriggeredTask is very similar to the TimedTask, but the biggest difference is in how the canRun() method is overridden:
bool TriggeredTask::canRun(uint32_t now) {
return runFlag;
}
Instead of a runTime variable that contains a time in millis() like the TimedTask, the TriggeredTask uses a protected bool value stored in the runFlag variable to determine if it's ready to run by the TaskScheduler. The runFlag variable is set to true by the setRunnable() method, or reset to false (normal operation) by the resetRunnable() method of the TriggeredTask task:
As mentioned earlier, the PhotocellSensor task calls the setRunnable() method to "trigger" a TriggeredTask. In the example code provided I use a TriggeredTask class named LightLevelAlarm to handle the control of the two LEDs that are connected to pins 4 and 5 of the Arduino. After the photocell is read by the PhotocellSensor task, the task compares the value to a predefined lower threshold (initialized to 300). Depending on the result, the PhotocellSensor task then uses a pointer to access a method named setAlarmCondition() to update a private variable named alarmCondition to either true or false. Finally, it uses a pointer to the LightLevelAlarm task to access the setRunnable() method of the TriggeredTask class. Consider the following code snippet from the PhotocellSensor::run() method:
// ***
// *** Code Snippet of the PhotocellSensor::run() method
// ***
void PhotocellSensor::run(uint32_t now)
{
// read the light level of PHOTOCELL_PIN A0
lightLevel = analogRead(pin);
...
...
//Check the threshold level and update the debugger info
if(lightLevel < LIGHT_LEVEL_LOWER_THRESHOLD)
{
//Set LightLevelAlarm::alarmCondition = true
ptrAlarm->setAlarmCondition(true);
ptrDebugger->debugWrite("LIGHT LEVEL ALARM!");
}
else
{
//Set LightLevelAlarm::alarmCondition = false
ptrAlarm->setAlarmCondition(false);
}
...
...
//Trigger the LightLevelAlarm task to update the LEDs on pins 4 and 5
ptrAlarm->setRunnable();
}
Note the syntax for accessing a member of another class using the -> (dash + greater than) symbol. As you can see, the PhotocellSensor task accesses two tasks: the Debugger and the LightLevelAlarm class.
The pointers to these two classes are passed in when the PhotocellSensor task is instantiated. It is important to make sure both the Debugger and LightLevelAlarm class are instantiated before the PhotocellSensor class. Consider the following code snippet:
// ***
// *** Code Snippet showing the order of instantiation
// *** The debugger and lightLevelAlarm tasks are instantiated
// *** before photocellSensor
// ***
Debugger debugger;
...
LightLevelAlarm lightLevelAlarm(LED_LIGHTLEVEL_OK,
LED_LIGHTLEVEL_ALARM
);
PhotocellSensor photocellSensor(PHOTOCELL_PIN,
RATE_PHOTOCELL_READING,
&lightLevelAlarm,
&debugger
);
The resetRunnable() method must be executed at the end of the run() method of the task being triggered, otherwise it would run over and over infinitely. After the LEDs are set to the appropriate level, the LightLevelAlarm task executes its resetRunnable() method:
void LightLevelAlarm::run(uint32_t now)
{
// Set appropriate LED for alarm condition
if(alarmCondition == false)
{
digitalWrite(ok_pin, HIGH);
digitalWrite(alarm_pin, LOW);
}
else
{
digitalWrite(ok_pin, LOW);
digitalWrite(alarm_pin, HIGH);
}
resetRunnable();
}
That's the TaskScheduler library in a nutshell. I'd suggest downloading the Arduino code and Block Diagram pdf from my GitHub repository and experimenting with the code for yourself.
Arduino Code ExampleIf you haven't already done so, download the code for this project from my GitHub repository and make sure it compiles and uploads to your Arduino Uno successfully.
The first thing you might notice while scrolling through all of the comments to find the code you're looking for is that the code is very well commented. :) This is a good thing. I plan to design most of my future projects around this framework so I want to make sure people (mostly me) can come back to this project for a refresher.
Take a Look AroundOpen the Serial Monitor and make sure the baud rate is set to 57600, you should see the Debugger Task sending information from the other tasks in the window, and some (not all) the LEDs will be lit.
Tasks in OperationDebugger Task: as mentioned above, you should see the debugger task sending periodic info to the Serial Monitor (e.g. "BLINKER: ON", "BLINKER: OFF", "FADER LEVEL: 255"). You also should be able to type characters in the upper section of the Serial Monitor and see them echoed back to you after you press return or click "Send."
Blinker Task: the on-board LED (pin 13) should be blinking at a 500ms rate. This task is communicating with the Debugger Task which enables status output to the Serial Monitor.
Fader Task: the LED attached to pin 3 (PWM) should be slowly fading up and down. This task is also communicating with the Debugger Task which enables status output to the Serial Monitor.
Light Level Alarm Task: this task is primarily used to illustrate the use of the TriggeredTask in the TaskScheduler Library. It is accessed via the PhotocellSensor task to control the two LEDs on pins 4 (ALARM) and 5 (OK) which indicates whether the light level is above a preset threshold (300). The PhotocellSensor task is quite capable of handling this, but since I've never been able to find example code for the TriggeredTask, I chose to break out the task this way.
Photocell Sensor Task: this task reads the photocell value on pin A0 using analogRead() and accesses the LightLevelAlarm task via a pointer. This task communicates with the Debugger task via a pointer which enables status output to the Serial Monitor.
I appreciate any and all comments and suggestions, if you have any questions please feel free to leave a comment and I will get to them as time permits.
Have fun. See you next time. -KG
Comments