Basics

Introduction of tasks

What is a task? A task is an entity managing the life cycle of the execution of a function. A task evolves in a multitasking context which means that many tasks are in competition to get the CPU time resource at the same time (because the CPU can have only one thread of operations). Only one task will be choosen during a given time slot. This is the role of the Scheduler, ordering the tasks and giving them a determined amount of time before letting another task get the CPU resources and so on.

Screenshot

Tasks

As you can see in the Getting started page, you can create a task by calling the function Scheduler::createTask() on the scheduler instance. For the moment, there are 3 sort of tasks:

The last two sort of tasks are described in Special usage page of this tutorial.

To create a task you need to declare it as a global variable: Task* task1 = NULL;. Then, in the setup function assign this pointer by calling Scheduler::createTask():

task1 = scheduler->createTask(&func1, 60, PrNormal);.

The first argument is the address of the function you want to associate with the task. The second is the amount of memory you want to reserve for your task (for the stack because each task as its own stack). The third parameter defines the priority relative to the other tasks you will create (PrLow, PrNormal, PrHigh).

#include <os48.h>

using namespace os48;

Scheduler* scheduler = Scheduler::get();

Task* task1 = NULL; 

void setup() {  
  task1 = scheduler->createTask(&func1, 60, PrNormal);

  //[...]

  scheduler->start();
}

void func1()
{
  //[...]
}

void loop()
{
}

warning A task using the Serial object should reserve a lot of memory (> 50 bytes).

Inside the function associated to your task, you can use task()-> which returns the instance of the current task executing this function.

More tasks you create, the more the system will be slow, because there will be more tasks in competition. Also the memory can quickly overflow. To avoid these situations, you have to think how optimize your application by asking yourself:

These questions can be difficult to understand at the beginning but don't worry, that will be soon spontaneous.

Get errors code

Functions of the lib may generate an error code for some reasons. For example, if you give to a function some wrong arguments.

In the same way that Linux and the variable errno, OS48 provides its own variables.

To retrieve the last error code you can use Scheduler::getLastError() or Task::getLastError(). The functions returning an error are described in the library documentation.

You can see below an example of a recommended usage.

void func1()
{
  if (!task()->resume())
  {
    if (task()->getLastError() == TskErrIncorrectState)
    {
      OS48_ATOMIC_BLOCK
      {
        Serial.println("My task is not suspended or sleeping !");
      }
    }
  }
}

The error codes returned are described in the library documentation. In the example above, the function returns TskErrIncorrectState meaning that the function expects a non-alive task.

The error code is stored until you call a kernel function which may return another error.

Detect unauthorized memory overwrites

You should know that the 8-bits AVR MCUs don't provides a MMU unit within. So the memory areas reserved for each task are not protected against a write beyond their limits.

The size you pass in argument to create a task is very important and you have to think well about how much memory you want to reserve to your task. If you give an overestimated size, you risk to waste memory space. On the contrary, if you underestimate the size, you have a bigger risk to cause a memory overflow.

The library provides overflow detections in order to inform you that your program take a bigger memory size than you expected.

For experimented users, in the Advanced_parameters.h file, you can disable the detection in order to save CPU resources during the context switch.

It's recommended to use the function Scheduler::setStackOverflowFnc(void_fnc_t fnc). The kernel executes the code in this function if it detects a stack overflow (interrupts are disabled in the function). You will be able to know why your program causes malfunctions. If no function is specified, the kernel will execute an infinite loop.

After the completion of this custom function, the kernel enters, by default, in an infinite loop.

See the example below. The example sets deliberately the size to 0 byte to show you a stack overflow.

#include <os48.h>

using namespace os48;

Scheduler* scheduler = Scheduler::get();

Task* task1 = NULL;

void setup() {
  Serial.begin(9600);
  task1 = scheduler->createTask(&doOverflow, 0);

  scheduler->setStackOverflowFnc(&fncSO); // <-- Define your custom function here

  scheduler->start();
}

void doOverflow()
{
  /**
   * This function takes 3 bytes.
   * 1 for the variable 'i'
   * 2 for the volatile keyword to store the address of i
   * 
   * Note to advanced users:
   * volatile ensures i is read and write at each access in order to avoid
   * GCC optimizations (without volatile the compiler removes the loop)
   */
  for (volatile byte i = 0; ; ++i) {}//busy wait
}

void fncSO()
{
  Serial.println("Stack overflow!");
  Serial.print("ID of the task affected: ");
  Serial.println(task()->getId());
  Serial.print("Free stack size: ");
  Serial.println(task()->getUserFreeStackSize()); 
  Serial.flush();
}


void loop() {}

This is a representation of an overlow:

Screenshot

In order to have some helps in your development, the kernel provides some functions to get statistics:

warning Call Task::clearStackFootprints() just after the creation of the task for Task::printMem().

Example of Task::print() and Scheduler::print():

#include <os48.h>

using namespace os48;

Scheduler* scheduler = Scheduler::get();

Task* task1 = NULL;
Task* task2 = NULL;
Task* taskP = NULL;

void setup() {
  Serial.begin(9600);
  Serial.println("Creating tasks...");

  task1 = scheduler->createTask(&task1Func, 20, PrHigh); //id 1
  task2 = scheduler->createTask(&task2Func, 20); //id 2
  taskP = scheduler->createTask(&taskPFunc, 150); //id 3

  Serial.println("Starting...");

  scheduler->start();
}

void taskPFunc()
{
  for (;;)
  {
    task()->sleep(2000);

    OS48_ATOMIC_BLOCK // <-- disable all interrupts to prevent update of internal variables (because the prints consume also CPU time)
    {
      Serial.println(F("----------------"));
      task1->print(Serial, true);
      task2->print(Serial);
      taskP->print(Serial);
      scheduler->print(Serial);
      Serial.println(F("----------------"));
    }   
  }
}

void task1Func()
{
  for (volatile int i = 0;; i++) //fake work
  {
  }
}

void task2Func()
{
  for (volatile int i = 0;; i++) //fake work
  {
  }
}

void loop() {}

Note that the task 1 has a high priority and therefore consumes more CPU time than task 2. The task 3 consumes almost nothing because this task is asleep most of the time.

You should get something like this (or equivalent):

Screenshot

Task::printMem() is usefull in order to have a better idea of the usage of the memory by a task. While the column Used shows the used memory at the call location , the column Footprint shows the highest amount of memory used during the all life cycle of the task. With the column UserTotal and the column Footprint, you can review and adjust the amount of memory reserved for your task. The column %footprint shows the ratio Footprint / UserTotal.

To get the last footprint as an integer in your code you can call Task::getLastStackFootprint().

Example with Task::printMem():

#include <os48.h>

using namespace os48;

Scheduler* scheduler = Scheduler::get();

Task* task1 = NULL;
Task* task2 = NULL;
Task* taskP = NULL;

void setup() {
  Serial.begin(9600);
  Serial.println("Creating tasks...");

  task1 = scheduler->createTask(&task1Func, 40); //id 1
  task1->clearStackFootprints(); //IMPORTANT to call Task::printMem()
  task2 = scheduler->createTask(&task2Func, 40); //id 2
  task2->clearStackFootprints(); //IMPORTANT to call Task::printMem()
  taskP = scheduler->createTask(&taskPFunc, 150); //id 3
  taskP->clearStackFootprints(); //IMPORTANT to call Task::printMem()

  Serial.println("Starting...");

  scheduler->start();
}

void taskPFunc()
{
  for (;;)
  {
    task()->sleep(2000);

    OS48_ATOMIC_BLOCK // <-- disable all interrupts to prevent update of internal variables (because the prints consume also CPU time)
    {
      Serial.println(F("----------------"));
      task1->printMem(Serial, true);
      task2->printMem(Serial);
      taskP->printMem(Serial);
    }
  }
}

void task1Func()
{
  volatile byte bytes[5];
  for (volatile int i = 0;; i++) //fake work
  {
    bytes[i % 5] = (byte) i;
  }
}

void task2Func()
{
  volatile byte bytes[15];
  for (volatile int i = 0;; i++) //fake work
  {
    bytes[i % 15] = (byte) i;
  }
}

void loop() {}

Result:

Screenshot

Take a look to the Footprint column. The task 2 uses 10 bytes more than the task 1 because of the declaration of the bytes variable which have not the same size. The task 3 uses a lot of memory because of the usage of the Serial object.

Stop the scheduler

Sometimes, it's interresting to stop all tasks. You can use the function Scheduler::stop(void_fnc_t fnc) to perform that. The scheduler is stopped and the function passed as argument will be executed. An infinite loop is executed after your function execution.

Interruptions are still enabled.

#include <os48.h>
using namespace os48;

Scheduler* scheduler = Scheduler::get();

Task* task1 = NULL;
Task* task2 = NULL;

void setup() {
  Serial.begin(9600);
  task1 = scheduler->createTask(&func1, 50);
  task2 = scheduler->createTask(&func2, 50);

  scheduler->start();
}

void func1()
{
  for(;;)
  {
    OS48_ATOMIC_BLOCK
    {
      Serial.println("...");
      task()->sleep(500);
    }
  }
}

void func2()
{
  task()->sleep(2000);
  scheduler->stop(&stopProcess);
}

void stopProcess()
{
  Serial.println("Kernel is stopped");
}

void loop() {}

You can restart the scheduler inside the function but you need to call Task::reset() on each task before.

Serial.println("Kernel is stopped");
Serial.flush();
task1->reset();
task2->reset();
scheduler->start();

Delete a task

You can delete a task to save memory by using Scheduler::deleteTask()

#include <os48.h>
using namespace os48;

Scheduler* scheduler = Scheduler::get();

Task* task1 = NULL;
Task* task2 = NULL;

void setup() {
  Serial.begin(9600);
  task1 = scheduler->createTask(&func1, 60);
  task2 = scheduler->createTask(&func2, 60);

  scheduler->start();
}

void func1()
{
  OS48_NO_CS_BLOCK
  {
    Serial.print("Free memory: ");
    Serial.println(scheduler->getFreeMemorySize());
    task()->sleep(5000);
    scheduler->deleteTask(task2);
    Serial.print("Free memory: ");
    Serial.println(scheduler->getFreeMemorySize());
    Serial.flush();
  }
}

void func2()
{
  for (;;)
  {
    OS48_NO_CS_BLOCK
    {
      task()->sleep(1000);
      Serial.println("task2");
    }
  }
}

void loop() {}

Sharing variables / functions

Set the same functions to different tasks

You can assign the same function to different tasks.

#include <os48.h>
using namespace os48;

Scheduler* scheduler = Scheduler::get();

Task* task1 = NULL;
Task* task2 = NULL;

void setup() {
  Serial.begin(9600);
  task1 = scheduler->createTask(&func, 60);
  task2 = scheduler->createTask(&func, 60);

  scheduler->start();
}

void func()
{
  for (;;)
  {
    OS48_ATOMIC_BLOCK
    {
      task()->sleep(1000);
      Serial.println(task()->getId());
    }
  }
}


void loop() {}

Share a variable between different tasks

This section of the turorial is very important.

Assume this code:

#include <os48.h>
using namespace os48;

Scheduler* scheduler = Scheduler::get();

Task* task1 = NULL;
Task* task2 = NULL;

void setup() {
  Serial.begin(9600);
  task1 = scheduler->createTask(&func1, 60);
  task2 = scheduler->createTask(&func2, 60);

  scheduler->start();
}

unsigned int shared_i = 0;

void func1()
{
  for (;;)
  {
    ++shared_i;
  }
}

void func2()
{
  for (;;)
  {
    OS48_ATOMIC_BLOCK
    {
      Serial.println((unsigned int) shared_i);
    }
  }
}


void loop() {}

At the execution, you get always 0. The reason is very simple. The compiler can optimize the code and it stores shared_i in a register for the two tasks. Therefore, it doesn't noticed that the variable can be changed with different threads (because each task has their own register values).

You need to use volatile variable: volatile unsigned int shared_i = 0;. Now, that should work well.

Indeed, volatile notices the compiler to always read and write the variable from the memory. If you omit volatile the variable is always read from the register.

Unfortunately, it's still not perfect. shared_i is stored in two bytes. If you write ++shared_i, the compiler produces the following ASM code :

LDS R24,0x0142      Load direct from data space 
LDS R25,0x0143      Load direct from data space 
ADIW R24,0x01       Add immediate to word 
STS 0x0143,R25      Store direct to data space 
STS 0x0142,R24      Store direct to data space 

If the kernel interruption occurs between the two STS for example, the task2 will read a corrupted value. You have to protect the operation by surrounding it with OS48_ATOMIC_BLOCK:

void func1()
{
  for (;;)
  {
    OS48_ATOMIC_BLOCK
    {
      ++shared_i;
    }
  }
}

Now, the code is perfect. Note that this macro is useless if the variable would have been stored with 1 byte.

If you don't want to tag the variable as volatile to keep performances, you can use, when you want it in your code, the macro OS47_VOLATILE_R(TYPE, VARIABLE) to read and write the variable from stack.

In the rest of this tutorial, we will see how warn task 2 of a value change using synchronization primitives.

Priority of tasks

You can assign a priority to a task. By default the scheduling algorithm is Round Robin. You can see how to change it in in the advanced section of this tutorial. With the Round Robin algorithm, the time slot allocated to a task depends of its priority. A task with a high priority has more CPU cycles to perform its job.

You can set the priority when you create a task with Task::create() or you can change later the priority on a task with Task::setPriority(). You have 5 priority choices: PrHigh, PrAboveNormal, PrNormal, PrBelowNormal, or PrLow. By default, the normal priority is used when you create a task if the argument is not specified.

#include <os48.h>
using namespace os48;

Scheduler* scheduler = Scheduler::get();

Task* task1 = NULL;
Task* task2 = NULL;
Task* task3 = NULL;

void setup() {
  Serial.begin(9600);

  task1 = scheduler->createTask(&func, 70, PrHigh);
  task2 = scheduler->createTask(&func, 70, PrNormal);
  task3 = scheduler->createTask(&func, 70, PrNormal);

  task3->setPriority(PrLow);

  scheduler->start();
}

void func()
{
  for (unsigned long i = 0; i < 10000 ; ++i)
  {
    if (i % 1000 == 0)
    {
      OS48_NO_CS_BLOCK
      {
        Serial.print("Task ");
        Serial.print(task()->getId());
        Serial.print(": ");
        Serial.println(i);
      }
    }
  }

  OS48_NO_CS_BLOCK
  {
    Serial.print("Task ");
    Serial.print(task()->getId());
    Serial.println(" done!");
  }

  Serial.flush();
}

void loop() {}

On the above example, you can see that the task 1 which has the highest returns first and its counter is always greater than the other tasks.

Task states

Tasks have 3 categories of states:

Each category has several states:

Screenshot

On the left side you can see how a task can pass to an alive state to a blocked or dead state. On the right side you can see how a task can be alive.

warning Whatever the previous state of the task, the task state can't be directly "running". A state can't be "running" before being "queuing". Only the scheduling algorithm can change the state to "running". That's why a task, previously asleep, and which has just been woken up, can't resume immediately its execution where it left off before sleep. A task with an higher priority can be first chosen.

You can get at any time the current state of a task with Task::getState().

Suspend / resume a task

You can suspend temporarly a task with the function Task::suspend() and resume it with Task::resume().

Sleep a task

Call Task::sleep() by specifying a duration in milliseconds. You can abort the wait by calling Task::resume(). The task is again inserted in the list of alive tasks once the period has expired. But that does not mean it will be resumed immediately. That's why, the task will sleep AT LEAST the specified duration before the resumption. There are many reasons that a task can't resume its process after the expected delay:

warning The sleep function ensures only that the state is StQueuing after the expiration of the delay.

In the advanced section of this tutorial, we will see that the most responsive algorithm is the 'Intelligent'.

A task having a high priority has more chance to resume its process within the given delay. You can change the task priority before call the sleep function or think about a more responsive scheduling algorithm.

Abort a task

To abort the job execution of a task you can call Task::abort().

Reset a task

At any time, you can reset the task. The task restarts the execution of the function associated. The stack pointer of the task is also reset.