Unlike a PC, microcontrollers like the Arduino or ESP32 are not able to run multiple programs in parallel. But that doesn't mean that you wouldn't be able to run multiple tasks in parallel. This article shows how to achieve this in a simple way.
The Concept
The main idea is that most code pieces do not fully require the full processor power. Therefore we can alternate and let the processor work on each piece of code for a limited amount of time (typically less than a millisecond). In this case, each piece of code needs to remember where it stopped, so it knows where to continue the next time. This requires to define (and to number) all the possible "states" of the code, so it can memorize this "state number".
This approach is called a Finite State Machine (FSM). If this sounds complex to you, let me assure you: it isn't! Probably, you have used the "Blink" example from the Arduino library before (I always use this to test any new Arduino board). Under "02.Digital" is another example "BlinkWithoutDelay". Please have a look at that one. The difference between these two examples explains basically all the ingredients that we need to understand a FSM - and to do multitasking.
A Simple Example
Before we build a slightly more complex example below, let's study the main features by comparing slightly modified versions of the two blink sketches.
Here is the main loop (which is the same for both):
void loop() {
checkButton();
blink();
}
Let us assume that "checkButton()" is a subroutine that checks if a button has been pressed - it does not matter for our purposes how this is done in detail, and what the code will do else. It only matters here, that we want to ensure that the code will be able recognize if a user pushes a button, which requires it to be called in sufficiently small time intervals. For the moment, we just create an empty subroutine.
void checkButton() {
}
The first (simple and inefficient) version of the blink sketch is this:
void blink() {
digitalWrite(LED_BUILTIN, HIGH); // turn the LED on
delay(1000); // wait for a second
digitalWrite(LED_BUILTIN, LOW); // turn the LED off
delay(1000); // wait for a second
}
In this case, the main loop is doing the following: it enters "checkButton()" to quickly check if the button was pressed (this is extremely fast - much faster than 1 millisecond), and then it enters "blink()". In "blink()", it turns on the LED, waits one second, turns off the LED, and waits another second - so two seconds are spent in each call of "blink()". Then the loop repeats and it checks the button again.
The obvious problem is that two seconds pass in between subsequent readings of the button. In many cases, when a user presses the button for a typical time of 1/2 second, the code will not recognize this, since it may just be waiting in "blink()" inside one of the two "delay(1000)" statements.
So, the culprit is the "delay()" statement - which essentially prevents our Arduino sketch from doing anything during this time.
A better, more efficient code would memorize whether the LED is on or off, and also the time when it was set into this state. Then it would exit the blink() subroutine, so that the main loop() could go back and call checkButton() again after a much shorter time. Whenever blink() is called again, it checks if one second has already expired. If not, it exits - and if yes, it turns the LED on or off (depending on its previous state) and, again, memorizes the new on/off state and the time when this happened. Together, the information whether the LED is on or off plus the time information define the state of the updated blink() subroutine. This is what a Finite State Machine (FSM) is about.
A FSM can be in a certain (finite) number of possible states. The state changes whenever it receives input (examples are signals from other code pieces or here, from a timer). The new state is defined based on the type of input and on the current state.
Let's implement everything that we stated above in a new, FSM version of the blink() code. It is almost equal to the "BlinkWithoutDelay" example - just slightly re-organized, so that the FSM feature becomes more clear.
void blink() {
static byte state = 0; // state 0:LED-off 1:LED-on
static unsigned long previousTime = 0; // time of the last change
const unsigned long delayTime = 1000; // blink-time in millisec
if ((millis()-previousTime) > delayTime) { // one sec expired
previousTime = millis(); // store current time
if (state == 0) { // if LED was off
state = 1; // -> new state is "on"
digitalWrite(LED_BUILTIN, HIGH); // -> turn on LED
} else { // if LED was off
state = 0; // -> new state is "off"
digitalWrite(LED_BUILTIN, LOW); // -> turn off LED
}
}
Let's understand this, line by line:
First we define the variable "state" in which we store, well, the current state of the LED - using the type "byte". Then we define the variable "previousTime" to store the last time that the LED was turned on/off - using the type "unsigned long ". Both of these variables are declared as "static", which means that the Arduino will remember their values between subsequent calls of the blink() code (otherwise they would be initialized as zero in every new call).
In the third line we define a constant "delayTime" which we set to 1000. This is the time (in milliseconds) between subsequent on/off changes as used in the first simple and inefficient blink() version. By declaring this as a constant, it will be easier to change this in the future, if you ever decide to change the blink frequency.
Then we check if one second has passed: This is the case when the difference of the current time and the previous time when the state changed is larger than 1000 ms (the "delayTime"). If not, the code is finished and returns back to the "loop()".
If the code finds that 1000 ms have passed, it will change the state of the LED. Therefore, first, it stores the time when this happened, by updating the variable "previousTime" and setting it to the current time. Then, if the current state of the LED is "off", we set the state variable to "on", and turn on the LED. If the current state of the LED is "on", we set the state variable to "off", and turn off the LED.
In either case, whether the state of the LED was changed or not, the time spent in the blink routine is very small (much less than one millisecond). Therefore, the loop() code can quickly go back and check the status of the button, so that no push of the button will go unnoticed. Which is exactly what we wanted!
The essential lesson is, not to use any "blocking" code This includes "delay()" statements and also "while" loops that wait for events to occur. If it is necessary for your code to wait for something to happen, then remember (i.e. store) the current state, exit the subroutine, and check again the next time the subroutine is called.
A General Rule: Keep Your Code Local
This means that all operations of a given component (like an LED) are done by a single subroutine which has a state variable to store the current state. If the component can affected by external code, the subroutine also needs an input variable that specifies the request to change the state.
Let's say we have an LED that can be turned on and off. When it's asked to turn on, the LED should fade from zero to the maximum brightness and stay there until requested to turn off. For this case we provide a subroutine "operateLed" with an input variable "input" and a state variable "state".
The "input" variable can have the following values:
- 0 (means: continue to operate in the current state)
- 1 (means: turn on the LED)
- 2 (Means: turn off the LED)
The "state" can either be:
- 0 (off)
- 1 (currently ramping up from zero up to max brightness)
- 2 sitting at max. brightness
The detailed code for "operateLed()" is listed further below as part of ...
A More Interesting and Complex Example
Let's operate a device that has a switch (or a momentary push button), one LED, two servos, and a sound player. The device is supposed to do the following. At startup (when the switch/button is open), the LED is off, the servos sit at their initial angles "minAngle", and no sound is playing.
- When the switch/button changes from off to on:
- the servos start to rotate from "minAngle" to "maxAngle" (and stop when they reached the latter)
- the LED code waits a little, then slowly fades on
- the sound code waits a little, then starts the sound player
- The switch/button is ignored for 100-200 ms to avoid bouncing
- When the switch/button changes from on to off:
- the servos start to rotate from "maxAngle" back to "minAngle"
- the LED code turns the LED off
- the sound code checks if the sound is still playing, and, if yes, it stops.
- The switch/button is ignored for 100-200 ms to avoid bouncing.
The complete code is provided in seven pieces,
- a list of constants in which parameters are stored, plus include statements of libraries used,
- the setup() code in which the Arduino I/O pins are declared,
- the loop() which is the main program that is executed continuously,
- the subroutine checkButton() which reads the button status and initiates the requested responses,
- the subroutine operateLed() which operates the LED,
- the subroutine operateServos() which operates the servos,
- the subroutine operateSound() which (you guessed it!) operates the sound output.
Parameters and Libraries
/*
* Arduino code for a device with
* push button, LED, 2 servos, sound player
* - for Arduino Uno, Nano, ProMini
*/
// -----------------------------------------------------------------
// --- constants
//
// --- The angles where the servos start and stop
// These could be different for both servos
const byte minAngle[2] = {0,0}; // servo start angles
const byte maxAngle[2] = {80, 80}; // servo stop angles
//
// --- delay times for the LED to turn on and the sound to start
// after the button was pushed
const int ledDelay = 500; // delay time (in ms) for the LED turn on
const int soundDelay = 450; // delay time (in ms) for the sound
//
// -----------------------------------------------------------------
// --- pins and libraries
//
// --- button (connected to this pin and GND
const byte pinButton = 2;
//
// --- LED - (connected to this pin and, via a resistor) to GND
const byte pinLed = 3; // must be a PWM pin like 3,5,6,9
//
// --- servos
#include <Servo.h>
Servo myServo[2];
const byte pinServo[2] = {9, 10};
//
// --- plus variables/pins for the sound player
// [these are omitted here for brevity]
In the setup() code, the Arduino pins are enabled as required. The button is connected between the Arduino pin and GND. To ensure that the logic level of the pin is well-defined while the pin is open, we use the built-in pull-up resistor.
void setup() {
Serial.begin(9600);
pinMode(pinButton, INPUT_PULLUP); // push button
pinMode(pinLed, OUTPUT); // LED pin
digitalWrite(pinLed, LOW); // turn off LED
for (byte i = 0; i < 2; i++) {
myServo[i].attach(pinServo[i]); // two servos
myServo[i].write(minAngle[i]); // rotate to minimum angle
}
// --- plus initialization for sound player
}
The loop() code is executed and repeated continuously. It is very simple and consists of four calls. The checkButton() routine has no input - it always does the same: checking for changes of the button status. The other routines each have an input argument. In the loop() code, this input argument is always zero, meaning: "just continue doing what you did".
void loop() {
checkButton();
operateLed(0);
operateServos(0);
operateSound(0);
}
Now we look at the individual device-specific subroutines, starting with the code to check changes of the button status and to initiate actions.
// --- check changes of the status of the button
//
// possible states
// 0: the button is open - check if the button is closed
// 1: the button was closed - wait a few milliseconds
// to ignore possible bounces - then go to state 2
// 2: the button is closed - wait for button to open
// 3: button was opened - wait few ms to debounce
//
void checkButton() {
static byte state = 0;
static unsigned long previousTime = 0;
if (state == 0) {
if (digitalRead(pinButton) == LOW) { // button was pressed
operateLed(1); // turn on the LED
operateServos(1); // rotate servos
operateSound(1); // start sound
state = 1; // change the state
previousTime = millis(); // store current time
}
} else if (state == 1) {
if ( (millis() - previousTime) > 100) { // wait 100ms
state = 2; // change state
}
} else if (state == 2) {
if (digitalRead(pinButton) == HIGH) { // button was released
operateLed(2);
operateServos(2);
operateSound(2);
state = 3; // change state
previousTime = millis(); // store current time
}
} else if (state == 3) {
if ( (millis() - previousTime) > 100) { // wait 100ms
state = 0; // change state
}
}
}
The LED is operated by the subroutine "operateLed()". It has an input argument for which the meaning of the values are described in the header of the routine.
// --- operate the LED
// input 0 continue
// 1 turn LED on
// 2 turn LED off
//
// possible states
// 0: LED is off
// 1: LED has received the "turn-on" signal but waits
// 2: LED is fading on
// 3: LED is on
//
void operateLed(byte input) {
static byte state = 0; // store the state
static unsigned long previousTime = 0; // store the time
static byte step = 0; // steps when fading LED
// --- deal with external inputs
if (input == 1 && state == 0) { // turn on signal - if LED:off
state = 1; // start turn-on procedure
previousTime = millis(); // store current time
} else if (input == 2) { // turn off signal
digitalWrite(pinLed, LOW); // turn off LED
state = 0; // set state to 0:off
}
// --- timer-based state changes
if (state == 1) { // turn-on was requested
if ( (millis() - previousTime) > ledDelay) { // wait
state = 2; // set state to 2:fade-on
step = 0; // reset step counter
previousTime = millis(); // store current time
}
} else if (state == 2) { // ramp-up
if (step == 255) {
state = 3;
} else if ( (millis()-previousTime) > 2){ //
previousTime = millis();
step++;
analogWrite(pinLed,step);
}
}
}
The servos are operated by the subroutine "operateServos()". It has an input argument as described in the header.
// --- operate the servos
// input 0 continue
// 1 rotate to max angle
// 2 rotate back to min angle
//
// possible states
// 0: servos sit at min angle
// 1: servos rotate from min to max
// 2: servos sit at max angle
// 3: servos rotate from max to min
//
//
void operateServos(byte input) {
static byte state = 0; // store the state
static unsigned long previousTime = 0; // store the time
// store current servo angles
static byte currentAngle[2] = {minAngle[0],minAngle[1]};
// --- deal with external inputs
if (input==1 && (state==0 || state==3)) { // go to max angle
state = 1; // set state to 1:start rotate to max
previousTime = millis(); // store current time
} else if (input==2 && (state==1 || state==2)) { // go to min angle
state = 3; // set state to 3:start rotate to min
previousTime = millis(); // store current time
}
// --- timer-based state changes (rotation of servos)
if (state == 1) { // rotate from min to max
if ( (millis() - previousTime) > 2) { // wait
previousTime = millis(); // store current time
for (byte i = 0; i < 2; i++) { // do for both servos
if (currentAngle[i] < maxAngle[i]) { // if not at max angle
currentAngle[i]++; // increase angle
myServo[i].write(currentAngle[i]); // rotate to new angle
}
}
// if both servos reached their max angles -> change to state 2
if (currentAngle[0]==maxAngle[0] &&
currentAngle[1]==maxAngle[1]) {
state = 2; // set state to 2:at max angle
}
}
} else if (state == 3) { // rotate from max to min
if ( (millis() - previousTime) > 2) { // wait
previousTime = millis(); // store current time
for (byte i = 0; i < 2; i++) { // do for both servos
if (currentAngle[i] > minAngle[i]) { // if not at min angle
currentAngle[i]--; // decrease angle
myServo[i].write(currentAngle[i]); // rotate to new angle
}
}
// if both servos reached their min angles -> change to state 0
if (currentAngle[0]==minAngle[0] &&
currentAngle[1]==minAngle[1]) {
state = 2; // set state to 0:at min angle
}
}
}
}
Finally, the routine to operate the sound player. I am just giving the skeleton - the user needs to add the specific commands for their sound player. Code for the DFPlayerMini (plus a detailed description of the setup) can be found in one of my older blog posts.
// --- operate the sound player
// input 0 continue
// 1 start sound
// 2 stop sound
//
// possible states
// 0: sound is off
// 1: waiting for sound to start after a short delay
// 2: sound has started
//
//
void operateSound(byte input) {
static byte state = 0; // store the state
static unsigned long previousTime = 0; // store the time
// --- deal with external inputs
if (input == 1 && state == 0) { // start signal - if:off
state = 1; // go into waiting state
previousTime = millis(); // store current time
} else if (input == 2) { // stop signal - if:playing
// your-code-to-stop-playing(); // stop playing
state = 0; // set state to 0:off
}
// --- timer-based state changes
if (state == 1) { // waiting for sound to start
if ( (millis()-previousTime) > soundDelay) { // wait
state = 2; // set state to 2:playing
// your-code-to-start-playing(); // play sound
}
}
}
Outlook: Possible Improvements
void operateMyDevice(byte input) {
static byte currentState = 0; // store the current state
byte newState = 0; // store the new state
// --- response matrix for 3 inputs (i.e. 3 rows 0-2)
// and 4 states (i.e. 4 columns 0-3)
const byte fsmMatrix[3][4] = {
{0,1,2,3}, // responses for input==0
{3,3,1,1}, // responses for input==1
{0,0,0,0} // responses for input==2
};
newState = fsmMatrix[input][currentState];
// possible actions, depending on the current state,
// the input, and/or the new state
// further actions (including state changes) may also
// be based on timers
currentState = newState;
}
Some different cases:
- If the code is called with "input" zero, the first row of the matrix is evaluated to compute the new state. The first row contains {0, 1, 2, 3}. Since the n-th element is equal to "n", the new state will always be equal to the current state - so the state does not change. (This is the same as what we did in the routines above, where a zero input meant to continue operating in the same state).
- If the code is called with "input" of one
- if the current state was zero or one, the new state will become "3"
- if the current state was two or three, the new state will become "1"
- if the code is called with input "2", the new state will always be "0" (as the turn-off in the operateLed() code above)
No comments:
Post a Comment