Introduction: How to Run Several Tasks on an Arduino?
A common problem encountered by new Arduino users is to run concurrent tasks. A microcontroller can execute only one instruction at a time, but you may want it to run several tasks in parallel, such as lighting an LED, reading a sensor, printing on an LCD display, etc.
We will see that running concurrent tasks is not very difficult, and that copying only a few lines of code at the beginning of a function is all you need for that.
Supplies
Supply:
- An Arduino board
- All hardware you'd want to use (sensors, display, etc)
Good programming practices:
I will be assuming in the following that we follow a few of the good programming practices. You can easily find on the Internet many websites explaining these good practices, but let's use two of them here:
(GP1) Naming convention: use variable names that we can understand,
(GP2) Keep the code simple: to simplify the code and make it easier to read, we will use functions whenever we can.
Step 1: Using Delay: the Bad Way
Usual tasks are periodic, meaning that they are executed every X milliseconds. The most simple way to do this is to use the delay() function:
Description
delay() pauses the program for the amount of time (in milliseconds) specified as parameter. (There are 1000 milliseconds in a second.)
This works very well if you have only one task to execute. The simplest example toggles an LED:
int ledPin = 13; // LED connected to digital pin 13 void setup() { pinMode(ledPin, OUTPUT); // sets the digital pin as output } void loop() { digitalWrite(ledPin, HIGH); // sets the LED on delay(1000); // waits for a second digitalWrite(ledPin, LOW); // sets the LED off delay(1000); // waits for a second }
The code above, taken from the Arduino website, begins by defining the LED attached to pin 13, and then toggles it every second (1000 ms, in the delay function). But, the Arduino does nothing but waiting during the delay function. So if you want to read a temperature sensor or compute something, it cannot be done during this time.
Step 2: An Analogy
Let's suppose you are watching a car race on TV, while cooking your lunch. On the circuit, there are 2 cars: one is driving at 150 km/h and the other at 240 km/h. The circuit is 10 km long, so the first car runs a lap in 4 minutes (240 seconds) and the second in 2 minutes and 30 seconds (150 seconds).
In the same time, you want to cook a boiled egg, which takes 3 minutes (180 seconds).
If you want to see both cars' arrivals and have your egg cooked the right way, you must be in front of your TV after 150 seconds, then go back to turn off the heat under the pan after 30 more seconds, then go back to the TV to see the second car after 60 more seconds.
What you need here is a stopwatch!
Actually, to be sure to handle the timings correctly, the best solution would be to use 3 stopwatches: one that will count 150 seconds, one 180 seconds and the last one 240 seconds. This is what we will do in our Arduino code.
Step 3: Use Millis() for Unblocking Code
The correct way to handle several concurrent tasks running in parallel is to use the millis() instruction: it is your Arduino's stopwatch. There are several possibilities to use it, I'll show you one of them here, which I think is the easiest and has the minimum impact on your code.
Description
millis() returns the number of milliseconds passed since the Arduino board began running the current program. This number will overflow (go back to zero), after approximately 50 days.
Syntax
time = millis()
As the number returned by this instruction can be quite high, it is better to store it in an unsigned longvariable (also called uint32_t).
Unsigned long variables are extended size variables for number storage, and store 32 bits (4 bytes). Unlike standard longs, unsigned longs won’t store negative numbers, making their range from 0 to 4,294,967,295 (2^32 - 1).
4,294,967,295 milliseconds last 49 days 4 hours and 15 minutes. This means that unless your code runs for a longer time, the value returned by millis() will always be increasing.
Let's go back to our good practices, specifically the one I called GP2. To have a code that is easy to read, I will write all the code lines of each task in separate functions, such as:
void task1 (/* put arguments here */) { // code lines for task 1 }
Then the loop function will remain very simple: if there are 3 concurrent tasks, it will look like:
void loop () { task1 (/* arguments for task 1 */); task2 (/* arguments for task 2 */); task3 (/* arguments for task 3 */); // other code lines }
After these assumptions, let's code our stopwatches: one in each task function. Each time the function is called, the stopwatch checks if its period is over: if not, it just ends the function as it is not yet the time to execute the task. But if the period is passed, then it resets and executes the task.
That's all folks!
To do this, we just need to put 3 lines of code at the beginning of the task's function:
static unsigned long chrono = millis(); if (millis() - chrono < duration) return; chrono = millis();
The first line declares the variable that will store the starting time of the stopwatch. It is an unsigned long as we saw before.
static unsigned long chrono = millis();
The keyword static means that this variable keeps its value even when the function is not running. In this particular line, I both define the variable chrono and initialize it to the current execution time (millis()). This initialization is done only at the first execution of the task: for each other executions, the variable will keep its previous value.
The second line checks if it is time to execute the task. millis() - chrono computes the time passed since the stopwatch was reset: if this time is less than the period's duration, just end the function.
But otherwise, we just need to reset the stopwatch
chrono = millis();
After that, we can execute the instructions of the task.
Step 4: A Simple Example
Let's write an example that will warn us about the cars and the egg... But, since the Arduino lives in the milliseconds world, I will divide all durations by 100.
We have 3 tasks:
- car1 with 1500 ms period
- car2 with 2400 ms period
- egg with 1800 ms period
These values are called duration in the code lines above. This leads to a simple Arduino code:
/* How to run multiple tasks on an Arduino? (c) Lesept - nov. 2021 Use this freely... */ void car1 (unsigned long duration /* ms */) { static unsigned long chrono = millis(); if (millis() - chrono < duration) return; chrono = millis(); // Type here the instructions Serial.print(chrono); Serial.println("\tcar 1"); } void car2 (unsigned long duration /* ms */) { static unsigned long chrono = millis(); if (millis() - chrono < duration) return; chrono = millis(); // Type here the instructions Serial.print(chrono); Serial.println("\t\tcar 2"); }
void egg (unsigned long duration /* ms */) { static unsigned long chrono = millis(); if (millis() - chrono < duration) return; chrono = millis(); // Type here the instructions Serial.print(chrono); Serial.println("\t\t\tegg"); }
void setup() { Serial.begin(115200); } void loop() { car1(1500 /* ms */); car2(2400 /* ms */); egg (1800 /* ms */); }
Each task only displays a message on the serial monitor (make sure to set it to 115200 baud). \t is the escape character for the tabulation, to make it easier to distinguish each line.
Here are the messages for the 10 first seconds:
1528 car 1
1828 egg
2428 car 2
3028 car 1
3628 egg
4528 car 1
4828 car 2
5428 egg
6028 car 1
7228 egg
7228 car 2
7528 car 1
9028 egg
9028 car 1
9628 car 2
There is a 28ms inaccuracy due to the initialization time of the serial monitor, which may depend on your Arduino board, but it is acceptable in most situations.
If you want to take it into account in a few additional lines of code: first declare a global variable at the very beginiing of your ino file
unsigned long offset;
Then change the setup:
void setup() { Serial.begin(115200); offset = millis(); }
And finally, change the initialization line of each stopwatch:
static unsigned long chrono = millis() - offset;
This will reduce the first delay before each task of the offset's duration, and provide very accurate timings:
1500 car 1
1800 egg
2400 car 2
3000 car 1
3600 egg
4500 car 1
4800 car 2
5400 egg
6000 car 1
7200 car 2
7200 egg
Attachments
Step 5: Wait a Minute...
One problem in this example is that we need to cook an infinite number of boiled eggs. But what if we only want one or two?
In other words, there may be some cases in which a task only needs to run once or a given number of times. This can be handled similarly, by adding a counter which will increment each time the task is executed.
void egg (unsigned long duration /* ms */, byte maxNumber) { static byte number = 0; static unsigned long chrono = millis() - offset; if (millis() - chrono < duration) return; chrono = millis(); if (number >= maxNumber) return; ++number; // Type here the instructions Serial.print(chrono); Serial.print("\t\t\tegg number "); Serial.println(number); }
And we just need to add an argument when calling the task in the loop:
egg (1800 /* ms */, 2 /* eggs max */);
Here, we cook 2 eggs. The monitor shows:
1500 car 1
1800 egg number 1
2400 car 2
3000 car 1
3600 egg number 2
4500 car 1
4800 car 2
6000 car 1
7200 car 2
7500 car 1
9000 car 1
9600 car 2
There may be other cases in which a task can only be executed if some condition is met. It is straightforward to code these conditions in the beginning lines of the task's function. If the condition is related to other tasks (for example, begin to cook the eggs AFTER the second car has finished its third lap) you may have to change the definition of the function so it can return something useful.
In this example, the function car2 returns a boolean which is true after the third lap:
bool car2 (unsigned long duration /* ms */) { static byte number = 0; static unsigned long chrono = millis(); if (millis() - chrono < duration) return (number >= 3); chrono = millis(); ++number; // Type here the instructions Serial.print(chrono); Serial.println("\t\tcar 2"); // return (number >= 3); }
and this condition is used in the egg task:
void egg (unsigned long duration /* ms */, byte maxNumber, bool condition) { static byte number = 0; static unsigned long chrono = millis(); if (millis() - chrono < duration) return; chrono = millis(); if (!condition) return; if (number >= maxNumber) return; ++number; // Type here the instructions Serial.print(chrono); Serial.print("\t\t\tegg number "); Serial.println(number); }
Of course, the loop must be changed accordingly:
void loop() { car1(1500 /* ms */); bool condition = car2(2400 /* ms */); egg (1800 /* ms */, 2 /* eggs max */, condition); }
This time, the monitor shows that we only cook 2 eggs after the end of the 3rd lap of car number 2:
1500 car 1
2400 car 2
3000 car 1
4500 car 1
4800 car 2
6000 car 1
7200 car 2
7200 egg number 1
7500 car 1
9000 egg number 2
9000 car 1
9600 car 2
10500 car 1
12000 car 1
12000 car 2
Attachments
Step 6: Wrap Up
I hope that this Instructable has helped you understand how to run several tasks in parallel using an Arduino, in an easy way, with minimal impact on your code. This way of doing it enables to keep the loop quite clean, to separate each task in a function and handle its execution just by adding a few lines at the beginning. It makes your code easy to read and understand.