There are generally 3 different levels of multi-tasking in micro-controllers like Arduino:
- No Multi-task, e.g. Superloop
- Cooperative Multi-task
- RTOS
SUPERLOOP
Most coders start by learning about the "super loop", in which all functions are called every time through the loop. They generally look something like this:
void loop(){
EncoderCounts();
GetLine();
EncoderCounts(); // This needs to run more often, so do it again in between every other function
GetAccel();
EncoderCounts();
CheckBat();
EncoderCounts();
SendData();
delay(10); // Wait a little bit so SendData isn't sending info out too fast. Uh oh, are we going to miss encoder counts?
}
Super Loops are unsuitable for all but the most basic applications. You can't even edit the Arduino "Blink" or "Fade" sketches without changing the LED's behavior. If you're building a robot, you'll need something better.
RTOS
Real Time Operating Systems are very powerful, but they consume a fair amount of overhead especially on micro-controllers. An RTOS can pause a function in the middle, go do something else, and then come back to finish up the first function. This allows a lot of different code paths to share the same processor, giving the impression that they're all executing simultaneously. The code can be written however the programmer wants, while the RTOS will worry about how to switch between different tasks. An RTOS can still only run one function at a time, and may have dozens of functions paused in the background waiting in line for their chance to crunch a few more numbers. All these half-finished functions consume memory and it takes extra CPU time whenever it pauses one function and restarts a different one. While 32-bit ARM processors generally have enough memory and CPU power to run an RTOS, smaller processors like the Arduino Uno with its 8-bit AVR chip struggle to run even a basic RTOS.
Cooperative Multi-Tasking
Cooperative Multi-tasking is a compromise between the Super Loop and an RTOS. It allows a single micro-controller to accomplish multiple tasks by interleaving function calls. Functions are not stopped or paused in the middle - they must be written in a way that they finish quickly yielding time back to the processor to run other functions. A Scheduler can keep track of the time and call functions as needed.
To further illustrate why a Cooperative Multitask is better, consider the Super Loop example above. Certainly we want to know if the battery is running low, but does it make sense to check the battery voltage every time through the loop? Checking the battery voltage once per second is probably just fine, no need to waste CPU cycles checking it 50 times a second. Cooperative Multitasking allows us to specify how often each function should run, so we can use the CPU time more wisely but without the overhead of an RTOS.
Because a Cooperative Multitask framework won't pause a function in the middle, each function must be written carefully so that it doesn't hog the CPU for a long duration of time. Other functions need a chance to run too. More on that later.
BASIC SCHEDULER CODE
I've used the following Scheduler code on dozens of projects and it works quite well.
// If using Arduino, put this in the loop() function:
void loop(){
Scheduler();
// Don't put anything else here in the loop.
// All code should be put into functions, and all functions managed by the scheduler.
}
void Scheduler(){ // Call this function constantly
static uint32_t oldTime = micros();
uint32_t currentTime = micros();
uint32_t elapsedTime = currentTime - oldTime;
oldTime = currentTime;
// countdown variables keep track of how long until the function needs to be run again
static uint32_t cdEnc, cdTX, cdLine, cdAccel, cdVbat; // cd countdown variables
if(cdEnc > elapsedTime){cdEnc -= elapsedTime;}else{EncoderCounts(); cdEnc = 179;} // 20 us (microseconds)
if(cdTX > elapsedTime){cdTX -= elapsedTime;}else{SendData(); cdTX = 5023;} // 38 us
if(cdLine > elapsedTime){cdLine -= elapsedTime;}else{GetLine(); cdLine = 1999;} // 420 us
if(cdAccel > elapsedTime){cdAccel -= elapsedTime;}else{GetAccel(); cdAccel = 373;} // 85 us
if(cdVbat > elapsedTime){cdVbat -= elapsedTime;}else{CheckBat(); cdVbat = 100999;} // 8 us
}
When using Cooperative Multitasking, you need to know how often a function should be called, and how much time it takes the function to run. If a function takes too long or is called too often, it will consume too much processing power and your program will not perform as you expect.
Function Name | How often is function called? (microseconds) | How long does Function take to finish? (microseconds) | % of CPU |
EncoderCounts() | 179 | 20 | 20 / 179 = 11.2% |
SendData() | 5023 | 38 | 38 / 5023 = 0.8% |
GetLine() | 1999 | 420 | 65 / 1999 = 21.0% |
GetAccel() | 373 | 85 | 85 / 373 = 22.8% |
CheckBat() | 100999 | 8 | 8 / 100999 = 0.0% |
TOTAL | | | 55.8% |
CPU Free Time | | | 100% - 55.8% = 44.2% |
So far so good. Together all of the functions consume just over half the CPU power, so this should run just fine. If the CPU usage is close to 80% or higher then you may need to do a more in-depth timing analysis to see if there will be issues.
Did you notice the seemingly random numbers for "How often is function called"? Why not round these off to 5000, 2000 and 100000? Sure that's probably fine and usually won't cause any noticeable problem - especially with 44.2% CPU Free Time. But before we go rounding off all the numbers, think about what would happen when we reached 100000us. If we round off all the numbers, we'll then have 3 or more functions that all want to run at the same time. The Scheduler will step through all 3 of them, but it will take a little while and by the time those 3 are done some other function is probably running late too. The program will run more smoothly if we can avoid having a bunch of functions all wanting to run at the same exact time. The best way to avoid this is to make the time between function calls prime numbers like 179, 5023, 1999, etc. If you have a function that you want to run about every 10000 microseconds, just google "list of prime numbers" and pick a number that is close, like 9941 or 10069.
But wait - there is another timing problem with our scheduler! We want to run the EncoderCounts() function every 179 microseconds, but the GetLine() function takes 420 us to finish. While that GetLine() function is running for 420us, we're unable to run the EncoderCounts() function and we're going to miss some important data. What should we do?
DEALING WITH LONG FUNCTIONS
With Cooperative Multitasking, it is important that functions finish up quickly and return to the scheduler, so that all functions get a chance to run on a timely basis.
How can we make functions that run quickly? Different situations call for different methods, but often times a long function can be split up into smaller steps. Every time the function is called, it does the next step, then returns. By calling the function many times, it will eventually complete all the steps.
Let's take a look and figure out why our GetLine() function is taking so long:
// global variables
const int NUMLINE = 15; // Number of Line Sensors
int LINE_PINS[NUMLINE] = {A0, A1, A2, A3, A4, A5, A6, A7, A8, A9, A10, A11, A12, A13, A14}; // Line Sensor pin assignments
int line_values[NUMLINE]; // A place to store the data from the sensors
// READS ANALOG OUTPUT OF ALL THE LINE SENSORS
void GetLine(){
const int NUM_AVERAGES = 10; // Take this many readings and then take the average
for(int i = 0; i < NUMLINE; i++){ // For each Line Sensor
line_values[i] = 0;
for(int j = 0; j < NUM_AVERAGES; j++){
line_values[i] += analogRead(LINE_PINS[i]); // Sum of 10 readings
}
line_values[i] = line_values[i] / NUM_AVERAGES; // Divide by 10 to get average reading
}
}
The GetLine() function is doing analogReads for 15 sensors, and its taking 10 readings per sensor and averaging for a total of 150 readings. How do we make this function run in less time? Options:
- Get rid of some sensors, robot may not work correctly.
- Average fewer samples, data will be noisier.
- Play with ADC registers to speed up the conversion time, data will be noisier.
- Call the function more often, but only do part of the work each time.
This last option is the best, because we don't have to sacrifice performance. The problem isn't that the function consumes too much % of CPU cycles, the problem is that the function hogs the CPU for too long each time it's called. There are a bunch of ways to split up this function, here's one way to do it where it reads a single sensor per function call instead of reading every sensor per function call.
// Improved GetLine Function:
void GetLine(){
static int index = 0; // index of sensor that will be read
const int NUM_AVERAGES = 10; // Take this many readings and then take the average
line_values[index] = 0;
for(int j = 0; j < NUM_AVERAGES; j++){
line_values[index] += analogRead(LINE_PINS[index]); // Sum of 10 readings
}
line_values[index] = line_values[index] / NUM_AVERAGES; // Divide by 10 to get average reading
index++; // Next time function is called, the next sensor will be read
if(index >= NUMLINE) index = 0; // loop back to the beginning after the last sensor is read
}
And then change this line in the Scheduler:
// Was:
if(cdLine > elapsedTime){cdLine -= elapsedTime;}else{GetLine(); cdLine = 1999;} // 420us
// Change to this:
if(cdLine > elapsedTime){cdLine -= elapsedTime;}else{GetLine(); cdLine = 133;} // 30us
The GetLine() function is now called 15x more often, but each time it only takes 30 microseconds instead of 420 microseconds. The same amount of Line Sensor data is collected just as often as in the original code, but now the EncoderCounts() function will have a chance to run on time. Problem solved!
MEASURING FUNCTION DURATION
In any Arduino-compatible environment, the time it takes for a function to run can easily be measured with help from the micros() function. I like to use the variable names "tic" and "toc" which will be familiar to anyone who uses Matlab.
uint32_t tic, toc;
tic = micros();
MyFunction();
toc = micros();
Serial.print("Duration (us) = ");
Serial.println(toc - tic);
Just replace "MyFunction()" with any function in your program, and this will print out the time it takes for that function to run. If the function runs in just a couple microseconds making it hard to get an accurate measurement of the duration, then copy and paste the function call a few dozen times and take an average.
HOW OFTEN SHOULD I CALL A FUNCTION?
This really depends on what you're doing, but here are a few scenarios to get you thinking:
Example 1: Line Following - How often should the robot read the Line Sensor?
Answer: Suppose you're robot is line following at a speed of 1 meter / second. The line is 3/4" (19mm) wide, and worst case scenario is the line does a sharp 90 deg turn. At 1 meter/second, the robot will travel 19mm in 19 milliseconds. Its best to have a bit of safety margin, so I'd recommend no more than 15 milliseconds between line sensor readings. Even better, read it line every 8 milliseconds so you would be taking two readings within that 19mm so you're almost guaranteed to not miss that turn. Now if you've already split the GetLine() function up so that it reads only 1 of 15 line sensors for each function call, then reading the whole line every 8ms means you'd need to call GetLine() every 533 microseconds (533 microseconds * 15 sensors = 8 milliseconds).
Example 2: Sharp IR sensor is looking for motion approaching the front door so some lights can be turned on.
Answer: The datasheets for Sharp IR sensors will state how fast the sensor is able to update its output. The popular GP2Y0A21 sensor has an update rate of 38 +/- 10 ms. In this case, there isn't any value in reading the sensor any faster than every 40-50 milliseconds.
Example 3: Update an LCD screen on a mobile robot.
Answer: Depends on what the LCD screen is used for. If this is a high speed line following robot, consider not updating the screen at all because no one can see it with the robot zooming around so fast. If this is a personal assistance robot that a human is constantly looking at, then the screen should probably refresh every 30-40 milliseconds (25 to 33 FPS).
Example 4: Ultrasonic Sensor - How often to check the pulse duration?
Answer: HC-SR04 Time-of-Flight ultrasonic sensors have a range of up to 4 meters and output a pulse whose duration typically translates to about 58 microseconds/centimeter distance to the object. If the goal is to avoid running into the object, ideally staying more than 10cm away from it, then a resolution of +/-2 cm on the distance measurement should be acceptable. Two centimeters translate to 2*58 = 116 microseconds, so the pulse should be checked about every 116 microseconds to see if its started or stopped.