ESP32 Microcontroller Tutorial – Multi-core Task Programming

Reminder: This issue covers ESP32’s FreeRTOS and multi-core task programming.

Project Preview: An interactive IoT chassis car that does not require a computer board, costing around a hundred yuan.

In-depth Analysis of ESP32 Multi-threaded Programming

In IoT projects, multi-tasking is a common requirement. The ESP32, as a powerful microcontroller, has a built-in dual-core processor and supports multi-threading through the FreeRTOS operating system, making it possible to handle multiple tasks simultaneously. This article will analyze the multi-threaded programming methods and characteristics of the ESP32 from a beginner’s perspective.

1. Introduction to ESP32S3 Multi-core Features

The ESP32-S3 is a low-power microcontroller developed by Espressif Systems. It is part of the ESP32 series, designed for general low-power devices, IoT applications, and smart home fields.

The ESP32-S3 features a dual-core Xtensa® 32-bit LX7 microprocessor, meaning it has two processing cores that can run tasks simultaneously, increasing processing capacity and multi-tasking efficiency. These two cores can operate independently or work together to handle complex computational tasks or multiple concurrent operations.

When programming the ESP32-S3, you can specify which core a specific task should run on. This can be achieved using FreeRTOS (Real-Time Operating System), which is commonly used with the ESP32-S3.

ESP32 Microcontroller Tutorial - Multi-core Task Programming

2. Introduction to FreeRTOS

FreeRTOS is a mini real-time operating system kernel that provides rich APIs for multi-tasking. On the ESP32, FreeRTOS has been highly integrated and optimized to fully utilize the dual-core processor of the ESP32. The main features of FreeRTOS include:

Task management: You can create, delete, suspend, and resume multiple tasks.

Synchronization primitives: Including mutexes, semaphores, event flags, etc., for task synchronization.

Memory management: Provides dynamic memory allocation and deallocation.

Timers: Provides software timers for executing timed tasks.

Here is a brief introduction to the low-level implementation of FreeRTOS on ESP32:

The low-level implementation of FreeRTOS on the ESP32 involves hardware abstraction, interrupt management, multi-core scheduling, and more.

Hardware Abstraction Layer (HAL)

FreeRTOS provides a hardware abstraction layer for different hardware platforms, allowing the core code of FreeRTOS to run on different hardware without modification. On the ESP32, the HAL includes abstractions of hardware resources such as CPU, timers, and interrupt controllers. These abstractions enable FreeRTOS to leverage the hardware features of the ESP32, such as timers for scheduling and interrupt controllers for managing interrupts.

Interrupt Management

The interrupt management of the ESP32 is implemented through its interrupt controller. FreeRTOS defines a set of rules for writing interrupt service routines (ISRs), ensuring that ISRs can safely interact with FreeRTOS tasks and kernel objects. For example, FreeRTOS provides the portENTER_CRITICAL and portEXIT_CRITICAL macros to protect critical sections in ISRs, preventing data inconsistency during task switching.

Multi-core Scheduling

FreeRTOS on the ESP32 utilizes the dual-core feature to implement multi-core scheduling. The FreeRTOS scheduler is designed to run in a multi-core environment, where each core can run its own tasks. The implementation of FreeRTOS on the ESP32 uses special synchronization mechanisms, such as “spinlock” locks, to synchronize the task states across the two cores.

Task Switching and Context Saving

Task switching is the core of multi-task operating systems, allowing the CPU to switch execution between different tasks. On the ESP32, FreeRTOS’s task switching involves saving and restoring the context of tasks, including CPU registers and other state information. FreeRTOS uses assembly language functions portSAVE_CONTEXT and portRESTORE_CONTEXT to handle these operations, ensuring that each task’s state is correctly saved and restored during task switching.

3. Basics of ESP32 Multi-threaded Programming

Basic Multi-threading Example:

#include <Arduino.h>
// Define task handles
TaskHandle_t Task1;
TaskHandle_t Task2;
// Define task functions
void Task1code( void * pvParameters ) {
  Serial.print("Task1 running on core ");
  Serial.println(xPortGetCoreID());
  for(;;) {
    Serial.println("This is Task1");
    vTaskDelay(1000 / portTICK_PERIOD_MS); // Delay 1 second
  }
}
void Task2code( void * pvParameters ) {
  Serial.print("Task2 running on core ");
  Serial.println(xPortGetCoreID());
  for(;;) {
    Serial.println("This is Task2");
    vTaskDelay(2000 / portTICK_PERIOD_MS); // Delay 2 seconds
  }
}
void setup() {
  Serial.begin(115200);
  // Create tasks
  xTaskCreatePinnedToCore(
    Task1code, /* Task function */
    "Task1",   /* Task name */
    10000,     /* Stack size */
    NULL,      /* Parameters passed to task function */
    1,         /* Priority */
    &Task1,    /* Task handle */
    0);        /* Core number */
  xTaskCreatePinnedToCore(
    Task2code,
    "Task2",
    10000,
    NULL,
    1,
    &Task2,
    1);
}
void loop() {
  // No need to do anything here, as all work is done in tasks
}

In this example, we create two tasks (Task1 and Task2). Each task runs on a different core (the ESP32S3 has two cores). Each task prints a message and then delays for a period of time. Task1 prints a message once every second, while Task2 prints a message every two seconds.

ESP32 Microcontroller Tutorial - Multi-core Task Programming

It can be seen that Task1 runs on core 0, and Task2 runs on core 1.

A simple analysis of the above program shows that the main functions for implementing multi-threading are only two:

Task1code and xTaskCreatePinnedToCore

Task1code Function:

Task1code is the task function used to execute in the FreeRTOS task. In this example, it serves as the body of Task 1. Here is a detailed analysis of this function:

void Task1code( void * pvParameters ) {
  Serial.print("Task1 running on core ");
  Serial.println(xPortGetCoreID());
  for(;;) {
    Serial.println("This is Task1");
    vTaskDelay(1000 / portTICK_PERIOD_MS); // Delay 1 second
  }
}

Function Definition: void Task1code( void * pvParameters ). This function returns no value (void) and accepts a void * type parameter. This parameter can be used to pass any type of data to the task, but in this example, we did not use it.

Print Task Information: Serial.print(“Task1 running on core “); and Serial.println(xPortGetCoreID());. These two lines of code print a message indicating which core Task 1 is running on. The xPortGetCoreID() function returns the core number on which the current task is running.

Infinite Loop: for(;;) {…}. This is an infinite loop, and the task will run continuously in this loop until it is deleted or the ESP32S3 is restarted.

Print Task Message: Serial.println(“This is Task1”);. This line of code prints a message indicating that this is Task 1 in each iteration of the loop.

Delay: vTaskDelay(1000 / portTICK_PERIOD_MS);. This line of code makes the task delay for 1 second (1000 milliseconds). portTICK_PERIOD_MS is a constant in FreeRTOS representing the milliseconds of a clock tick. The vTaskDelay() function blocks the task until the specified number of clock ticks has passed. In this example, we use the vTaskDelay() function to control the execution frequency of the task.

xTaskCreatePinnedToCore Function:

xTaskCreatePinnedToCore is a function in the FreeRTOS library used to create a new task on a specified core. In a multi-core processor like the ESP32S3, this function is very useful as it allows you to control which core each task runs on.

BaseType_t xTaskCreatePinnedToCore(  TaskFunction_t pvTaskCode,  const char * const pcName,  const uint32_t usStackDepth,  void * const pvParameters,  UBaseType_t uxPriority,  TaskHandle_t * const pxCreatedTask,  const BaseType_t xCoreID);

pvTaskCode: This is a pointer to the task function. The task function is the body of the task, containing the code that needs to be executed.

pcName: This is the name of the task, a null-terminated string. The name of the task is mainly used for debugging.

usStackDepth: This is the size of the task stack, in words. The task stack is used to store local variables and return addresses of function calls. If the task stack is too small, it may cause stack overflow; if the task stack is too large, it may waste memory.

pvParameters: This is a pointer used to pass parameters to the task function. This parameter can be any type of data, but in the task function, it is always treated as void * type.

uxPriority: This is the priority of the task. In FreeRTOS, the higher the number, the higher the priority. When multiple tasks are in a ready state, the task with the highest priority will be executed first.

pxCreatedTask: This is a pointer to the task handle. The task handle is a “handle” used to reference the task. You can use the task handle to control the task, such as deleting the task or changing the task’s priority.

xCoreID: This is the core number on which the task should run. On the ESP32S3, this number can be 0 or 1, representing the two cores. If you want FreeRTOS to automatically choose a core, you can set this parameter to tskNO_AFFINITY.

Task Scheduling

On the ESP32, FreeRTOS supports preemptive scheduling and time-slice scheduling. Preemptive scheduling means that whenever a higher-priority task is ready, it will immediately preempt the currently running task. Time-slice scheduling allows tasks of the same priority to share CPU time fairly.

Synchronization Mechanisms

In a multi-tasking environment, task synchronization is another important issue. FreeRTOS in the ESP32 provides various task synchronization mechanisms, including semaphores, mutexes, and event groups.

Semaphore: A semaphore is a synchronization mechanism used to protect shared resources. When a task needs to access a shared resource, it must first obtain the semaphore. If the semaphore is already occupied by other tasks, this task will be blocked until the semaphore becomes available.

Mutex: A mutex is a special type of semaphore mainly used to protect shared resources and prevent simultaneous access. Unlike a semaphore, a mutex has the concept of ownership, and only the task that holds the mutex can release it.

Event Group: An event group is a mechanism used to synchronize multiple tasks. Each event group contains a set of event bits, and tasks can wait for one or more event bits to be set. When event bits are set, tasks waiting for those event bits will be awakened.

4. Advanced ESP32 Multi-threaded Programming

Core Affinity

The ESP32 is a dual-core microcontroller, which means it has two CPU cores that can process tasks in parallel. However, not all tasks are suitable for running on any core. Some tasks may need to frequently access certain specific hardware resources that may only be accessible by a specific core. This introduces the concept of core affinity.

Core affinity refers to the preference of a task to run on a specific CPU core. In FreeRTOS, you can use the xTaskCreatePinnedToCore() function to set the core affinity of a task. For example, the following code creates a task and pins it to run on core 0:

void taskCode(void * parameter) {
  for (;;) {
    // Task code
  }
}
void setup() {
  xTaskCreatePinnedToCore(
    taskCode,          // Task function
    "TaskName",        // Task name
    10000,             // Stack size
    NULL,              // Parameters passed to task function
    1,                 // Priority
    NULL,              // Task handle
    0                  // CPU core
  );
}

In this example, the task taskCode will always run on CPU core 0, regardless of whether CPU core 1 is idle. This ensures that taskCode always has enough CPU time to execute without being interrupted by other tasks running on core 1.

Task Queues

In a multi-tasking environment, tasks often need to exchange data in some way. FreeRTOS provides a mechanism called task queues (Task Queue) to achieve this.

A task queue is a first-in-first-out (FIFO) data structure, where tasks can send data items to the queue or receive data items from the queue. When the queue is empty, tasks trying to receive data from the queue will be blocked until other tasks send data to the queue. Similarly, when the queue is full, tasks trying to send data to the queue will be blocked until other tasks receive data from the queue.

Here is an example of using task queues:

QueueHandle_t queue;
void senderTask(void * parameter) {
  int item = 0;
  for (;;) {
    xQueueSend(queue, &item, portMAX_DELAY);
    item++;
  }
}
void receiverTask(void * parameter) {
  int item;
  for (;;) {
    xQueueReceive(queue, &item, portMAX_DELAY);
    Serial.println(item);
  }
}
void setup() {
  queue = xQueueCreate(10, sizeof(int));
  xTaskCreate(senderTask, "Sender", 10000, NULL, 1, NULL);
  xTaskCreate(receiverTask, "Receiver", 10000, NULL, 1, NULL);
}

In this example, we create a queue and two tasks. The senderTask continuously sends data to the queue, while the receiverTask continuously receives data from the queue. When the queue is empty, the receiverTask will be blocked until the senderTask sends data to the queue. When the queue is full, the senderTask will be blocked until the receiverTask receives data from the queue.

Overall, task queues are a powerful tool that can help you pass data between tasks, synchronize task execution, and manage shared resources. By using task queues effectively, you can build complex multi-task applications.

Task Notifications

Task notifications are a lightweight and efficient communication mechanism that can be used to wake up one or more tasks. Each task has an associated notification value, and tasks can wait for their notification value to be set or modify their notification value.

A common use case for task notifications is to wake up a task in an interrupt service routine (ISR). For example, when an external interrupt is triggered, the ISR can wake up the task by setting its notification value, and then the task can handle the interrupt event.

Here is an example using task notifications:

void taskCode(void * parameter) {
  uint32_t notificationValue;
  for (;;) {
    xTaskNotifyWait(0, 0, &notificationValue, portMAX_DELAY);
    // Handle notification
  }
}
void isr() {
  BaseType_t xHigherPriorityTaskWoken = pdFALSE;
  vTaskNotifyGiveFromISR(taskHandle, &xHigherPriorityTaskWoken);
  portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
void setup() {
  xTaskCreate(taskCode, "TaskName", 10000, NULL, 1, &taskHandle);
  attachInterrupt(digitalPinToInterrupt(pin), isr, RISING);
}

In this example, when the level on the pin rises, the ISR wakes up the task taskCode. The taskCode can handle the interrupt event after receiving the notification.

Mutexes

A mutex is a synchronization mechanism that can be used to protect shared resources and prevent multiple tasks from accessing them simultaneously. When a task acquires a mutex, other tasks cannot acquire that mutex until it is released by that task.

Here is an example using mutexes:

SemaphoreHandle_t mutex;
void taskCode(void * parameter) {
  for (;;) {
    xSemaphoreTake(mutex, portMAX_DELAY);
    // Access shared resource
    xSemaphoreGive(mutex);
  }
}
void setup() {
  mutex = xSemaphoreCreateMutex();
  xTaskCreate(taskCode, "TaskName1", 10000, NULL, 1, NULL);
  xTaskCreate(taskCode, "TaskName2", 10000, NULL, 1, NULL);
}

In this example, both tasks try to access the same shared resource. By using a mutex, we can ensure that only one task can access that resource at any time, preventing data races and other concurrency issues.

Semaphores

A semaphore is a counting synchronization mechanism that can be used to control access to a set of shared resources. The value of the semaphore indicates the number of available resources. When a task acquires a semaphore, the semaphore value decreases; when a task releases a semaphore, the semaphore value increases.

Here is an example using semaphores:

SemaphoreHandle_t semaphore;
void taskCode(void * parameter) {
  for (;;) {
    xSemaphoreTake(semaphore, portMAX_DELAY);
    // Access shared resource
    xSemaphoreGive(semaphore);
  }
}
void setup() {
  semaphore = xSemaphoreCreateCounting(3, 3);
  xTaskCreate(taskCode, "TaskName1", 10000, NULL, 1, NULL);
  xTaskCreate(taskCode, "TaskName2", 10000, NULL, 1, NULL);
  xTaskCreate(taskCode, "TaskName3", 10000, NULL, 1, NULL);
}

In this example, we have three tasks and three shared resources. By using semaphores, we can ensure that at most three tasks can access shared resources at any time.

5. Practical Code

This is a code for an IoT flower pot that runs on the ESP32S3, capable of uploading sensor information to a server and receiving control information from the server.

#include <Arduino.h>
#include <WiFi.h>
#include <PubSubClient.h>
#include "DHT.h"
#include <Wire.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_BMP280.h>
#include <BH1750.h>
const char *ssid = "809";
const char *password = "809809809";
const char *mqttServer = "192.168.3.222";
const int mqttPort = 1883;
#define DHTPIN 4      // Digital pin connected to the DHT sensor
#define DHTTYPE DHT11 // DHT 11
DHT dht(DHTPIN, DHTTYPE);
#define soil 10
#define STBY 38
#define AIN1 36
#define AIN2 39
#define sun_pin 15
#define SCL_PIN 8
#define SDA_PIN 9
#define Sub "js"
#define Pub "cgq"
const int flowPin = 47;           volatile int flowPulseCount;  const float pulsesPerLitre = 4834.0; Adafruit_BMP280 bmp;                
int sensorValue; long sum = 0;    int vout = 0;  int uv = 0;    
// PWMint freq = 5000;    int channel = 10;  int resolution = 8;
float Humidity; // = dht.readHumidity();// Humidity= dht.readHumidity();float Temperature; // = dht.readTemperature(); // Temperature= dht.readTemperature();double soil_moisture; // soil_moisture= analogRead(soil);uint16_t lux; //= lightMeter.readLightLevel();// uint16_t lux = lightMeter.readLightLevel();float BMP_Temperature; //= bmp.readTemperature()// BMP_Temperature = bmp.readTemperature()float BMP_Pressure; // bmp.readPressure()// BMP_Pressure = bmp.readPressure()float BMP_Approxaltitude; //=bmp.readAltitude(1013.25)// BMP_Approxaltitude = bmp.readAltitude(1013.25)float S_vout; //= vout;// S_vout = vout;
WiFiClient espClient;PubSubClient client(espClient);
void pulseCounter(){  flowPulseCount++;}
int readuv(){  sensorValue = 0;  sum = 0;  for (int i = 0; i < 1024; i++)   {    sensorValue = analogRead(15);    sum = sensorValue + sum;    delay(2);  }  vout = sum >> 10;   vout = vout * 4980.0 / 1024;
  if (vout < 50)  {     uv = 0;  }  else if (vout < 227)  {    uv = 1;  }  else if (vout < 318)  {    uv = 2;  }  else if (vout < 408)  {    uv = 3;  }  else if (vout < 503)  {    uv = 4;  }  else if (vout < 606)  {    uv = 5;  }  else if (vout < 696)  {    uv = 6;  }  else if (vout < 795)  {    uv = 7;  }  else if (vout < 881)  {    uv = 8;  }  else if (vout < 976)  {    uv = 9;  }  else if (vout < 1079)  {    uv = 10;  }  else  {    uv = 11;  }  delay(20);  return uv;}
void callback(char *topic, byte *payload, unsigned int length){  Serial.println("Entered callback function");   int message;  for (int i = 0; i < length; i++)  {    message = payload[0]-48;      }  Serial.println(message);  if (message)  {  digitalWrite(37, HIGH);   Serial.println("high");  }  if (!message)  {  digitalWrite(37, LOW);    Serial.println("low");  }   }
void mqttSubscriberTask(void *pvParameters){  (void)pvParameters;  Serial.println("Entered callback function111");  for (;;)  {    client.loop();    // ledcWrite(LEDC_CHANNEL_1, motorSpeed);    vTaskDelay(10 / portTICK_PERIOD_MS); // wait for 10 ms  }}
void mqttPublisherTask(void *pvParameters){  (void)pvParameters;  pinMode(flowPin, INPUT_PULLUP);                                      attachInterrupt(digitalPinToInterrupt(flowPin), pulseCounter, RISING);  for (;;)  {    Humidity = dht.readHumidity();    Temperature = dht.readTemperature();    // Pressure= bmp280.readPressure()/ 100.0F;    soil_moisture = analogRead(soil);    BMP_Temperature = bmp.readTemperature();    BMP_Pressure = bmp.readPressure();    BMP_Approxaltitude = bmp.readAltitude(1013.25);    S_vout = vout;
    String sensorData = "{";     sensorData += "\"temperature\":";
    sensorData += String(Temperature);
    sensorData += ",\"humidity\":";
    sensorData += String(Humidity);
    sensorData += ",\"soil_moisture\":";
    sensorData += String(soil_moisture);
    sensorData += ",\"BMP_Temperature\":";
    sensorData += String(BMP_Temperature);
    sensorData += ",\" BMP_Pressure\":";
    sensorData += String(BMP_Pressure);
    sensorData += ",\" BMP_Approxaltitude\":";
    sensorData += String(BMP_Approxaltitude);
    sensorData += ",\" The Photocurrent value : \":";
    sensorData += String(S_vout) + "mV";
    sensorData += ",\" UV Index =  \":";
        sensorData += String(readuv());
    sensorData += ",\" flowPulseCount =  \":";
    sensorData += String(flowPulseCount);
    sensorData += "}";
    client.publish(Pub, sensorData.c_str());    Serial.println(sensorData);
    vTaskDelay(500 / portTICK_PERIOD_MS); // wait for 5000 ms  }}
void setup(){  Serial.begin(115200);
  WiFi.begin(ssid, password);
  ledcSetup(channel, freq, resolution);   ledcAttachPin(AIN1, channel);        
  Wire.begin(SDA_PIN, SCL_PIN); // I2C initialization  if (!bmp.begin(0x76))  {    Serial.println(F("Could not find a valid BMP280 sensor, check wiring!"));  }
  /* Default settings from datasheet. */  bmp.setSampling(Adafruit_BMP280::MODE_NORMAL,     /* Operating Mode. */                  Adafruit_BMP280::SAMPLING_X2,     /* Temp. oversampling */                  Adafruit_BMP280::SAMPLING_X16,    /* Pressure oversampling */                  Adafruit_BMP280::FILTER_X16,      /* Filtering. */                  Adafruit_BMP280::STANDBY_MS_500); /* Standby time. */
  while (WiFi.status() != WL_CONNECTED)  {    delay(1000);    Serial.println("Connecting to WiFi...");  }  Serial.println("Connected to the WiFi network");
  client.setServer(mqttServer, mqttPort);  while (!client.connected())  {    Serial.println("Connecting to MQTT...");
    if (client.connect("ESP32Client"))    {      Serial.println("connected");      client.subscribe(Sub);    }    else    {      Serial.print("failed with state ");      Serial.print(client.state());      delay(2000);    }  }
  // pinMode(35, OUTPUT);  pinMode(11, OUTPUT);  pinMode(12, OUTPUT);  pinMode(18, OUTPUT);  pinMode(17, OUTPUT);  pinMode(37, OUTPUT);  pinMode(AIN1, OUTPUT);  pinMode(STBY, OUTPUT);  pinMode(AIN2, OUTPUT);  digitalWrite(STBY, HIGH);  digitalWrite(AIN2, LOW);
  pinMode(48, OUTPUT);  pinMode(45, OUTPUT);  digitalWrite(45, HIGH);  digitalWrite(48, LOW);
  analogReadResolution(12);
  dht.begin();   client.setCallback(callback);

  digitalWrite(12, LOW);  digitalWrite(11, HIGH);
  digitalWrite(18, LOW);  digitalWrite(17, HIGH);
  xTaskCreatePinnedToCore(      mqttSubscriberTask,   /* Task function. */      "mqttSubscriberTask", /* Name of task. */      10000,                /* Stack size in words. */      NULL,                 /* Parameter passed as input of the task */      1,                    /* Priority of the task. */      NULL,      0); /* Task handle. */
  xTaskCreatePinnedToCore(      mqttPublisherTask,   /* Task function. */      "mqttPublisherTask", /* Name of task. */      10000,               /* Stack size in words. */      NULL,                /* Parameter passed as input of the task */      1,                   /* Priority of the task. */      NULL,      1); /* Task handle. */}
void loop(){
}

Running the program will show the information sent by the ESP32 on the cgq topic.

ESP32 Microcontroller Tutorial - Multi-core Task Programming
ESP32 Microcontroller Tutorial - Multi-core Task Programming

ESP32 Microcontroller Tutorial - Multi-core Task Programming

ESP32 Microcontroller Tutorial - Multi-core Task Programming

ESP32 Microcontroller Tutorial - Multi-core Task Programming

ESP32 Microcontroller Tutorial - Multi-core Task Programming

ESP32 Microcontroller Tutorial - Multi-core Task Programming

Leave a Comment

Your email address will not be published. Required fields are marked *