Home | pfodApps/pfodDevices | WebStringTemplates | Java/J2EE | Unix | Torches | Superannuation | | About Us
 

Forward Logo (image)      

Simple Multitasking Arduino on any board
without using an RTOS

by Matthew Ford 20th June 2021 (original 4th September 2019)
© Forward Computing and Control Pty. Ltd. NSW Australia
All rights reserved.


How to keep your Arduino loop() responsive

Update 6th Jan 2021 – loopTimer class now part of the SafeString library (V3+) install it from Arduino Library manager or from its zip file
Update 29th Dec 2020 – Revised loopTimer to V1.1.1
Update 15th Dec 2020 – Revised to use SafeString readUntilToken and BufferedOutput for non-blocking Serial I/O, loopTimer now displays its print time as prt:
Update 27th Sept 2020 – Added note about using multiple thermocouples/SPI devices

Introduction

The tutorial describes how to run multiple task on your Arduino without using an RTOS. Your 'tasks' are just normal methods, called directly from the loop() method. Each 'task' is given a chance to run each loop. You can either use a flag to skip 'tasks' that don't need to be run or, more often, just return immediately from the method call if that task has nothing to do. Each task is called in a round robin manner.

As a practical application, this tutorial will develop a temperature controlled, stepper motor driven damper with a user interface. The entire project can be developed and testing on just an Arduino UNO. Because this page is concentrating on the software, the external thermocouple board and stepper motor driver libraries are used, but the hardware is omitted and the input temperature is simulated in the software. Finally the same project code is moved from the UNO to an ESP32 so that you can control it remotely via WiFi, BLE or Bluetooth.

If you search for 'multitasking arduino' you will find lots of results. Most of them deal with removing delays or with using an RTOS. This page goes beyond just removing delays, that was covered in How to code Timers and Delays in Arduino, and covers the other things you need to do for multi-tasking Arduino without going to an RTOS, such as avoiding Arduino Serial and using the SafeString non-blocking alternative.

This tutorial also covers moving from an Arduino to a FreeRTOS enabled ESP32 board and why you may want to keep using “Simple Multi-tasking” approach even on a board that supports an RTOS.

Also see Arduino For Beginners – Next Steps
Taming Arduino Strings
How to write Timers and Delays in Arduino
Safe Arduino String Processing for Beginners
Simple Arduino Libraries for Beginners
Simple Multi-tasking in Arduino (this one)
Arduino Serial I/O for the Real World

Parts List

Hardware
Arduino UNO or any other board supported by the Arduino IDE. All the code developed here can be tested with just an Arduino UNO.
Optional - an ESP32 e.g. Sparkfun ESP32 Thing. The last step in this tutorial moves the code, unchanged, to an ESP32 and adds remote control.

Software
Install the Arduino IDE V1.8.9+
Install the SafeString library (V3+) from the Arduino Library Manager, it includes the millisDelay class and the loopTimer class used here.

The loopTimer library has the sketches used here in its examples directory. Open Arduino's File → Examples → SafeString → loopTimer for a list of them.

For the temperature controlled stepper motor drive damper example sketch:-
Temperature input library MAX31856_noDelay.zip. Adafruit-MAX31855-library-master.zip is also used for illustration purposes, but because it uses
delay() it is replaced by MAX31856_noDelay.zip
Stepper motor control, AccelStepper-1.59.zip

Optionalinstall ESP32 Arduino support, see ESP32_Arduino_SetupGuide.pdf

Simple Multi-tasking

Here is an example of multi-tasking for Arduino

void loop() {
 task_1(); // do something
 task_2(); // do something else
 task_1(); // check the first task again as it needs to be more responsive than the others.
 task_3(); // do something else
}

That is very simple isn't it. What is the trick?
The trick is that each call to a task..() method must return quickly so that the other tasks in the loop get called promptly and often. The rest of this tutorial covers how to keep your tasks running quickly and not holding everything up, using a temperature controlled, stepper motor driven damper with a user interface as a concrete example.

Why not use an RTOS?

A 'Real Time Operating System' (RTOS) adds another level of complexity to your programs as well as needing more RAM and taking more time to execute. There are a number of RTOS (Real Time Operating Systems) systems for Arduino such as:-
https://github.com/feilipu/Arduino_FreeRTOS_Library,
https://github.com/Floessie/frt, https://github.com/PeterVranken/RTuinOS,
https://github.com/ftrias/TeensyThreads and
https://github.com/arduino/ArduinoCore-nRF528x-mbedos

The preemptive RTOS systems, work by dividing the CPU's time up into small slices and sharing these slices between competing tasks. The cooperative multi-tasking RTOS systems depend on each task pausing to let another task run.

For example, comparing Arduino_FreeRTOS and frt to Simple Multi-tasking in Arduino using the Blink_AnalogRead examples to read the analog input as fast as possible. Adding a loopTimer to the AnalogRead task shows that:-
frt reads the analog input every 17ms and uses 10988 bytes Flash program memory and 453 bytes RAM (Blink_AnalogRead_frt.ino)
FreeRTOS reads the analog input every 17ms and uses 9996 bytes Flash program memory and 456 bytes RAM (Blink_AnalogRead_FreeRTOS.ino)
Simple Multi-tasking in Arduino reads the analog input every 0.1ms and uses 3822 bytes Flash program memory and 307 bytes RAM (Blink_AnalogRead_multitaksing.ino)

Each of the Arduino_FreeRTOS and frt use an extra 0.6Kb of program memory and an extra 150 bytes of RAM, but, more importantly, because the analogRead task has the highest priority, they each need to include a minimum 'delay' in the analogRead task to allow other less important tasks a chance to run. Simple Multi-tasking in Arduino does not need that extra delay. As we will see in the stepper motor control below, that 15ms delay is prohibitive. Simple Multi-tasking in Arduino is smaller and simpler than RTOS alternatives and does not need extra added delays. Note also that the time taken to print the loop timings (prt: ) to Serial is significant but is NOT included in the loop times.

These frameworks add extra program code, use more RAM and involve learning a new 'task' framework, the FreeRTOS manual is 400 pages long. Some of them only run on specific Arduino boards. In general they aim to 'appear' to execute multiple 'tasks' (code blocks) at the same time, but in most cases just put one block of code to sleep for some time while executing another block. Because of this task switching taking place at a low level it is difficult to ensure any particular tasks will respond in a given time. E.g. running the AccelStepper stepper motor library on an RTOS system can be difficult because the run() method needs to be called every 1ms for high speed stepping. Later we will look at how “simple multi-tasking' lets you run these types of high speed libraries on an ESP32 which runs FreeRTOS.

All computers take time to do tasks. While the cpu is occupied with that task it can miss other signals. The “Real Time” in RTOS is a misnomer. An UNO has limited RAM and Flash memory available to run an RTOS. In contrast to RTOS systems, the approach here uses minimal RAM and follows the standard Arduino framework of first running the setup() and then repeatedly running loop() method. The “simple multi-tasking” examples below are run on an Arduino UNO.

Keep the loop() fast and 'responsive'.

Why do you want a fast and 'responsive' loop()? Well for simple single action programs like blinking one led, you don't need it to be fast or responsive. However as you add more tasks/functions to your sketch, you will quickly find things don't work as expected. Inputs are missed, outputs are slow to operate and when you add debugging print statements everything just gets worse. This tutorial covers how to avoid the blockages that cause your program to hang without resorting to using a 'Real Time Operating System' (RTOS)

To keep the appearance of 'real time' the loop() method must run as quickly as possible without being held up. The approach here aims to keep the loop() method running continually so that your tasks are called as often as possible.

There are a number of blockages you need to avoid. The 'fixes' covered here are generally applicable all Arduino boards and don't involve leaning a new framework. However they do involve some considered programming choices. The following topics will be covered:-

Simple Multi-tasking in Arduino
Add a loop timer
Rewriting the Blink Example as a task
Another Task
Doing two things at once
Get rid of delay() calls, use millisDelay
Buffering Print Output
Getting User Input without blocking

Temperature Controlled Damper
Adding the Temperature Sensor
Modifying Arduino libraries to remove delay() calls

Giving Important Tasks Extra Time

ESP32 Damper Remote Control

Simple Multi-tasking versus ESP32 FreeRTOS

Simple Multi-tasking in Arduino

Add a loop timer

The first thing to do is to add a loop timer to keep track of how long it takes your loop() method to run. It will let you know if one or more of your tasks in holding things up. As we will see below, third party libraries often have delays built in that will slow down your loop() code. Using Arduino Serial for I/O will also slow down your loop().

The loopTimer library (which also needs millisDelay library) provides a simple timer that keeps track of the maximum and average time it take to run the loop code. Those two classes are included in the SafeString V3+ library. Insert #include <loopTimer.h> at the top of the file and then add loopTimer.check(Serial); to the top of your loop() method.

#include <loopTimer.h>
…
void setup() {
  Serial.begin(9600);}
void loop() {
 loopTimer.check(Serial);.
}

loopTimer.check(Serial) will print out the results every 5sec. You can suppress the printing by omitting the Serial argument, i..e loopTimer.check() and then call loopTimer.print(Serial) later. You can also create extra named timers from the loopTimerClass that will add that name to their output. e.g. loopTimerClass task1Timer("task1");

The loopTimer library includes a number of examples. LoopTimer_BlinkDelay.ino is the 'standard' Blink code with a loop timer added

Running the LoopTimer_BlinkDelay.ino gives the following output

loop us Latency
 5sec max:2000028 avg:2000026
 sofar max:2000028 avg:2000026 max - prt:24996

As this shows the loop() code takes 2sec (2000000 us) to run. So not even close to 'real time' if you are trying to do anything else. The loopTimer excludes the time it takes to print its results (prt: 24996) from the loop time. As you can see it takes about 25.5ms just to print the loopTimer output to Serial. So each time the loopTimer prints, the loop() takes 25ms longer to run. You should always remove the loopTimer once you have completed your testing. As we will see below just added debugging print statements, using Serial, can seriously delay the rest of your loop()

Rewriting the Blink Example as a task.

Lets rewrite the blink example as task in Simple Multi-tasking Arduino, BlinkDelay_Task.ino

// the task method
void blinkLed13() {
  digitalWrite(LED_BUILTIN, HIGH);   // turn the LED on (HIGH is the voltage level)
  delay(1000);                       // wait for a second
  digitalWrite(LED_BUILTIN, LOW);    // turn the LED off by making the voltage LOW
  delay(1000);                       // wait for a second  
}

// the loop function runs over and over again forever
void loop() {
  loopTimer.check(Serial);
  blinkLed13(); // call the method to blink the led
}

Another Task

Now lets write another task that prints the current time in ms to the Serial every 5 secs, PrintTimeDelay_Task.ino

// the task method
void print_ms() {
  Serial.println(millis());   // print the current ms
  delay(5000);              // wait for a 5 seconds
}

Here is some of the output when that task is run just by itself (without the blink task)

10033
loop us Latency
 5sec max:5007284 avg:5007284
 sofar max:5007284 avg:5007284 max - prt:24992
15072

The millseconds is printed every 5secs and the loopTimer shows the loop is taking about 5secs to run.

Doing two things at once

Putting the two task in one sketch clearly shows the problem most people face when trying to do more than one thing with their Arduino. PrintTime_BlinkDelay_Tasks.ino

void loop() {
  loopTimer.check(Serial);
  blinkLed13(); // call the method to blink the led
  print_ms(); // print the time
}

A sample of the output now shows that now the loop() takes 7 secs to run and so the blinkLed13() and the print_ms() tasks are only call once every 7secs

14032
loop us Latency
 5sec max:7000284 avg:7000284
 sofar max:7000284 avg:7000284 max - prt:24992
21065

Clearly the delay(5000) and the two blink delay(1000) are the problem here.

Get rid of delay() calls, use millisDelay

The PrintTime_Blink_millisDelay.ino example replaces the delay() calls with millisDelay timers. See How to code Timers and Delays in Arduino for a detailed tutorial on this.

void blinkLed13() {
  if (ledDelay.justFinished()) {   // check if delay has timed out
    ledDelay.repeat(); // start delay again without drift
    ledOn = !ledOn;     // toggle the led
    digitalWrite(led, ledOn?HIGH:LOW); // turn led on/off
  } // else nothing to do this call just return, quickly
}

void print_ms() {
  if (printDelay.justFinished()) {
    printDelay.repeat(); // start delay again without drift
    Serial.println(millis());   // print the current ms
  } // else nothing to do this call just return, quickly
}

Running this example code on an Arduino UNO gives

25000
loop us Latency
 5sec max:7276 avg:12
 sofar max:7276 avg:12 max - prt:15512

So now the loop() code runs every 7.28ms and you will see the LED blinking on and off every 1sec and the every 5sec the milliseconds will be printed to Serial. You now have two tasks running “at the same time”.
The 7.2ms is due to the print() statements as we will see next.

Buffering Print Output – Avoid Serial

However delay() is not the only thing that can hold up your loop from running quickly. The next most common thing that blocks your loop() is print(..) statements to Arduino Serial. See Arduino Serial I/O for the Real World for a complete tutorial.

The LongPrintTime_Blink.ino adds some extra description text as the LED is turned On and Off.

void blinkLed13() {
  if (ledDelay.justFinished()) {   // check if delay has timed out
    ledDelay.repeat(); // start delay again without drift
    ledOn = !ledOn;     // toggle the led
    Serial.print("The built-in board LED, pin 13, is being turned "); Serial.println(ledOn?"ON":"OFF");
    digitalWrite(led, ledOn?HIGH:LOW); // turn led on/off
  } // else nothing to do this call just return, quickly
}

When you run this example on an Arduino UNO board, the loop() run time goes from 7.2ms to 62.4ms.

 . . . 
The built-in board led, pin 13, is being turned OFF
The built-in board led, pin 13, is being turned ON
The built-in board led, pin 13, is being turned OFF
loop us Latency
 5sec max:62396 avg:12
 sofar max:62396 avg:12 max - prt:10436
The built-in board led, pin 13, is being turned ON
40072

As you add more debugging output the loop() gets slower and slower.

What is happening? Well the Serial.print(..) statements block once the TX buffer in Hardware Serial is full, waiting for the preceding bytes (characters) to be sent. At 9600 baud it takes about 1ms to send each byte to the Serial port. In the UNO the TX buffer is 64 bytes long and the loopTimer Latency message is 83 bytes long, so every 5sec it fills the buffer and the “led ON” “led OFF” message is blocked waiting for the Latency debug message to be sent so there is room for the ON/OFF message. The print ms also blocks waiting for another 7 bytes (including the /r/n) to be sent. The net result is that the loop() is delayed for 62ms waiting for the Serial.print() statements to send the output to Serial.

This is a common problem when adding debug print statements and other output. Once you print more than 64 chars in the loop() code, it will start blocking. Increasing the Serial baud rate to 115200 will reduce the delay but does not remove it.

The simple fix is to NOT use any Serial statements in your loop() code, use BufferedOutput instead.

BufferedOutput class from the SafeString library, can be used to avoid blocking the loop() code due to prints(..) by providing a larger buffer to print to and also discarding any excess chars so that the loop() is not delayed waiting for the Serial port. It actually runs as another task, called from the nextByteOut() call. See Arduino Serial I/O for the Real World for a complete tutorial.

Install the SafeString library (V3+) from the Arduino library manager, which contains the BufferedOutput class and then run the BufferedPrintTime_Blink.ino example. The print(..) statements don't block and with an extra 80 byte buffer, no output is discarded. The bufferedOut is connected to the Serial and thereafter the sketch prints to the bufferedOut. At the top of the loop() a call to bufferedOut.nextByteOut() is added to release a buffered characters as there is space in the Serial Tx buffer. This is like running another background task releasing buffered characters to Serial.

// install SafeString library from Library manager or from https://www.forward.com.au/pfod/ArduinoProgramming/SafeString/index.html
// the loopTimer, BufferedOutput, SafeStringReader and millisDelay are all included in SafeString library V3+
#include <loopTimer.h>
#include <millisDelay.h>
#include <BufferedOutput.h>
// See https://www.forward.com.au/pfod/ArduinoProgramming/Serial_IO/index.html for a full tutorial on Arduino Serial I/O that Works

//Example of using BufferedOutput to release bytes when there is space in the Serial Tx buffer, extra buffer size 80
createBufferedOutput(bufferedOut, 80, DROP_UNTIL_EMPTY);

int led = 13;
// Pin 13 has an led connected on most Arduino boards.
bool ledOn = false; // keep track of the led state
millisDelay ledDelay;
millisDelay printDelay;

// the setup function runs once when you press reset or power the board
void setup() {
  Serial.begin(9600);
  for (int i = 10; i > 0; i--) {
    Serial.println(i);
    delay(500);
  }
  bufferedOut.connect(Serial);  // connect buffered stream to Serial

  // initialize digital pin led as an output.
  pinMode(led, OUTPUT);
  ledDelay.start(1000); // start the ledDelay, toggle every 1000ms
  printDelay.start(5000); // start the printDelay, print every 5000ms
}

// the task method
void blinkLed13() {
  if (ledDelay.justFinished()) {   // check if delay has timed out
    ledDelay.repeat(); // start delay again without drift
    ledOn = !ledOn;     // toggle the led
    bufferedOut.print("The built-in board led, pin 13, is being turned "); bufferedOut.println(ledOn ? "ON" : "OFF");
    digitalWrite(led, ledOn ? HIGH : LOW); // turn led on/off
  } // else nothing to do this call just return, quickly
}

// the task method
void print_ms() {
  if (printDelay.justFinished()) {
    printDelay.repeat(); // start delay again without drift
    bufferedOut.println(millis());   // print the current ms
  } // else nothing to do this call just return, quickly
}

// the loop function runs over and over again forever
void loop() {
  bufferedOut.nextByteOut(); // call at least once per loop to release chars
  loopTimer.check(bufferedOut); // send loop timer output to the bufferedOut
  blinkLed13(); // call the method to blink the led
  print_ms(); // print the time
}

A sample of the output is.

The built-in board led, pin 13, is being turned OFF
25010
loop us Latency
 5sec max:848 avg:20
 sofar max:848 avg:20 max - prt:1256
The built-in board led, pin 13, is being turned ON

Now the loop() is running every 0.8ms. Of course if you add more print( ) statements then eventually you will exceed the BufferedOutput buffer capacity. In that case some of the output will be discarded to avoid blocking the other loop() code. See Arduino Serial I/O for the Real World for a complete tutorial on how to control that.

Getting User Input without blocking

Another cause of delays is handling user input. The Arduino Stream class, which Serial extends, is typical of the Arduino libraries in that includes calls to delay(). The Stream class has a number of utility methods, find...(), readBytes...(), readString...() and parseInt() and parserFloat(). All of these methods call timedRead() or timedPeek() which enter a tight loop for up to 1sec waiting for the next character. This prevents your loop() from running and so these methods are useless if you need your Arduino to be controlling something as well as requesting user input. You can use the low level read() and available() Serial methods to avoid delays, but the coding is tricky and it is easy to make mistakes handling the resulting data. The SafeString library provides high level functions that are easy to use and safe from coding error that will cause your Arduino to reboot. See Arduino Text I/O for the Real World for a complete tutorial

The next example, Input_Blink_Tasks.ino, the Serial baud rate has been increased to 115200 as recommended by Arduino Text I/O for the Real World an a SafeStringReader used to read user commands. It also illustrates how easy it is to pass data between tasks. Use either global variables or arguments to pass in values to a task and use global variables or a return statement to the return the results. No special locking is needed to ensure things work as you would like.

SafeString library provides the non-blocking SafeStringReader class that looks for text separated by one of the specified delimiters. You can also specify a non-blocking timeout to return the last token if there is not a delimiter at the end of the input. Unlike the Serial readUntil() methods, SafeStringReader.read() does not block the rest of the loop while waiting for input or for the timeout to expire. Only a couple of small SafeStrings are needed to read even very long inputs and it is easy to change the commands and add more.

createSafeStringReader(sfReader, 15, " ,\r\n"); // create a SafeString reader with max Cmd Len 15 and delimiters space, comma, Carrage return and Newline

In setup() connect the SafeStringReader to an input Stream and configure it.

void setup() {
  Serial.begin(115200);
 . . . 
  bufferedOut.connect(Serial);  // connect bufferedOut to Serial
  sfReader.connect(bufferedOut);
  sfReader.echoOn(); // echo goes out via bufferedOut
  sfReader.setTimeout(100); // set 100ms == 0.1sec non-blocking timeout
 . . . 
}

The task to collect user input is

void processUserInput() {
  if (sfReader.read()) { // echo input and 100ms timeout, non-blocking set in setup()
    if (sfReader == "start") {
      handleStartCmd();
    } else if (sfReader == "stop") {
      handleStopCmd();
    } else {
      bufferedOut.println(" !! Invalid command: ");
    }
  } // else no delimited input
}

The blinkLed13 task now takes an argument to stop the blinking

void blinkLed13(bool stop) {
  if (ledDelay.justFinished()) {   // check if delay has timed out
    ledDelay.repeat(); // start delay again without drift
    if (stop) {
      digitalWrite(led, LOW); // turn led on/off
      ledOn = false;
      return;
    }
    ledOn = !ledOn;     // toggle the led
    digitalWrite(led, ledOn ? HIGH : LOW); // turn led on/off
  } // else nothing to do this call just return, quickly
}

The Input_Blink_Tasks.ino loop() is now

void loop() {
  bufferedOut.nextByteOut(); // call this one or more times each loop() to release buffered chars
  loopTimer.check(bufferedOut);
  processUserInput();
  blinkLed13(stopBlinking); // call the method to blink the led
  print_ms(); // print the time
}

A sample of the output from Input_Blink_Tasks.ino is below. The loop() time is ~0.6ms even while waiting for the user input to timeout if there is no <space> , or <CR> or <NL>

To control the Led Blinking, enter either stop or start
 . . .
stop  Blinking Stopped
15010
loop us Latency
 5sec max:708 avg:69
 sofar max:708 avg:69 max - prt:1676

So now the 'simple multi-tasking' sketch is controlling the Led blinking via a user input command while still keeping the loop time to 708us (< 1ms)

Temperature Controlled Damper

Now that we have a basic multi-tasking sketch that can do multiple things “at the same time”, print output and prompt for user input, we can add the temperature sensor and stepper motor libraries to complete the Temperature Controlled Damper sketch.

Adding the Temperature Sensor

The next task in this project is to read the temperature that is going to be used to control the damper. Most Arduino sensor libraries use calls to delay() to wait for the reading to become available. To keep your Arduino loop() running you need to remove these calls to delay(). This takes some work and code re-organization. The general approach is to start the measurement, set a flag to say a measurement is under way, and start a millisDelay to pick up the result.

For the temperature sensor we are using Adafruits's MAX31856 breakout board. The MAX31856 uses the SPI interface which uses pin 13 for the SCK, so the led in the blinkled13 task is moved to pin 7. You don't need the breakout board to run the sketch, it will just return 0 for the temperature.

As a first attempt we will use the Adafruit's MAX31856 library (local copy here). The sketch TempDelayInputBlink_Tasks.ino, adds a readTemp() task. For simplicity this task does not check for thermocouple faults. A full implementation should.

// return 0 if have new reading and no errors
// returns -1 if no new reading
// returns >0 if have errors
int readTemp() {
  tempReading = maxthermo.readThermocoupleTemperature();
  return 0;
}

And the loop() is modified to allow the user to start and stop taking temperature readings. This is an example of using a flag, stopTempReadings, to skip a task that need not be run.

void loop() {
  bufferedOut.nextByteOut(); // call this one or more times each loop() to release buffered chars
  loopTimer.check(bufferedOut);
  processUserInput();
  blinkLed7(stopTempReadings); // call the method to blink the led
  printTemp(); // print the temp
  if (!stopTempReadings) {
    int rtn = readTemp(); // check for errors here
  }
}

The print_ms() is replaced with a printTemp() task

void printTemp() {
  if (printDelay.justFinished()) {
    printDelay.repeat(); // start delay again without drift
    if (stopTempReadings) {
      bufferedOut.println(F("Temp reading stopped"));
    } else {
      bufferedOut.print(F("Temp:")); bufferedOut.println(tempReading);
    }
  } // else nothing to do this call just return, quickly
}

The led output will only blink if we are taking temperature readings.

Running the TempDelayInputBlink_Tasks.ino on an UNO with no breakout board attached (that is the SPI leads are not connected) gives this output. Note the commands are startTemps and stopTemps

 Temp reading stopped
loop us Latency
 5sec max:708 avg:62
 sofar max:712 avg:62 max - prt:1676
startTemp
 Start Temp Readings
loop us Latency
 5sec max:252948 avg:75
 sofar max:252948 avg:75 max - prt:1976
 Temp:0.00

As you can see before we start taking reading the loop() runs every 0.7ms. Once we start taking readings, the loop() slows to a crawl, 252ms. The problem is the delay(250) which is built into Adafruit's MAX31956 library. Searching through the library code from https://github.com/adafruit/Adafruit_MAX31856 shows that there is only one use of delay in the oneShotTemperature() method, which adds a delay(250) at the end to give the board time to read the temperature and make the result available.

Modifying Arduino libraries to remove delay() calls

Fixing this library turns out to be relatively straight forward. Remove the delay(250) at the end of the oneShotTemperature() method and delete the calls to oneShotTemperature() from readCJTemperature() and readThermocoupleTemperature(). This library also add some 1ms SPI timing delays to ensure reliable operation of the MAX31856 when used with fast processors. The MAX31856_noDelay library also supports having multiple thermocouples and other SPI devices connect to the same SPI bus. See Multiple Thermocouples below.
The modified library, MAX31856_noDelay, is available here.

To use the modified noDelay library, we need to start a reading and then come back a little while later to pick up the result. The readTemp() task now looks like

int readTemp() {
  if (!readingStarted) { // start one now
    maxthermo.oneShotTemperature();
    // start delay to pick up results
    max31856Delay.start(MAX31856_DELAY_MS);
  }
  if (max31856Delay.justFinished()) {
    readingStarted = false;
    // can pick up results now
    tempReading = maxthermo.readThermocoupleTemperature();
    return 0; // new reading
  }
  return -1; // no new reading
}

Running the modified sketch TempInputBlink_Tasks.ino, gives the output below. The loop() runs in ~1.4ms while taking temperature readings.

startTemp
 Start Temp Readings
Temp:0.00
loop us Latency
 5sec max:1408 avg:254
 sofar max:1408 avg:254 max - prt:1872

Using Multiple Thermocouples

The MAX31856_noDelay library supports multiple thermocouples wired to the same SPI bus but with different CS pins. Here the second MAX31856 uses pin 9 for its CS pin.

The sketch, dual_MAX31856.ino, in the MAX31856_noDelay examples directory, shows how to define and setup two or more thermocouple boards. The first board is defined as before

// Use software SPI: CS, DI, DO, CLK
MAX31856_noDelay maxthermo = MAX31856_noDelay(10, 11, 12, 13);
// use hardware SPI, just pass in the CS pin
//MAX31856_noDelay maxthermo = MAX31856_noDelay(10);

The second board only needs to have a CS pin specified as it always uses the same SPI settings as the first board defined

 // create the second thermocouple object controlled by CS pin 9
MAX31856_noDelay maxthermo2 = MAX31856_noDelay(9); // NOTE: this still uses software SPI set by maxthermo above
// the SPI settings are set by the first call to MAX31856_noDelay(..) and ignored by any subsequent calls

In setup() call begin() on both boards first, BEFORE calling any of the get/set methods. The begin() method sets the SPI (if not already set) and disables the CS pin. Then the first call to a get/set method on each board will set its default setting to those set at the top of the MAX_noDelay.cpp file. You can then override the ones you want to change.

void setup() {
… 
  // call both begin() first before any other calls.
  maxthermo.begin();  
  maxthermo2.begin();  // begin second board
  // SPI interface is only started once by the first call to begin()
  // but each begin() set the CS line for that MAX31856

  // the defaults at the top of MAX31856_noDelay.cpp are set on the first call to any on of the library methods if resetDefaults() not called here
  maxthermo.setThermocoupleType(MAX31856_TCTYPE_K);  // this is the defaults for thermocouple 1}

Giving Important Tasks Extra Time

The last part of this simple multi-tasking temperature controlled damper is the damper's stepper motor control. Here we are using the AccelStepper library to control the damper's stepper motor. The accelStepper's run() method has to be called for each step. That means in order to achieve the maximum 1000 steps/sec, the run() method needs to be called at least once every 1ms.

As a first attempt, just add the stepper motor library and control. Since this tutorial is about the software and not the hardware, it will use a very simple control and just move the damper to fixed positions depending on temperature. 0 degs to 100 degs will be mapped into 0 to 5000 steps position. To test the software without a temperature board, the user can input numbers 0 to 5 to simulate temperatures 0 to 100 degs. The readTemp() task will still be called but its result will be ignored.

There are two new tasks setDamperPosition() to convert temp to position and runStepper() to run the AccelStepper run() method.

void setDamperPosition() {
  if (closeDampler) {
    stepper.moveTo(0);
  } else {
    long stepPosition = simulatedTempReading * 50;
    stepper.moveTo(stepPosition);
  }
}

void runStepper() {
  stepper.run();
}

The loop() handles the user input temperature simulation and adds these two extra tasks on the end

void loop() {
  bufferedOut.nextByteOut(); // call this one or more times each loop() to release buffered chars
  loopTimer.check(bufferedOut);
  processUserInput();
  blinkLed7(closeDampler); // call the method to blink the led
  printTemp(); // print the temp
  int rtn = readTemp(); // check for errors here
  setDamperPosition();
  runStepper();
}

Running the FirstDamperControl.ino sketch and inputting run 66.5 from the Arduino IDE monitor, gives the following timings

Temp:66.50
Position current:2096 Damper running
loop us Latency
 5sec max:2608 avg:791
 sofar max:2608 avg:791 max – prt:1580

The loop() runs on average every 0.8ms. So the average maximum stepper motor speed can exceed 1000 steps/sec. However the maximum loop() time is ~2.5ms, so some times runStepper() is only called that often. A good guess would be that this is due to the print statements in the printTemp() method. We can test that by just commenting out the print statements in that methods.

void printTemp() {
  if (printDelay.justFinished()) {
    printDelay.repeat(); // start delay again without drift
      // removed all the print()s
  } // else nothing to do this call just return, quickly
}

The output is then

loop us Latency
 5sec max:960 avg:784
 sofar max:1220 avg:784 max – prt:1596

This confirms that the print() statements are the major source of the maximum loop() time. The max so far time, 1.2ms, occurs when there is a user input

So we can say that it is only once every 5 seconds that the stepper motor's maximum speed will drop from >1000 steps/sec to ~400 steps/sec. Depending on the application this may be acceptable or it may be noticeable.

In this tutorial we are aiming for a maximum speed of 1000 steps/sec consistently so we will continue to make changes to get the max interval between calls to runStepper() to <1ms. The way to do this is to add more calls to runStepper() through out the code. Since we are now focusing on the time between runStepper() calls we move the loopTimer from the loop() into the runStepper() method to measure there.

void runStepper() {
  loopTimer.check(bufferedOut); // moved here from loop()
  stepper.run();
}

Also since we have determined that the printTemp() method is the major source of the slowness, we will add extra calls to runStepper() between the print statements in that method.

void printTemp() {
  if (printDelay.justFinished()) {
    printDelay.repeat(); // start delay again without drift
  runStepper(); // <<<< extra call here
    bufferedOut.print(F("Temp:")); bufferedOut.println(simulatedTempReading);
  runStepper(); // <<<< extra call here
    bufferedOut.print(F("Position current:")); bufferedOut.print(stepper.currentPosition());
  runStepper(); // <<<< extra call here
    if (closeDampler) {
      bufferedOut.println(F(" Close Damper"));
    } else {
      bufferedOut.println(F(" Damper running"));
    }
  runStepper(); // <<<< extra call here
  } // else nothing to do this call just return, quickly
}

The output from the resulting sketch, FinalDamperControl.ino, achieves the 1000 steps/sec consistently.

Temp:66.50
Position current:3325 Damper running
loop us Latency
 5sec max:844 avg:764
 sofar max:1240 avg:815 max - prt:1824

Remember that the loopTimer.check(bufferedOut); needs to be commented out once testing is complete as its print statements add an extra 1.5ms every 5 seconds

Adding more extra calls to runStepper() does just allow us to read the 1000 steps/sec, but there is nothing left over for any more I/O or calculations on the UNO, with a 16Mhz clock. To do better we need to use a faster processor. The ESP32's clock is 80Mhz, so lets try it.

ESP32 Damper Remote Control

Without making any changes to the FinalDamperControl.ino sketch, recompile and run it on an ESP32 board. Here we are using a Sparkfun ESP32 Thing. The timings for runStepper() are now

Temp:66.50
Position current:3325 Damper running
loop us Latency
 5sec max:62 avg:43
 sofar max:279 avg:43 max - prt:121

So running on an ESP32, there is no problem achieving 1000 steps/sec for the stepper motor. Actually even the FirstDamperControl.ino sketch can run at 1000 steps/sec consistently because of the faster ESP32 clock speed.

Using an ESP32 also gives you the ability to control the damper via WiFi, BLE or Classic Bluetooth.

Note that although the ESP32 is a dual core processor running FreeRTOS, no changes were needed to run the “simple multi-tasking” sketch on it. The loop() code runs on core 1, leaving core 0 free to run the communication code. You have a choice of WiFi, BLE or Classic Bluetooth for remote control of the damper system. WiFi is prone to 'Half-Open' connections and requires extra work to avoid problems. BLE is slower with smaller data packets and requires different output buffering. If you are using the free pfodDesigner Andoid app to create your control menu to run on pfodApp then the correct code for these cases are generated for you.

Here we will use Classic Bluetooth as it the simplest to code and easily connects to a terminal program on old computers as well as mobiles.

The ESP32DamperControl.ino sketch has the necessary mods. The bufferedOut is now connected to the SerialBT stream at a baud rate of 115200 and a separate serialBufferedOut created to send the loopTimer output to the Serial connection. Once you see “The device started, now you can pair it with Classic bluetooth!” in the Serial Monitor, you can pair with your computer or mobile. After pairing with the computer, a new COM port was created on the computer and TeraTerm for PC (or CoolTerm Mac/PC) can be used to connect and control the damper. On your Android mobile you can use a bluetooth terminal app such as Bluetooth Terminal app.

The Serial Monitor displays the loopTimer output.

loop us Latency
 5sec max:407 avg:44
 sofar max:407 avg:44 max – prt:129

and the Bluetooth Terminal handles the commands and displays the damper position

Of course now that you have finished checking the timings you can comment out the loopTimer.check() statement. You could also add you own control menu. The free pfodDesigner Android app lets you do that easily and generate the menu code for you to use with the, paid, pfodApp.

Simple Multi-tasking versus ESP32 FreeRTOS

Given that “simple multi-tasking” works on any Arduino board, why would you want to use ESP32 FreeRTOS or other RTOS system? Using ESP32 FreeRTOS is not as straight forward as “simple multi-tasking”.

The Arduino ESP32's FreeRTOS scheduler is configured with preemption enabled. However if you have tasks of different priorities then the lower priority tasks will never run if you not add a delay() or vTaskDelay() in all the higher priority tasks. This makes the system look like a cooperative multi-tasking system when you have tasks with different priority levels, so you have to program delays into your tasks to give other tasks a chance to run. You need to learn new methods for starting tasks and if you use the default method, your task can be run on either core, so you can find your task competing with the high priority Radio tasks for time. Also if you have multiple tasks distributed across the two cores, you have to worry about safely transferring data between the tasks in a thread safe manner, i.e. locks, semaphores, critical sections etc. Finally due to a quirk in the way the ESP32 implements the task switching, you can find your task is not called at all, or called less often then you would expect. You can code around this problem, but it takes extra effort.

If you want to use FreeRTOS on the EPS32, search for Digikey's Introduction to RTOS series of 12 lessons with code examples.

In a preemptive RTOS systems, as used by TeensyThreads, it can be difficult to force a tasks like the AccelStepper run() method to run as often as you want.

All RTOS systems add an extra overhead of support code with its own set of bugs and limitations. So all in all, the recommendation is to code using the “simple multi-tasking” approach that will run on any Arduino board you choose. If you want to add a communication's module, then the ESP32's second core provides it without impacting your code and having two separate cores minimizes the impact of the underlying RTOS.

Conclusions

This tutorial presented “simple multi-tasking” for any Arduino board. The detailed example sketches showed how to achieve 'real time' execution limited only by the cpu's clock, by replacing delay() with millisDelay, and using the SafeString library to buffer output and get user input without blocking. The loopTimer lets you bench mark how responsive your sketch is. As a practical example a temperature controlled, stepper motor driven damper program was built.

Finally the example sketch was simply recompiled for an ESP32 that provides a second core for remote control via WiFi, BLE or Classic Bluetooth, without impacting the responsiveness of original code.


For use of the Arduino name see http://arduino.cc/en/Main/FAQ


The General Purpose Android/Arduino Control App.
pfodDevice™ and pfodApp™ are trade marks of Forward Computing and Control Pty. Ltd.


Forward home page link (image)

Contact Forward Computing and Control by
©Copyright 1996-2020 Forward Computing and Control Pty. Ltd. ACN 003 669 994