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.
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.
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:
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?
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?
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:
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.
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.
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