Coming from a bare metal embedded system, the tasks of FreeRTOS may seem intimidating. In this article, we will look at how to setup a pair of threads and have them cooperate.
When working in an embedded settings, it is quite common to have multiple tasks to handle. It is just that one often does not think about them that way. For instance, you might have a main loop with one part reading a sensor, another monitoring a serial port and a third part outputting the sensor reading somewhere. The easiest way to arrange this is to have a main loop which takes care of these tasks sequentially.
However, you might want to read the sensor value once every 100ms regardless of the outcome of the serial port monitoring. The issue being, that the serial port may trigger operations that lasts for seconds. How do we handle this? Either we add more and more complexity to the main loop, or we use an operating system, e.g. FreeRTOS.
Setting up FreeRTOS for a given platform is not an extremely complex task, and loads of platforms are already supported – both officially and by the community. We will not touch this here, at least not in this installment.
Given a working setup, creating tasks is simply a matter of calling xTaskCreate. This creates a task and adds it to the list of runnable tasks. Each task can be assigned a priority, as well as a stack size, name and custom parameters.
Usually, tasks run forever, but certain calls may trigger the creation of a dynamically created task. Such tasks can be deleted using the vTaskDelete function. This removes the task from the system, and clears any allocated memory used by the task.
When registering a task, you actually register a function. This function is the task, and it is supposed to run forever. A very basic task can look something like this.
void taskFunction( void *parameters ) { for( ;; ) { /* Do work here */ } }
Given the function above, simply register it using the following call.
xTaskCreate( taskFunction, (signed portCHAR*) "Task Name", 50, /* stack size */ NULL, /* custom parameters */ MY_TASK_PRIORITY, /* priority */ NULL); /* Pointer to xTaskHandle */
The priority in an unsigned integer value, where a higher value indicates a higher priority task. The lowest priority is the tskIDLE_PRIORITY, where the idle task is running. The idle task is responsible for cleaning up after deleted tasks, so it is important not to starve it, by letting higher priority tasks get all the execution time.
So, how do you give other tasks run time? You block on a function, e.g. vTaskDelay or vTaskDelayUntil. Using the latter, you can achieve a constant frequency unlocking. For instance, you can implement the 100ms sample period discussed earlier. By assigning a sensor polling task a high priority, perhaps even the highest, and implementing the following structure, you can get constant frequency polling.
void taskFunction( void *parameters ) { portTickType lastTimeRun; for( ;; ) { vTaskDelayUntil( &lastTimeRun, PERIOD ); /* Poll sensor here */ } }
Multiple tasks, lets you perform multiple parallel jobs. However, you also need the tasks to cooperate. Sharing data between tasks is not trivial, as race conditions can occur. FreeRTOS provide structures for this: basic semaphores and mutexes, but also queues. A queue is the ideal way to move data between threads, while semaphores and mutexes can be used to protect shared data.
A queue is setup using the xQueueCreate function. By allowing all involved threads share the returned xQueueHandle, the queue can be shared. The producing thread adds data to the queue by calling the xQueueSend function. When created, each queue is given a size, and if the queue is full, the sender can specify for how long it is allowed to wait for a slot to become available.
It is important not to use the xQueueSend function from with an interrupt service routine (an ISR). Here, the xQueueSendFromISR is used. This is true for all queue functions. Only the …FromISR versions can be used from within interrupt functions.
While the producing task feeds the queue, another process, the consumer, must empty it. Otherwise the producer will eventually block on having filled the queue (or time-out and fail). The consumer uses the xQueueReceive function. It reads the first item of the queue. If the queue is empty, the calling task is blocked for a specified amount of time before it returns with a failure.
When working with queues, it is worth noticing that they work by copying the data being passed between the tasks. If large amounts of data needs to be shared, a custom, mutex protected, circular buffer is a more efficient solution. However, for small amounts of data, queues are a very convenient mechanism to rely on.
Combining what we have looked at this far, gives us something like the structure shown below. Notice that the queue and tasks must be setup in a main function for the code to actually do something.
void producerFunction( void *parameters ) { portTickType lastTimeRun; int value; for( ;; ) { vTaskDelayUntil( &lastTimeRun, PERIOD ); /* Poll sensor here */ xQueueSend( queueHandle, &value, DELAY ); } } void consumerFunction( void *parameters ) { int value; for( ;; ) { while( xQueueReceive( queueHandle, &value, DELAY ) != pdPASS ); /* Process value here */ } }
As you can tell, tasks makes it easy to handle multiple jobs at once, while maintaining clearly separated chunks of code. By using priorities and queues, it is possible to make queues work together.
As FreeRTOS is aimed at such a range of processors – from PIC to Arm. Tasks is not always the ideal solution. For smaller systems, co-routines is an option. The FreeRTOS documentation contains a good comparison of the two.
Before throwing yourself blindly at the possibilities that task offers, it is important to recognize that multitasking, or multithreading, is not a trivial challenge. Dining philosophers and other classical computer science problems make this all too clear. Still, played right, tasks are a very useful tool.