Understanding FreeRTOS: Applications and Use Cases

Understanding FreeRTOS: Applications and Use CasesUnderstanding FreeRTOS: Applications and Use Cases

Last year during the NXP KW41 competition, I was forced to take a bite of FreeRTOS. I later decided to systematically learn it and try to apply it to my DIY projects. FreeRTOS is one of the many RTOS (Real-Time Operating Systems) available. Since it is widely used and open-source, it is a good choice for learning. I have roughly read its documentation, and now I am going back to sort out and study the implementation details while writing this series.

When can FreeRTOS be used?

Do microcontrollers need an operating system? If we consider commonly used operating systems like Windows, Linux, BSD, and even DOS, running an operating system on a microcontroller seems absurd—because most microcontrollers have too little RAM. FreeRTOS does not aim to provide a platform for running software on microcontrollers, where software can be installed for users to choose what to run; it has no user interface; it is not a butler, nor does it come with any hardware drivers or file system services. FreeRTOS is just an operating system kernel that primarily provides the most important feature of an operating system: task scheduling.

In other words, with FreeRTOS, implementing multitasking on microcontrollers becomes easier. There are at least two layers of meaning here. First, is multitasking necessarily achievable only with an RTOS? Certainly not. For microcontroller development, all system resources are yours, and handling different tasks in different interrupt services is not very difficult. Second, does the absence of multitasking mean that an RTOS is completely unnecessary? That depends on the specific situation and how the concept of “task” is defined. A complex task may also be divided into several tasks for processing in a program. Let’s illustrate with a few examples.

1
SD Card MP3 Player

Without considering the user interface, during playback, the player works as follows from the data stream perspective (with some simplifications): I2S interface DMA buffer is free, fill the buffer with remaining PCM data, request to decode the next chunk of MP3 data, request to read the next segment of the MP3 file, locate the sector position to read on the SD card, send the SDIO controller read command, receive data completion interrupt from the SDIO controller, fill the file data buffer, decode MP3 data, write PCM data to the buffer, fill the I2S interface DMA buffer. This process involves four key software components:

Understanding FreeRTOS: Applications and Use Cases If we follow a top-down software design approach, the I2S device driver is awakened at a fixed rhythm to fill the PCM data buffer, so it needs to periodically call the MP3 decoding program. The MP3 decoder determines whether to access the file system based on the results of the previous decoding operation (since the amount of PCM audio data generated from decoding a chunk of MP3 data may not match the size requested by the I2S device driver) and how many bytes of MP3 file content need to be read, along with temporarily unused decoded data that must be saved for next use. When it comes to the file system, the requested read position and length may not align with a single sector on the SD card, so there is also caching, and it needs to track the file index on the SD card. The nested calling relationship looks like this:

Understanding FreeRTOS: Applications and Use Cases

Note that each function call represents a complete operation: filling a buffer, decoding a segment of MP3 data, reading a segment of a file, and reading a block of data from the SD card. The lower-level subroutine is called, and upon completion, control returns to the previous level of the program to continue execution. In the absence of exceptions (interrupts), the program does not leave this nested calling relationship.

It is also important to note that while the main functions listed above represent complete operations, their internal states may not be the same after each operation. In C language terms, these functions need to have static local variables or use some global variables to remember the state from the last call. A typical example is the file system function read_file() which, after being called, needs to remember the position of the file pointer for the next read.

The implementation of the music player described above has one significant drawback: during the wait time for the SD card read operation, the execution of the CPU can only remain in the SD card access function, which cannot be used for MP3 decoding calculations, wasting processing power. During the time when the I2S device’s buffer is filled until it needs to be filled again, the CPU is also in an idle state. In fact, these two idle periods can be utilized to perform other tasks, such as pre-decoding a portion of MP3 data. The question then is, how can we jump out of a function during execution to perform other functions and then jump back? Using interrupts, yes, but what do we place in the interrupt?

2
USB Mass Storage Device

This utilizes the onboard USB device of the microcontroller to simulate a USB flash drive. The main content of such a device is to respond to various requests from the USB host. When the USB host sends a request to the device, the USB hardware generates an IRQ, and then the USB interrupt service function (IRQ handler) is executed. Typically, the USB driver library provides some callback function interfaces, which allow users to write functions for the USB IRQ handler to call when needed.

As a USB mass storage device, the required callback functions must include reading and writing to the storage device to produce actual disk data for USB and to receive disk data that USB requires to write. When these functions are called is unpredictable from the application’s perspective, as it occurs during the response process after a USB interrupt. In this way, the callback functions handle the USB flash drive simulation transactions, and the main program can handle unrelated tasks, thus achieving multitasking!

However, executing user code in an interrupt environment does not seem like a good choice, as interrupts with the same or lower priority cannot respond. In actual USB mass storage devices, the USB flash drive data must be read from external storage devices like NAND flash, which complicates matters—reading data requires waiting, and this wait occurs entirely within the USB IRQ handler, causing the USB host’s requests to be temporarily unresponsive. If tasks in the main program also require hardware I/O interrupts, it may be affected.

Therefore, as an application that only simulates a USB flash drive without performing other tasks, this may not be significant: after all, it is all waiting. Based on my experience with USB full-speed (12Mbps) flash drive devices, the maximum transfer speed reached is over 500kB, which is significantly less than 12Mbps. Why is that?

Understanding FreeRTOS: Applications and Use Cases Looking at the above diagram, during the USB IRQ interrupt response period, no data is transmitted, so part of the effective bandwidth of 12Mbps is wasted, and the throughput of the USB flash drive naturally does not reach its theoretical value. The callback function waits during I/O, and the USB host is also waiting for the USB device’s response. Once the USB flash drive data is ready, the USB hardware sends the data, and the CPU is idle until the next request arrives. A more reasonable design is to utilize the USB TX time for actual storage device I/O pre-reading, i.e., guessing that the USB will continue to request reading the next data segment that was read last, so it reads the data into memory in advance. If the guess is correct, the response time for the next request can be shortened, improving the simulated USB flash drive’s reading throughput. To achieve this, I/O tasks cannot simply be processed in the callback function.

3
Data Management for Recording Devices

A certain application requires performing ReadChipID, ReadStatus, ReadData, EraseData, and WriteData operations on a remote device (connected via UART) to manage data. These operations are implemented by sending formatted command data via UART and parsing the returned data to determine the success of the operation and receive valid data.

If we focus on the UART command sending here, the program structure might look like this:

Understanding FreeRTOS: Applications and Use Cases This design assumes that the remote device will respond on time, with almost no fault tolerance in the UART interaction process. If an unexpected situation occurs during the interaction, it cannot recover from the error state and must start over.

Once we consider the potential exceptions in UART communication, the program writing becomes less straightforward. One approach is to write the function for parsing the received UART data as a state machine, determining the program’s execution state based on whether the data meets the predetermined format and recognizing the returned data. At certain states, commands are sent via UART. However, this writing method makes it difficult to discern the program’s intent from a code-reading perspective, mixing the operational flow with communication error handling, which is not conducive to code maintenance.

How can we integrate communication state recognition and error handling mechanisms while keeping the program flow intuitive? Generally, this is a single-task (thread) program, executing sequentially without the need for interrupts. The program operates according to a process while also handling UART communication exceptions that are not tightly related to the flow. If we imagine these as two tasks that are predictably interleaved, it seems to complicate a simple matter… The three examples above could all benefit from “task scheduling” for more effective time allocation. In my view, FreeRTOS provides a new way to organize programs—tasks, which divide complex matters into independent small pieces for separate writing; at the same time, it offers mechanisms for these small functional pieces to collaborate to achieve the overall goal. FreeRTOS tasks are written as C language functions, which provide a feature that allows several functions to appear to be “executing simultaneously.” Of course, with only one CPU, they are actually executed in turns, but this is a significant difference from ordinary C language programs.

We know that C language functions can be nested and recursively called. For example, funcA() calls funcB(), then funcB() calls funcC(), and even funcC() can call funcA(). However, funcA() must wait for funcB() to return before continuing with its subsequent content, and funcB() calling funcC() also waits for funcC() to complete before returning control.

Understanding FreeRTOS: Applications and Use Cases

In contrast, FreeRTOS tasks do not work that way; taskA() can voluntarily or passively yield control during execution, allowing taskB() to gain control at that moment, but it does not start executing from the beginning; instead, it continues from where it last yielded control. At some point, taskB() yields control, and the system chooses to execute taskC(), and when taskC() yields control, taskA() resumes execution.

Understanding FreeRTOS: Applications and Use Cases The above diagram simplifies the process of jumping from one task function to another; in reality, there is also a segment of FreeRTOS kernel code executed in between, meaning the kernel is responsible for scheduling which task to execute next, how to pause the current task, and how to resume another task. Is multitasking scheduling necessarily implemented using some RTOS? Not necessarily; using an RTOS is helpful, but it also incurs a bit more resource overhead. For example, as previously mentioned, using interrupts to switch tasks can achieve simple multitasking. Another example is breaking a task into multiple steps, each corresponding to a function, and then selecting which task’s step to execute in a large loop. This type of multitasking is non-preemptive, while the multitasking implemented with interrupts is preemptive.

Note that regardless of whether some RTOS is used to implement it, the characteristic of multitasking operations is that each task must completely save its state (including private data) as long as it has not finished executing. Because the function representing the task (such as an interrupt service routine or a certain step of a task) loses its local variable scope once it returns, it cannot be used to save state; therefore, task state preservation must use global variables, static local variables, or dynamically allocated storage. FreeRTOS’s approach is to allow task functions to pause without needing to return, enabling multiple such non-returning functions to exist simultaneously, from which one can be resumed at any time. How is this feature achieved? Please listen to my analysis in the next article.

Recommended Reading

Dry Goods | DIY an ARM Learning Machine

Dry Goods | Application of Enumeration Variables and Macros

Dry Goods | Creating a Beautiful VFD Voice Clock

Dry Goods | Step-by-Step Guide to Making a Mini Mobile Power Supply with LED Emergency Light

Dry Goods | Voltage Regulation Discussion

Dry Goods | DIY PCB Circuit Board for Engraving Machines

Dry Goods | How to Use Digital Rotary Encoding Switches

Dry Goods | Customizing Mechanical Keyboards with QWERTY/Dvorak One-Key Switching

Dry Goods | Writing a Super Simple CPU

Understanding FreeRTOS: Applications and Use Cases

Leave a Comment

×