Implementing FreeRTOS Solutions on ESP 32 Devices using Arduino

Tom Wilson
26 min readSep 1, 2024

--

Tutorial Lesson 3 of 4

In Lesson 2 we learned how to create multiple tasks running simultaneously in FreeRTOS. We also learned how to convey initial values to variables used in the tasks and exchange information among tasks using global variables and structs. We built a system of six simultaneously running tasks, all under the control of FreeRTOS. In lesson 3 we will learn several techniques for synchronizing the tasks so that they run in a controlled order. There are several reasons this is important.

  • In many applications there is a hard requirement that information is obtained and processed in a well-defined, coherent order.
  • It is often necessary to control the access to shared resources such as print services so that the task outputs don’t overlap each other.
  • Some tasks need to be triggered by interrupts or timers reacting to outside events.

To this end, we will look at four very common methods that are used to control the processing sequence in real-time operating systems.

  • interupts and timer events
  • semaphores
  • mutexes
  • queues

We will utilize the same hardware as the prior lessons and build on the software we developed in Lesson 2. A warning — this lesson turned out to be a bit longer than the others, but I felt it was useful to consider all four of the synchronization topics together. There is also more code involved, but most of it is pretty generic Arduino code. I’ve tried to highlight the FreeRTOS specific code for you to concentrate on.

Interrupts and Timer Events

Many times we have tasks that only need to be run as a result of an external triggering event or the passage of a specified time period. In Lesson 2 we used an interrupt that was triggered by a button push to invoke the speedup task and change the speed of the blinking LED lights. In this exercise we will add an interrupt generated by a timer to invoke the highwater mark printout to the serial console. Recall that the earlier code ran the task once 5 seconds after the start of execution to give us a snapshot of the memory usage by each task. Now we will modify it to run every 10 seconds so we can see if the memory requirement changes over time. We will utilize a FreeRTOS function called xTimerCreate to set up a timer that will repeatedly initiate a callback function on a scheduled basis, and simply resume the highwater mark task in the callback. To get started, reload the sketch we completed in Lesson 2 and save a copy as rtosLesson3. The required changes are shown below.


// 8 - define function for highwater mark
UBaseType_t uxTaskGetStackHighWaterMark(TaskHandle_t xTask);
TimerHandle_t highwaterTimer; // define data types for the highwater mark timer
BaseType_t hwTimerStarted;

First we add two lines to section 8 to define the data types used by the timer function.

// setup button for interupt processing
pinMode(pinButton, INPUT_PULLUP);
attachInterrupt(pinButton, buttonPush, FALLING);

highwaterTimer = xTimerCreate("Highwater Timer",pdMS_TO_TICKS(10000),pdTRUE,0,hwCallback);
hwTimerStarted = xTimerStart( highwaterTimer, 0 );

Nexe we add the code to create and start the highwaterTimer in the setup section of code. The arguments passed to the xTimerCreate function include:

  • descriptive name for the timer (text)
  • number of ticks between timer events (10000 milliseconds)
  • repeat indicator (if set to pdFALSE it only triggers one time)
  • timer ID (not used here)
  • name of the callback function to execute when a timer event occurs

The xTimerStart functions accepts arguments for the timer handle name and the time to wait for the timer queue to be available. Next we slightly modify the TaskHighwaterMark function created previously by commenting out the vTaskDelay function near the beginning. We will rely on the timer callback to initiate the function.

void TaskHighWater(void *pvParameters)
{
while (true) //run forever
{
//vTaskDelay(pdMS_TO_TICKS(5000)); // wait 5 seconds so system is fully running
// display highwater marks for all 6 tasks
Serial.println("***************************");
Serial.print("High Water Mark for Green LED : ");
Serial.println(uxTaskGetStackHighWaterMark(taskGreen));
Serial.print("High Water Mark for Red LED : ");
Serial.println(uxTaskGetStackHighWaterMark(taskRed));
Serial.print("High Water Mark for Blue LED : ");
Serial.println(uxTaskGetStackHighWaterMark(taskBlue));
Serial.print("High Water Mark for Tally : ");
Serial.println(uxTaskGetStackHighWaterMark(taskTally));
Serial.print("High Water Mark for Highwater Task: ");
Serial.println(uxTaskGetStackHighWaterMark(taskHighwater));
Serial.print("High Water Mark for Speed: ");
Serial.println(uxTaskGetStackHighWaterMark(taskSpeed));
Serial.flush(); // make sure last data is written

vTaskSuspend(NULL); // run this task only once
}
}

Finally, we can add the timer callback function. At the bottom of the file add the following lines.

void hwCallback( TimerHandle_t xTimer )
{
vTaskResume(taskHighwater); // run the highwater program once
}

That’s it. Now download the code to your circuit and open the serial console. You should now see the highwater mark data update every 10 seconds on the serial console.

Semaphores

Semaphores are a special programming tool to help synchronize and control the flow of execution between the tasks. As an analogy, consider a relay race where each team has one runner running at a time, and a baton is passed from one runner to the next as the race progresses. In a manner similar to the baton in a relay race, a semaphore can be used to control the execution sequence of FreeRTOS tasks. The simplest form is a binary semaphore that has two states — available, and taken. It can be used to give one task priority over others and require the others to wait for the semaphore to be released before they can claim it and continue execution. A counting semaphore allows multiple tasks to acquire the same semaphore at once up to a certain limit (the count). After that others have to wait until one of the operating tasks gives back its semaphore. This is very useful if you want to limit the number of tasks that can be performing operations at the same time. In this exercise we will use a binary semaphore to synchronize the blinking of our three LED lights so that they blink in sequential order. Lets get started modifying our code by reopening our rtosLesson3 sketch.

//define semaphore object handle
SemaphoreHandle_t syncFlag;

// 6 - load up data for each LED task
static BlinkData blinkGreen = { 15, 2000, &speedMult, &greenLED };
static BlinkData blinkRed = { 4, 2000, &speedMult, &redLED };
static BlinkData blinkBlue = { 16, 2000, &speedMult, &blueLED };

First we define a semaphore handle called syncFlag in the initialization code section and then update the data for each of the three LEDS so that the delay time for each is the same.

// initialize serial communication at 115200 bits per second:
Serial.begin(115200);

// create the binary semaphore
syncFlag = xSemaphoreCreateBinary();
if (syncFlag != NULL){Serial.println("flag OK");}

The next step is to create the binary semaphore called syncFlag. We will add this in the setup section immmediately after starting the Serial console. We check to verify it is successful and print the resulting message. At this point the flag is initalized but not yet available to use.

// Now set up tasks to run independently.
xTaskCreatePinnedToCore
(
TaskBlinkSeq, // name of function to invoke
"TaskBlinkGreen" , // label for the task
4096 , // This stack size can be checked & adjusted by reading the Stack Highwater
(void *)&blinkGreen, // pointer to global data area
3 , // Priority,
&taskGreen, //pointer to task handle
ARDUINO_RUNNING_CORE // run on the arduino process core
);

xTaskCreatePinnedToCore
(
TaskBlinkSeq,
"TaskBlinkRed",
4096,
(void *)&blinkRed,
3,
&taskRed,
ARDUINO_RUNNING_CORE
);

xTaskCreatePinnedToCore
(
TaskBlinkSeq,
"TaskBlinkBlue",
4096,
(void *)&blinkBlue,
3,
&taskBlue,
ARDUINO_RUNNING_CORE
);

Next we modify the task creation commands to use a new task function called TaskBlinkSeq. We also assign a priority level of 3 to each of these tasks.

// make the syncFlag semaphore available to start sequential processing    
xSemaphoreGive(syncFlag);

} //end of setup

at the end of the setup section we add the xSemaphoreGive function to make the semaphore available for use as soon as the scheduler starts.

void TaskBlinkSeq(void *xStruct)  // This is a task template
{
BlinkData *data = (BlinkData *)xStruct; //passed data cast as BlinkData structure
// unpack data from the BlinkData structure passed by reference
int pin = data->pin;
int delay = data->delay;
float *speedMult = data->speedMult;
int *statePtr = data->ptr;


// set pinMode on output pin
pinMode(pin, OUTPUT);

while(true) // A Task shall never return or exit.
{
// get the semaphores
xSemaphoreTake(syncFlag, 5000 );
int delayInt = (delay * (*speedMult)); // get nearest int to new delay time

digitalWrite(pin, HIGH); // turn the LED on (HIGH is the voltage level)
*statePtr = 1; // set the LED state to 1
// unblock delay for on cycle time
vTaskDelay(pdMS_TO_TICKS(delayInt));
digitalWrite(pin, LOW); // turn the LED off by making the voltage LOW
*statePtr = 0;
xSemaphoreGive(syncFlag);
vTaskDelay(pdMS_TO_TICKS(100));
}
}

Next is the heart of the new code — the TaskBlinkSeq function. In each call we are going to get the semaphore, turn on the LED, wait for a delay period, then turn off the LED, and release the semaphore. To summarize the execution flow;

  • unpack the data passed in by the BlinkData struct
  • set the pin mode on the current pin so we can change the value.
  • use the xSemaphoreTake function to get the semaphore when it is available. Wait up to 5000 ticks for it before giving up and blocking the rest of the task.
  • turn on the LED and update the status pointer for it.
  • wait for a period of time based on the base delay and the speed multiplier.
  • turn off the LED and update the status pointer for it.
  • use the xSemaphoreGive function to release the flag and let the next task use it.
  • wait a short period before looping back to try to take the semaphore. This prevents the task from reacquiring the semaphore out of turn if the release occurs in the middle of a time slice.

How does the system know what order to sequence the lights? We have given the three LED tasks a higher priority (3) than the other tasks, so the scheduler prioritizes them first and processes them in a round robn fashion so that they will take turns sequentially.

void TaskTally(void *pvParameters)  // This is a task template
{
(void)pvParameters;

TickType_t xLastWaitTime; //updated after first call by vTaskDelaUntil

// initialize xLastWaitTime
xLastWaitTime = xTaskGetTickCount();

// initialize the lcd display (once)
Adafruit_SSD1306 Display(OLED_RESET);
Display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR);
Display.clearDisplay();
Display.display();

while (true) // A Task shall never return or exit.
{
// calcualte numer of lamps lit from global variables
//int numLit = redLED + greenLED + blueLED;
// determine LED color
char colorStr[6] ;
if (redLED == 1){strcpy(colorStr,"Red");}
else if (greenLED == 1){strcpy(colorStr,"Green");}
else if (blueLED == 1){strcpy(colorStr,"Blue ");}
else {strcpy(colorStr,"");}
//display data on LCD display
Display.clearDisplay();
Display.setTextSize(1);
Display.setTextColor(WHITE);
Display.setCursor(40, 0);
Display.println("LED Lit");
Display.setTextSize(2);
Display.setTextColor(WHITE);
Display.setCursor(60, 15);
Display.println(colorStr);
//Display.println(String(numLit));
Display.display();
// short unblocked delay between readings (1/10 second)
xTaskDelayUntil(&xLastWaitTime, pdMS_TO_TICKS(100));
}

Finally, we modify the code for the TaskTally function. It doesn’t make sense to record the number of LEDs lit since it will always be 1 when they light sequentially. Instead we will have the OLED display the color of the LED currently lit. The changes are shown above.

Now compile and upload the new program and you should see the circuit operate like this.

Mutexes

Next we’re going to add the ability to enter and respond to commands entered on the serial console. This will give us a way to interact with our device without adding more buttons to push — but it also uncovers a potential problem. What happens if the highwater event decides to display its data on the console at the same time the command processor is writing to it? Without proper controls it would be a jumbled mess. But there is a special variation of the semaphore called a mutex that is made specifically to address this situation. Mutex is short for “mutually exclusive” and it is used to allow a task to reserve exclusive access to shared resources for a period of time. When the task no longer requires exclusive access to the resource it can (actually MUST) release the mutex so that other tasks can proceed to use the resource. Before writing our code to read and process console commands we will add a mutex and apply it to the previously written highwater mark task.

// the setup function runs once when you press reset or power the board
void setup() {

// initialize serial communication at 115200 bits per second:
Serial.begin(115200);

// create the binary semaphore
syncFlag = xSemaphoreCreateBinary();
if (syncFlag != NULL){Serial.println("flag OK");}

// start printer mutex access
SemaphoreHandle_t printMutex;
printMutex = xSemaphoreCreateMutex();
if (printMutex != NULL){Serial.println("printMutex created");}

In the setup section we’ve added a new section below the binary semaphore to set up the printer access mutex called printMutex. Now we add the calls to get the printMutex semaphore at the start of the function TaskHighWater and give it back at the end as below.

void TaskHighWater(void *pvParameters)
{
while (true) //run forever
{
//vTaskDelay(pdMS_TO_TICKS(5000)); // wait 5 seconds so system is fully running
// display highwater marks for all 6 tasks
xSemaphoreTake(printMutex,portMAX_DELAY); // get the printer mutex for use of the printer
Serial.println("***************************");
Serial.print("High Water Mark for Green LED : ");
Serial.println(uxTaskGetStackHighWaterMark(taskGreen));
Serial.print("High Water Mark for Red LED : ");
Serial.println(uxTaskGetStackHighWaterMark(taskRed));
Serial.print("High Water Mark for Blue LED : ");
Serial.println(uxTaskGetStackHighWaterMark(taskBlue));
Serial.print("High Water Mark for Tally : ");
Serial.println(uxTaskGetStackHighWaterMark(taskTally));
Serial.print("High Water Mark for Highwater Task: ");
Serial.println(uxTaskGetStackHighWaterMark(taskHighwater));
Serial.print("High Water Mark for Speed: ");
Serial.println(uxTaskGetStackHighWaterMark(taskSpeed));
Serial.flush(); // make sure last data is written
xSemaphoreGive(printMutex); //release the printer mutex
vTaskSuspend(NULL); // run this task only once
}
}

What is the difference between the mutex semaphore and a normal binary semaphore? Since the mutex is mutually exclusive the only task that can release it is the one that one that took it. This requires careful attention in the code to make sure that the task holding the mutex does not fail, suspend, or block out for long periods because it can cause problems for other tasks that need access to the mutex. You should also know there are more sophisticated methods to control the sharing of resources but they will not be covered in this tutorial.

Queues

Now we are ready to add the command processor. We’re going to add this in two parts.

  • Command Parser to read the keyboard entry and validate that is a correct command.
  • Command Executor to take the validated keyboard input entry and perform the requested command.

We will use a queue to pass the validated command from the command parser to the command executor. Why separate the two tasks? In the future we may want to add the ability to enter commands from a touchscreen display, via a web interface, or through some other input method. We would simply have the new input device send commands to the same command queue as the command parser and avoid writing additional processing code. The system will be able to process input from multiple sources in a consistent manner and in the correct order.

 // 4 - define global variable for led status
int greenLED = 0; // LED status indicator 0-off, 1-on
int redLED = 0;
int blueLED = 0;
int *greenPtr = &greenLED; //pointers to pass to tasks to update led status
int *redPtr = &redLED;
int *bluePtr = &blueLED;
float speedMult = 1.0; // define speed multipier value

// define structure for data in the cmdQueue
const byte numChars = 24;
struct cmdData {
char Cmd[numChars];
char Obj[numChars];
};
// define the queue handle for the queCmd queue
QueueHandle_t queCmd;

First add a section in the initialization code to create global variables to hold the command data and a struct called cmdData. Also define a handle for the queCmd queue.

// the setup function runs once when you press reset or power the board
void setup() {

// initialize serial communication at 115200 bits per second:
Serial.begin(115200);

// create the binary semaphore
syncFlag = xSemaphoreCreateBinary();
if (syncFlag != NULL){Serial.println("flag OK");}

// start printer mutex access
SemaphoreHandle_t printMutex;
printMutex = xSemaphoreCreateMutex();
if (printMutex != NULL){Serial.println("printMutex created");}

// initialize queue for commands
queCmd = xQueueCreate( 5, sizeof( cmdData ) );
if( queCmd != NULL ) {Serial.println("command queue created");}

Then in the setup section we add the code to create the command queue. The FreeRTOS function xQueueCreate is used to create a queue called queCmd that contains space for 5 entries, where each entry is the size of the cmdData struct. The command parser will enter values to the back of the queue and the command executor will read entries from the front, so that commands will be processed in the order they were entered. The commands we will process will execute rapidly so we won’t have to worry about overrunning the queue entry limit of 5.

Let’s turn our attention to the command structure. For this simple tutorial all of our commands will have two parts — a command name (Cmd variable) and an Object field (Obj variable). The permitted values for the command and object fields are;

  • pause (red, green, blue)
  • resume (red, green, blue)
  • kill (red, green, blue)
  • speed (slower, faster)

In other word we will be able to pause, resume, or kill the tasks associated with each LED color and change the speed slower or faster. The command parser will validate that the keyboard entry matches one of the values above and then add an entry into queCmd queue that points to the contents of cmdData structure. One important point — the queue will have a copy of the cmdData struct contents at the time the queue entry is made. The command parser may then receive new entry data without affecting the queue contents.

// define  global variables for keyboard entry
char receivedChars[numChars];
char tempChars[numChars]; // temporary array for use when parsing
cmdData valueToSend;

// define global variables to hold the parsed input data
char cmdFromPC[numChars] = {0};
char objFromPC[numChars] = {0};
const char* cmdList = "kill...pause...resume...speed"; // list of accepted values for command
const char* objListLED = "red...green...blue"; // list of accepted values
const char* objListSpeed = "faster...slower";
boolean newData = false;

// the setup function runs once when you press reset or power the board
void setup() {

First we need to define the global vaiables used in the command parsing routines. Insert the code above at the end of the initialization code, i.e. just before the setup function.

void TaskCmdParse(void *pvParameters)  // This is a task template
{
// This uses non-blocking input routines to read and check user input
// If values are acceptable the command is sent to the command processor
// The input format is "command object" where command and object must conform
// to values in lists. If either is not valid the user is notified.

// define sub functions used here
(void)pvParameters;
void parseData(void);
void checkParsedData(void);
void recvWithEndMarker(void);


while (true) // A Task shall never return or exit.
{
BaseType_t xStatus; // *** 1 *** typedef statement

recvWithEndMarker();
if (newData == true) {
strcpy(tempChars, receivedChars);
strcat (tempChars, " 1 2 3"); //add spaces in case user didn't enter any
// this temporary copy is necessary to protect the original data
// because strtok() used in parseData() replaces the commas with \0
parseData();
checkParsedData();
newData = false;

// load values into the struct used to send message in queue and send
strcpy(valueToSend.Cmd, cmdFromPC);
strcpy(valueToSend.Obj, objFromPC);

// *** 2 *** add the input values to the back of the queue
xStatus = xQueueSendToBack( queCmd, &valueToSend, 0 );
if( xStatus != pdPASS ) {Serial.println("queue send failed!");}
}
}
}

void parseData() // split the data into its parts
{

char * strtokIndx; // this is used by strtok() as an index

strtokIndx = strtok(tempChars," "); // get the first part - the string
strcpy(cmdFromPC, strtokIndx); // copy it to cmdFromPC

strtokIndx = strtok(NULL, " "); // this continues where the previous call left off
strcpy(objFromPC,strtokIndx); // copy it to objFromPC
}

void checkParsedData()
{
// *** 3 *** take the printer mutex
xSemaphoreTake(printMutex,portMAX_DELAY);
char * cmd;
char * obj;
int validRequest = 0;
cmd = strstr(cmdList,cmdFromPC);
if (cmd){
validRequest += 1;}
else{
Serial.println("rejected - unrecognized command");
validRequest -= 1;
}
if (strstr("speed",cmdFromPC))
{
obj = strstr(objListSpeed,objFromPC);
if (obj)
{
validRequest += 1;
}
else
{
Serial.println("rejected - unrecognized request");
validRequest -= 1;
}
}
else
{
obj = strstr(objListLED,objFromPC);
if (obj)
{
validRequest += 1;
}
else
{
Serial.println("rejected - unrecognized request");
validRequest -= 1;
}
}
if (validRequest == 2){
Serial.println("command accepted");
Serial.println(String(cmdFromPC) + " " + String(objFromPC));
}
Serial.flush();
// *** 4 *** flush the print data and wait three seconds for the user to read the response
vTaskDelay(pdMS_TO_TICKS(5000));
xSemaphoreGive(printMutex);
}

void recvWithEndMarker()
{
static byte ndx = 0;
char endMarker = '\n';
char rc;

while (Serial.available() > 0 && newData == false) {
rc = Serial.read();

if (rc != endMarker) {
receivedChars[ndx] = rc;
ndx++;
if (ndx >= numChars) {
ndx = numChars - 1;
}
}
else {
receivedChars[ndx] = '\0'; // terminate the string
ndx = 0;
newData = true;
}
}
}

Next we insert the code to read keyboard input, parse it, validate it, and enter into the queCmd que for processing. Most of this lengthy code deals with inputting, and checking the data using standard Arduino code and character functions that we don’t need to dwell on, but there are several steps related to the print mutex and the command queue.

  • 1 — is a typedef statement for the return from the queue entry function referenced below.
  • 2 — insert the parsed and validated input into the back of the queCmd queue using the xQueueSendToBack FreeRTOS function. There is also a xQueueSendToFront function if you want to send the request to the front of the queue ahead of other entries that might already exist.
  • 3 — the checkParsedData function validates the keyboard input and then prints a message on the console telling the user whether the command was accepted or not. At location 3 in the code we use the printMutex to get exclusive use of the console output.
  • 4 — at the end of the checkParsedData function we are ready to release the console output. But first we flush the printer to make sure all buffered data is output, and then allow 5 seconds for the user to read the output before letting other tasks take control.

We don’t need to use the printMutex when we are entering the keystrokes into the console because the Arduino IDE has a separate input field in the display, and there is only one key input processing task.

Now we just need to activate the new TaskCmdParse function by utilizing the xTaskCreatePinned To Core FreeRTOS function as before. Insert the code below directly below the TaskHighWater startup.

// insert after TaskCreatePinnedToCore section for TaskHighWater
xTaskCreatePinnedToCore(
TaskCmdParse,
"Command Parser", // A name just for human
4096, // This stack size can be checked & adjusted by reading the Stack Highwater
NULL,
2, // Priority
&taskCmdParse,
SYSTEM_RUNNING_CORE); //run this in core separate from LED tasks if there are two cores

And now add the handle definition in the initilization section as below

// 7 - set up task handles for the RTOS tasks
TaskHandle_t taskGreen;
TaskHandle_t taskRed;
TaskHandle_t taskBlue;
TaskHandle_t taskSpeed;
TaskHandle_t taskTally;
TaskHandle_t taskHighwater;
TaskHandle_t taskCmdParse; // add handle for the command parser

Now all we have to do is add another task called TaskCmdExec to read commands from the queCmd queue and process the requests. Here is what the code looks like.

void TaskCmdExec(void *pvParameters) 
{
(void)pvParameters;
cmdData receivedValue;
BaseType_t xStatus;
const TickType_t xTicksToWait = pdMS_TO_TICKS( 100 );
char nameObj[8];

while (true)
{
// 5 - receive items from the queCmd queue
// if new items are received xStatus is pdPass
xStatus = xQueueReceive( queCmd, &receivedValue, xTicksToWait );
if( xStatus == pdPASS ) // process new commands
{
// 6 - reserve the printer to respond to user
xSemaphoreTake(printMutex,portMAX_DELAY); // get the printer mutex for use of the printer

// 7 - process the command request and print outcome
if (strstr("red",receivedValue.Obj))
{
strcpy(nameObj,"red");
taskCmd = taskRed;
}
else if (strstr("green",receivedValue.Obj))
{strcpy(nameObj,"green");
taskCmd = taskGreen;}
else if (strstr("blue",receivedValue.Obj))
{strcpy(nameObj,"blue");
taskCmd = taskBlue;}

// check which command is sent and respond accordingly
if (strstr("pause",receivedValue.Cmd))
{
vTaskSuspend(taskCmd);
Serial.print(nameObj);
Serial.println(" paused");
}
else if (strstr("resume",receivedValue.Cmd))
{
vTaskResume(taskCmd);
Serial.print(nameObj);
Serial.println(" resumed");
}
else if (strstr("kill",receivedValue.Cmd))
{
vTaskDelete(taskCmd);
Serial.print(nameObj);
Serial.println(" killed");
}
else if (strstr("speed" , receivedValue.Cmd) )
{
if (strstr ( "slower" , receivedValue.Obj))
{speedMult *= 1.5;
Serial.println("speed is slower");}
else if (strstr("faster" , receivedValue.Obj))
{speedMult *= 0.67;
Serial.println("speed is faster");}
}
// 8 - close out the printer and give back to print mutex
Serial.flush();
vTaskDelay(pdMS_TO_TICKS(5000)); // pause for the user to read
xSemaphoreGive(printMutex); // flush the print buffer and release the print mutex
}
else
{
/* Do nothing here - Wait for a new entry to be added into the command queue */
}
}
}

In this code we do the following:

  • 5 — use the xQueueReceive FreeRTOS function to check for any new records that appear in the queue. If the return value is pdPass it means there is new data to process and the contents of first record in the queue are placed in the receivedValue structure.
  • 6 — if there is a command to process, reserve the printer mutex.
  • 7 — process the requested command. We don’t need to check the syntax because the command parser handled that already.
  • 8 — Flush the print buffer, wait 5 seconds, and release the printer mutex.
// 7 - set up task handles for the RTOS tasks
TaskHandle_t taskGreen;
TaskHandle_t taskRed;
TaskHandle_t taskBlue;
TaskHandle_t taskSpeed;
TaskHandle_t taskTally;
TaskHandle_t taskHighwater;
TaskHandle_t taskCmdParse;
TaskHandle_t taskCmdExec;
TaskHandle_t taskCmd = NULL; // task handle used to pause, restart, or stop running tasks

As before, we need to add the task handle definitions. Here we are adding one for the taskCmdExec task and we are adding and a second one called taskCmd that is used in the code to point to the task to modify. Add the new handle definitions to the previous ones as shown.

xTaskCreatePinnedToCore(
TaskCmdExec,
"Execute Commands From Queue", // A name just for humans
4096, // This stack size can be checked & adjusted by reading the Stack Highwate
NULL,
2, // Priority
&taskCmdExec,
SYSTEM_RUNNING_CORE); //run this in core separate from LED tasks if there are two cores

And of course we need to add the xTaskCreatePinnedToCore function to initiate the task on startup. Add this after the startup code for the TaskCmdParse task.

Let’s make one more change to show how the TaskCmdExec task can process input from other sources. We will modify the button push action to send a command to speed up the system using the queCmd. Here is the previous code.

// 9 - define ISR routine to service button interrupt
int pinButton = 23; //GPIO pin used for pushbutton
void IRAM_ATTR buttonPush()
{
vTaskResume(taskSpeed); // run the speed program once
}

and here is the new.

// 9 - define ISR routine to service button interrupt
int pinButton = 23; //GPIO pin used for pushbutton
void IRAM_ATTR buttonPush()
{
//vTaskResume(taskSpeed); // run the speed program once

cmdData sendCmd = {strcpy("speed"),strcpy("faster")};
xStatus = xQueueSendToBack( queCmd, &sendCmd, 0 );
if( xStatus != pdPASS ) {Serial.println("queue send failed!");}
}

You can see that we simply create a cmdData structure with the desired command input and send it to the back of the queue. It will be processed in the same manner as the other commands entered from the keyboard.

We can now deactivate the TaskSpeed task because it never gets called.

Finally (this time I really mean it), here is a listing of the entire code.

/*********
This code was developed for use with the tutorial series entitled
"Implementing FreeRTOS Solutions on ESP 32 Devices using Arduino"

Refer to https://medium.com/@tomw3115/implementing-freertos-solutions-on-esp-32-devices-using-arduino-114e05f7138a for more information.

This software is provided as-is for hobbyist use and educational purposes only.

published by Tom Wilson - September 2024 *********/

// 1 - define freeRTOS settings
#if CONFIG_FREERTOS_UNICORE
#define ARDUINO_RUNNING_CORE 0
#else
#define ARDUINO_RUNNING_CORE 1
#endif
#define SYSTEM_RUNNING_CORE 0

// 2 - define LCD display settings
#define OLED_ADDR 0x3C
#define OLED_RESET 4
#include <SPI.h>
#include <Wire.h>
#include <Adafruit_SSD1306.h>

// 3 - set up task handles for the FreeRTOS tasks
TaskHandle_t taskGreen;
TaskHandle_t taskRed;
TaskHandle_t taskBlue;
TaskHandle_t taskSpeed;
TaskHandle_t taskTally;
TaskHandle_t taskHighwater;
TaskHandle_t taskCmdParse;
TaskHandle_t taskCmdExec;
TaskHandle_t taskCmd = NULL; // task handle used to pause, restart, or stop running tasks

// 4 - define global variable for LED status
int greenLED = 0; // LED status indicator 0-off, 1-on
int redLED = 0;
int blueLED = 0;
int *greenPtr = &greenLED; //pointers to pass to tasks to update led status
int *redPtr = &redLED;
int *bluePtr = &blueLED;
float speedMult = 1.0; // define speed multipier value

// 5 - define structure for data to pass to each task
struct BlinkData {
int pin;
int delay;
float *speedMult;
int *ptr;
};

// 6 - load up data for each LED task
BlinkData blinkGreen = {15, 2000, &speedMult, &greenLED };
BlinkData blinkRed = {4, 2000, &speedMult, &redLED };
BlinkData blinkBlue = {16, 2000, &speedMult, &blueLED };


// 8 - define the function for highwater mark processing
UBaseType_t uxTaskGetStackHighWaterMark(TaskHandle_t xTask);
TimerHandle_t highwaterTimer; // define data types for the highwater mark timer
BaseType_t hwTimerStarted;


// New section for Lesson 3
//define semaphore object handle
SemaphoreHandle_t syncFlag;

// define mutex object handle
SemaphoreHandle_t printMutex;

// define the queue handle for the queCmd queue
QueueHandle_t queCmd;

// define structure for data in the cmdQueue
const byte numChars = 24;
struct cmdData {
char Cmd[numChars];
char Obj[numChars];
};

// define global variables for keyboard entry
char receivedChars[numChars];
char tempChars[numChars]; // temporary array for use when parsing
cmdData valueToSend;

// define global variables to hold the parsed input data
char cmdFromPC[numChars] = {0};
char objFromPC[numChars] = {0};
const char* cmdList = "kill...pause...resume...speed"; // list of accepted values for command
const char* objListLED = "red...green...blue"; // list of accepted values
const char* objListSpeed = "faster...slower";
boolean newData = false;

// 9 - define interupt processing for the push button
int pinButton = 23; //GPIO pin used for pushbutton
void IRAM_ATTR buttonPush()
{
//vTaskResume(taskSpeed); // run the speed program once
BaseType_t xStatus;
cmdData sendCmd = {"speed","faster"};
xStatus = xQueueSendToBack( queCmd, &sendCmd, 0 );
if( xStatus != pdPASS ) {Serial.println("queue send failed!");}
}


// the setup function runs once when you press reset or power the board
void setup() {

// initialize serial communication at 115200 bits per second:
Serial.begin(115200);

// create the binary semaphore
syncFlag = xSemaphoreCreateBinary();
if (syncFlag != NULL){Serial.println("flag OK");}

// start printer mutex access

printMutex = xSemaphoreCreateMutex();
if (printMutex != NULL){Serial.println("printMutex created");}

// initialize queue for commands
queCmd = xQueueCreate( 5, sizeof( cmdData ) );
if( queCmd != NULL ) {Serial.println("command queue created");}

// setup button for interupt processing
pinMode(pinButton, INPUT_PULLUP);
attachInterrupt(pinButton, buttonPush, FALLING);



highwaterTimer = xTimerCreate("Highwater Timer",pdMS_TO_TICKS(10000),pdTRUE,0,hwCallback);
hwTimerStarted = xTimerStart( highwaterTimer, 0 );

// Now set up tasks to run independently.
xTaskCreatePinnedToCore
(
TaskBlinkSeq, // name of function to invoke
"TaskBlinkGreen" , // label for the task
4096 , // This stack size can be checked & adjusted by reading the Stack Highwater
(void *)&blinkGreen, // pointer to global data area
3 , // Priority,
&taskGreen, //pointer to task handle
ARDUINO_RUNNING_CORE // run on the arduino process core
);

xTaskCreatePinnedToCore
(
TaskBlinkSeq,
"TaskBlinkRed",
4096,
(void *)&blinkRed,
3,
&taskRed,
ARDUINO_RUNNING_CORE
);

xTaskCreatePinnedToCore
(
TaskBlinkSeq,
"TaskBlinkBlue",
4096,
(void *)&blinkBlue,
3,
&taskBlue,
ARDUINO_RUNNING_CORE
);

xTaskCreatePinnedToCore
(
TaskTally,
"TaskTally",
4096,
NULL,
2,
&taskTally,
SYSTEM_RUNNING_CORE
);

xTaskCreatePinnedToCore
(
TaskSpeed,
"Speed",
4096,
NULL,
2,
&taskSpeed,
SYSTEM_RUNNING_CORE
);

xTaskCreatePinnedToCore
(
TaskHighWater,
"High Water Mark Display",
4096,
NULL,
2,
&taskHighwater,
SYSTEM_RUNNING_CORE
);
xTaskCreatePinnedToCore(
TaskCmdParse,
"Command Parser", // A name just for humans
4096, // This stack size can be checked & adjusted by reading the Stack Highwater
NULL,
2,
&taskCmdParse,
SYSTEM_RUNNING_CORE); //run this in core separate from LED tasks if there are two cores
xTaskCreatePinnedToCore(
TaskCmdExec,
"Execute Commands From Queue", // A name just for humans
4096, // This stack size can be checked & adjusted by reading the Stack Highwate
NULL,
2, // Priority
&taskCmdExec,
SYSTEM_RUNNING_CORE); //run this in core separate from LED tasks if there are two cores

// make the syncFlag semaphore available to start sequential processing
xSemaphoreGive(syncFlag);

} //end of setup

// Now the task scheduler, which takes over control of scheduling individual tasks, is automatically started.

void loop() {
// Hooked to Idle task, it will run whenever CPU is idle (i.e. other tasks blocked)
// DO NOTHING HERE...

/*--------------------------------------------------*/
/*---------------------- Tasks ---------------------*/
/*--------------------------------------------------*/
}
void TaskBlink(void *xStruct) // This is a task template
{
BlinkData *data = (BlinkData *)xStruct; //passed data cast as BlinkData structure
// unpack data from the BlinkData structure passed by reference
int pin = data->pin;
int delay = data->delay;
float *speedMult = data->speedMult;
int *statePtr = data->ptr;

// set pinMode on output pin
pinMode(pin, OUTPUT);


while(true) // A Task shall never return or exit.
{
int delayInt = (delay * (*speedMult)); // get nearest int to new delay time
digitalWrite(pin, HIGH); // turn the LED on (HIGH is the voltage level)
*statePtr = 1; // set the LED state to 1
vTaskDelay(pdMS_TO_TICKS(delayInt)); // unblock delay for on cycle time
digitalWrite(pin, LOW); // turn the LED off by making the voltage LOW
*statePtr = 0; // set the LED state to 0
vTaskDelay(pdMS_TO_TICKS(delayInt)); // unblock delay for off cycle
}
}

void TaskTally(void *pvParameters) // This is a task template
{
(void)pvParameters;

TickType_t xLastWaitTime; //updated after first call by vTaskDelaUntil

// initialize xLastWaitTime
xLastWaitTime = xTaskGetTickCount();

// initialize the lcd display (once)
Adafruit_SSD1306 Display(OLED_RESET);
Display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR);
Display.clearDisplay();
Display.display();

while (true) // A Task shall never return or exit.
{
// calculate numer of lamps lit from global variables
//int numLit = redLED + greenLED + blueLED;
// determine LED color
char colorStr[6] ;
if (redLED == 1){strcpy(colorStr,"Red");}
else if (greenLED == 1){strcpy(colorStr,"Green");}
else if (blueLED == 1){strcpy(colorStr,"Blue ");}
else {strcpy(colorStr,"");}
//display data on LCD display
Display.clearDisplay();
Display.setTextSize(1);
Display.setTextColor(WHITE);
Display.setCursor(40, 0);
Display.println("LED Lit");
Display.setTextSize(2);
Display.setTextColor(WHITE);
Display.setCursor(60, 15);
Display.println(colorStr);
//Display.println(String(numLit));
Display.display();
// short unblocked delay between readings (1/10 second)
xTaskDelayUntil(&xLastWaitTime, pdMS_TO_TICKS(100));
}
}
void TaskHighWater(void *pvParameters)
{
while (true) //run forever
{
//vTaskDelay(pdMS_TO_TICKS(5000)); // wait 5 seconds so system is fully running
// display highwater marks for all 6 tasks
Serial.println("***************************");
Serial.print("High Water Mark for Green LED : ");
Serial.println(uxTaskGetStackHighWaterMark(taskGreen));
Serial.print("High Water Mark for Red LED : ");
Serial.println(uxTaskGetStackHighWaterMark(taskRed));
Serial.print("High Water Mark for Blue LED : ");
Serial.println(uxTaskGetStackHighWaterMark(taskBlue));
Serial.print("High Water Mark for Tally : ");
Serial.println(uxTaskGetStackHighWaterMark(taskTally));
Serial.print("High Water Mark for Highwater Task: ");
Serial.println(uxTaskGetStackHighWaterMark(taskHighwater));
Serial.print("High Water Mark for Speed: ");
Serial.println(uxTaskGetStackHighWaterMark(taskSpeed));
Serial.print("High Water Mark for Command Parse: ");
Serial.println(uxTaskGetStackHighWaterMark(taskCmdParse));
Serial.print("High Water Mark for Command Execute: ");
Serial.println(uxTaskGetStackHighWaterMark(taskCmdExec));
Serial.flush(); // make sure last data is written

vTaskSuspend(NULL); // run this task only once
}
}

void TaskSpeed(void *pvParameters) // This is a task template
{
(void)pvParameters;
vTaskSuspend(NULL); // start in suspended state

while (true) // A Task shall never return or exit.
{
speedMult = 0.667 * speedMult; // increase speed by 50%
Serial.println("Speed increased");
vTaskSuspend(NULL); // run this task only once
}
}
void hwCallback( TimerHandle_t xTimer )
{
vTaskResume(taskHighwater); // run the highwater program once
}

void TaskBlinkSeq(void *xStruct) // This is a task template
{
BlinkData *data = (BlinkData *)xStruct; //passed data cast as BlinkData structure
// unpack data from the BlinkData structure passed by reference
int pin = data->pin;
int delay = data->delay;
float *speedMult = data->speedMult;
int *statePtr = data->ptr;


// set pinMode on output pin
pinMode(pin, OUTPUT);

while(true) // A Task shall never return or exit.
{
// get the semaphores
xSemaphoreTake(syncFlag, 5000 );
int delayInt = (delay * (*speedMult)); // get nearest int to new delay time

digitalWrite(pin, HIGH); // turn the LED on (HIGH is the voltage level)
*statePtr = 1; // set the LED state to 1
// unblock delay for on cycle time
vTaskDelay(pdMS_TO_TICKS(delayInt));
digitalWrite(pin, LOW); // turn the LED off by making the voltage LOW
*statePtr = 0;
xSemaphoreGive(syncFlag);
vTaskDelay(pdMS_TO_TICKS(100));
}
}



void TaskCmdParse(void *pvParameters) // This is a task template
{
// This uses non-blocking input routines to read and check user input
// If values are acceptable the command is sent to the command processor
// The input format is "command object" where command and object must conform
// to values in lists. If either is not valid the user is notified.

// define sub functions used here
(void)pvParameters;
void parseData(void);
void checkParsedData(void);
void recvWithEndMarker(void);


while (true) // A Task shall never return or exit.
{
BaseType_t xStatus; // *** 1 *** typedef statement

recvWithEndMarker();
if (newData == true) {
strcpy(tempChars, receivedChars);
strcat (tempChars, " 1 2 3"); //add spaces in case user didn't enter any
// this temporary copy is necessary to protect the original data
// because strtok() used in parseData() replaces the commas with \0
parseData();
checkParsedData();
newData = false;

// load values into the struct used to send message in queue and send
strcpy(valueToSend.Cmd, cmdFromPC);
strcpy(valueToSend.Obj, objFromPC);
// *** 2 *** add the input values to the back of the queue
xStatus = xQueueSendToBack( queCmd, &valueToSend, 0 );
if( xStatus != pdPASS ) {Serial.println("queue send failed!");}
}
}
}

void parseData() // split the data into its parts
{

char * strtokIndx; // this is used by strtok() as an index

strtokIndx = strtok(tempChars," "); // get the first part - the string
strcpy(cmdFromPC, strtokIndx); // copy it to cmdFromPC

strtokIndx = strtok(NULL, " "); // this continues where the previous call left off
strcpy(objFromPC,strtokIndx); // copy it to objFromPC
}

void checkParsedData()
{
// *** 3 *** take the printer mutex
xSemaphoreTake(printMutex,portMAX_DELAY);
char * cmd;
char * obj;
int validRequest = 0;
cmd = strstr(cmdList,cmdFromPC);
if (cmd){
validRequest += 1;}
else{
Serial.println("rejected - unrecognized command");
validRequest -= 1;
}
if (strstr("speed",cmdFromPC))
{
obj = strstr(objListSpeed,objFromPC);
if (obj)
{
validRequest += 1;
}
else
{
Serial.println("rejected - unrecognized request");
validRequest -= 1;
}
}
else
{
obj = strstr(objListLED,objFromPC);
if (obj)
{
validRequest += 1;
}
else
{
Serial.println("rejected - unrecognized request");
validRequest -= 1;
}
}
if (validRequest == 2){
Serial.println("command accepted");
Serial.println(String(cmdFromPC) + " " + String(objFromPC));
}
Serial.flush();
vTaskDelay(pdMS_TO_TICKS(3000)); // *** 4 *** flush the print data and wait three seconds for the user to read the response
xSemaphoreGive(printMutex);
}

void recvWithEndMarker()
{
static byte ndx = 0;
char endMarker = '\n';
char rc;

while (Serial.available() > 0 && newData == false) {
rc = Serial.read();

if (rc != endMarker) {
receivedChars[ndx] = rc;
ndx++;
if (ndx >= numChars) {
ndx = numChars - 1;
}
}
else {
receivedChars[ndx] = '\0'; // terminate the string
ndx = 0;
newData = true;
}
}
}

void TaskCmdExec(void *pvParameters)
{
(void)pvParameters;
cmdData receivedValue;
BaseType_t xStatus;
const TickType_t xTicksToWait = pdMS_TO_TICKS( 100 );
char nameObj[8];

while (true)
{
xStatus = xQueueReceive( queCmd, &receivedValue, xTicksToWait );
if( xStatus == pdPASS )
{
xSemaphoreTake(printMutex,portMAX_DELAY); // get the printer mutex for use of the printer
// check which led color to modify and set approriate task
if (strstr("red",receivedValue.Obj))
{
strcpy(nameObj,"red");
taskCmd = taskRed;
}
else if (strstr("green",receivedValue.Obj))
{strcpy(nameObj,"green");
taskCmd = taskGreen;}
else if (strstr("blue",receivedValue.Obj))
{strcpy(nameObj,"blue");
taskCmd = taskBlue;}

// check which command is sent and respond accordingly
if (strstr("pause",receivedValue.Cmd))
{
vTaskSuspend(taskCmd);
Serial.print(nameObj);
Serial.println(" paused");
}
else if (strstr("resume",receivedValue.Cmd))
{
vTaskResume(taskCmd);
Serial.print(nameObj);
Serial.println(" resumed");
}
else if (strstr("kill",receivedValue.Cmd))
{
vTaskDelete(taskCmd);
Serial.print(nameObj);
Serial.println(" killed");
}
else if (strstr("speed" , receivedValue.Cmd) )
{
if (strstr ( "slower" , receivedValue.Obj))
{speedMult *= 1.5;
Serial.println("speed is slower");}
else if (strstr("faster" , receivedValue.Obj))
{speedMult *= 0.67;
Serial.println("speed is faster");}
}
Serial.flush();
vTaskDelay(pdMS_TO_TICKS(5000));
xSemaphoreGive(printMutex);
}
else
{
/* Do nothing here - Wait for a new entry to be added into the command queue */
}
}

Now load this software using the Arduino IDE and you should see the lights blinking sequentially as before. What happens if you type in the command “pause red” on the serial console? The red LED should maintain its current state and the other two LEDs will continue to blink sequentially. But now type in “resume red” and see what happens. All three LEDS will be blinking but the order is no longer in sequence. We were relying on the fact that the FreeRTOS scheduler uses round robin scheduling to establish the pattern of sequencing and now the red LED is resumed out of sequence. Also if you kill the red LED while it has the semaphore it will prevent the other LEDs from running. The moral of the story is you need to be very careful pausing or killing tasks that have access to semaphores or mutexes.

You can also try typing in “speed faster” and “speed slower” commands to test out the other command options.

In the next lesson we will look at extending our control and monitoring capabilities across the web using ESP 32 web services capabilities. Granted, it’s not very important to our blinking lights demo, but if you are building a home automation or a weather station application you might find it very useful.

Ready for Lesson 4? Click here

--

--

Tom Wilson

I am a retired engineer living the Pura Vida lifestyle in a beachfront community along the Pacific coast of Costa Rica.