Setting up RTOS for dual-core & multi-threaded operation



A key feature of ESP32 that makes it so much more popular than its predecessor, ESP8266, is the presence of two cores on the chip. This means that we can have two processes executing in parallel on two different cores. Of course, you can argue that parallel operation can also be achieved on a single thread using FreeRTOS/ any other equivalent RTOS. However, there is a difference between two processes running in parallel on a single core, and they running in parallel on different cores. On a single core, often, one thread has to wait for the other to pause before it can begin execution. On two cores, parallel execution is literally parallel, because they are literally occupying different processors.

Sounds exciting? Let's get started with a real example, that demonstrates how to create two tasks and assign them to specific cores within ESP32.

Code Walkthrough

GitHub link: https://github.com/

To use FreeRTOS within the Arduino IDE, no additional imports are required. It comes inbuilt. What we need to do is define two functions that we wish to run on the two cores. They are defined first. One function evaluates the first 25 terms of the Fibonacci series and prints every 5th of them. It does so in a loop. The second function evaluates the sum of numbers from 1 to 100. It too does so in a loop. In other words, after calculating the sum from 1 to 100 once, it does so again, after printing the ID of the core it is executing on. We are not printing all the numbers, but only every 5th number in both the sequences, because both the cores will try to access the same Serial Monitor. Therefore, if we print every number, they will try to access the Serial Monitor at the same time frequently.

void print_fibonacci() {
   int n1 = 0;
   int n2 = 1;
   int term = 0;
   char print_buf[300];
   sprintf(print_buf, "Term %d: %d\n", term, n1);
   Serial.print(print_buf);
   term = term + 1;
   sprintf(print_buf, "Term %d: %d\n", term, n1);
   Serial.print(print_buf);
   for (;;) {
      term = term + 1;
      int n3 = n1 + n2;
      if(term%5 == 0){
      sprintf(print_buf, "Term %d: %d\n", term, n3);
      Serial.println(print_buf);
   }
   n1 = n2;
   n2 = n3;

   if (term >= 25) break;
   }
}
void sum_numbers() {
   int n1 = 1;
   int sum = 1;
   char print_buf[300];
   for (;;) {
      if(n1 %5 == 0){
         sprintf(print_buf, "                                                            Term %d: %d\n", n1, sum);
         Serial.println(print_buf);
      }
      n1 = n1 + 1;
      sum = sum+n1;
      if (n1 >= 100) break;
   }
}
void codeForTask1( void * parameter ) {
   for (;;) {
      Serial.print("Code is running on Core: ");Serial.println( xPortGetCoreID());
      print_fibonacci();
   }
}
void codeForTask2( void * parameter ) {
   for (;;) {
      Serial.print("                                                            Code is running on Core: ");Serial.println( xPortGetCoreID());
      sum_numbers();
   }
}

You can see above that we have shifted the print statement for Task 2 to the right. This will help us differentiate between the prints happening from Task 1 and Task 2.

Next we define task handles. Task handles serve the purpose of referencing that particular task in other parts of the code. Since we have two tasks, we will define two task handles.

TaskHandle_t Task1, Task2;

Now that the functions are ready, we can move to the setup part. Within setup(), we simply pin the two tasks to the respective cores. First, let me show you the code snippet.

void setup() {
   Serial.begin(115200);
   /*Syntax for assigning task to a core:
   xTaskCreatePinnedToCore(
                    coreTask,   // Function to implement the task
                    "coreTask", // Name of the task 
                    10000,      // Stack size in words 
                    NULL,       // Task input parameter 
                    0,          // Priority of the task 
                    NULL,       // Task handle. 
                    taskCore);  // Core where the task should run 
   */
   xTaskCreatePinnedToCore(    codeForTask1,    "FibonacciTask",    5000,      NULL,    2,    &Task1,    0);
   //delay(500);  // needed to start-up task1
   xTaskCreatePinnedToCore(    codeForTask2,    "SumTask",    5000,    NULL,    2,    &Task2,    1);
}

Now let's dive deeper into the xTaskCreatePinnedToCore function. As you can see, it takes a total of 7 arguments. Their description is as follows.

  • The first argument codeForTask1 is the function that will be executed by the task

  • The second argument "FibonacciTask" is the label or name of that task

  • The third argument 1000 is the stack size in bytes that is allotted to this task

  • The fourth argument NULL is the task input parameter. Basically, if you wish to input any parameter to the task, it goes here

  • The fifth argument 1 defines the priority of the task. The higher the value, the more is the priority of the task.

  • The sixth argument &Task1 is the Task Handle

  • The final argument 0 is the Code on which the task will run. If the value is 0, the task will run on Core 0. If it is 1, the task will run on Code 1.

Finally, the loop can be left empty, since the two tasks running on the two cores are of more importance here.

void loop() {}

You can see the output on the Serial Monitor. Note that there are no delays anywhere in the code. Therefore, both the series getting incremented shows that the computations are happening in parallel. The Core IDs printed on the Serial Monitor also confirm that.

Serial Monitor Output

Please note that Arduino sketches, by default, run on Core 1. This can be verified using Serial.print( xPortGetCoreID()); So if you add some code in loop(), it will run as another thread on Core 1. In that case, Core 0 will have a single task running, while Core 1 will have two tasks running.

Advertisements