Using a real operating system to simplify
programming with the Arduino IDE. Is this possible and how?
Let s have a closer look! Gr ezi YouTubers. Here is the guy with the Swiss
accent. With a new episode and fresh ideas around sensors and microcontrollers. Remember: If you
subscribe, you will always sit in the first row. Operating systems were invented to simplify our
lives. But, because they need a lot of resources, they only run on reasonable computers like the
Raspberry Pi or a PC. Right? Wrong. Nowadays, we also get operating systems running on our small
MCUs. Particularly interesting in this respect is the ESP32 because it has enough power and memory
to accommodate such an additional burden. And the best: It already runs a version of FreeRTOS with
all our Arduino sketches, and it is easier than you think. Is this useful or a pain in the a ?
To answer this question, - We will start with a short introduction to
multitasking and real-time operating systems - Then we will use a real project to understand
how to structure projects using freeRTOS and where the differences are to standard sketches
- We will have a look at the three most important concepts: Task scheduling, queues, and mutexes
- We will see the advantages as well as the caveats of using RTOS
Let s start with multitasking operating systems. Back in the day, computers were costly. This is
why engineers were asked to find ways to share these machines among many users. So they invented
a thing called the multi-user operating system. No, resource sharing was not invented by Airbnb
and the sharing economy. Time-sharing already existed in the sixties of the last century.
The computer time was chopped into slots, and different slots were assigned to different users.
Because these slots were very short, each user did not know that its program was interrupted by
other users. At least when response times were ok. So, a computer was able to run
several user tasks like in parallel. Operating systems had to ensure that the
shared resources were managed correctly and that data of the different users were held
separately. Shared resources were, for example, printers. If two users wanted to print to
the same printer, the operating system had to schedule one printout after the other. Otherwise,
it would have printed a mixture of two documents. But we do not want that many users can work
with one ESP32! Why do I tell you this story? If you allow one user to get more than one time
slot, he can attach different tasks to slots. Then he has a multitasking operating system
that runs many tasks in parallel. Exactly what we need because most of our projects
have some tasks running in parallel. Then came multi-processor computers that
allowed the operating system to distribute tasks not only to time-slots but also to different
processors. Current computers and Smartphones all run multitasking and multi-processor
operating systems to allow us to use many applications in parallel in an optimized way.
If you type htop on your Raspberry Pi, you see how this is done: A ton of different tasks run
in parallel also if you are the only user. And you see that these tasks are distributed to all
four cores. This is because, more and more, the operating systems execute tasks like networking
in the background. In the background means, their priority is lower, and they only run whenever
the higher priority tasks wait for something. If you want to control machines, a fast reaction
time is needed, and waiting for the next time slot is not an option. Your computer has to react in
real-time. These specialized operating systems are called real-time operating systems or RTOS. They
are multitasking but often not multi-user systems. Standard Linux or Windows, BTW, are not
real-time operating systems and should not be used for time-critical applications.
And now we come to our Arduinos. They have a single CPU or core, do not use an operating
system, and we only can run one sketch at a time. At least, using interrupts, they can react nearly
real-time to events. But basically, they are where computers were in 1960. Not satisfactory!
As usual on this channel, we want more! Of course, we can write sketches that behave
like multitasking systems. To achieve this goal, we have to write non-blocking code. This means
we must not use delay() statements because they would block the Arduino from executing a parallel
task. Also while loops are hazardous because they can loop for a long time and block other tasks.
Normal Universe, for example, shows us in a video how to do that using a state machine . It needs a
lot of additional code, and the sketches are hard to read because they are a mixture of tasks and
operating system. . For this programming style, your thinking has to be very different. I compare
writing such code with turning the inside of a t-shirt out. It works, but it does not look good.
https://www.youtube.com/watch?v=v8KXa5uRavg As said before, the ESP32 offers
a much better possibility: We can use the built-in FreeRTOS.
And we still can use our Arduino IDE. A well-kept secret for many Makers. In video #168,
I shortly used it but did not mention the fact. From our historical trip, we already
know what an RTOS has to offer: - Time slot management to run tasks in parallel
- Protection that tasks are kept separated - Task-to-task communication
- Sharing of common resources like a display - Fast reaction time using interrupts
- Background process execution using priorities FreeRTOS is open source, runs on many different
MCU architectures, and offers a lot of those functions. I leave a link to a much deeper
tutorial from Digikey if you are interested. Fortunately, we can build quite complex
programs using only a handful functions. So, which are these essential
functions, and how are they used? BTW: Why does ESP32s have a built-in RTOS?
Because WiFi and networking are time-critical, Espressif decided to use freeRTOS to manage those
tasks. It runs on Core0, and our Arduino sketches run on core 1. We all remember that the ESP8266
crashed if we did not put yield() commands in the right places. Because the ESP8266 had no RTOS
and only one core, our code could block WiFi and crash the MCU. Adding a yield() statement here
and there gave WiFi the possibility to take over. With the ESP32, WiFi always runs in parallel to
our sketches, and we do not have to care. Our code can block as much as we want, and WiFi still
works a significant advantage because of FreeRTOS. Enough theory. Let s build
something to see how it works. I use my trusted Morse Trainer to show you
how to build a project using RTOS. BTW: This trainer is the root of this channel because
it was featured in the first episodes. But why do I dig this example out? It probably would have
been better for my image if I kept it buried! After the specification of the project, you
will understand. As Albert Einstein said: If I had an hour to solve a problem, I d
spend 55 minutes thinking about the problem and five minutes thinking about solutions. Here
you see that he was a physicist, not an engineer. We usually do it the other way round.
My Morse Trainer consists of a loudspeaker, a box, and a keyboard. Three things have to run
in parallel: A generator produces Morse signals for the loudspeaker and is time-critical
to the millisecond. If not executed well, it could result in a harsh reaction. More than
100 years ago, they invented Q-codes to abbreviate common sentences and save transmission time.
QTH? For example means, What is your position? And QLF means "Are you sending with your
left foot? Invented for poorly timed signals. Don t tell me these guys had no humor.
So this task for sure has to run separated from the rest. We call it morseTask.
A receiver task has to wait for keyboard entries: As soon as the trainee hears a letter in Morse, he
has to press the correct key on the keyboard. This task then has to decide if the entry was right or
wrong and, based on this knowledge, adapt training in real-time to make the trainee always sweat
a little but keep his or her motivation high. It is the drill sergeant. If the trainee makes
many mistakes, the training speed has to be lowered and, without errors, the speed has to
be increased. If a letter was not or wrongly recognized, the trainer increases its occurrence.
A genuinely individualized training! Increasing training speed is the only compliment
this trainer can give. Everybody who was in military service knows what I am talking
about. If you are good, you get more work! This is the second task. It has
to react fast on keyboard entries. A third coordinator task oversees the training
from start to end and generates new letters. This task is not time-critical.
You see, a ton of problems to solve. How can we solve them using RTOS?
We need three tasks which have to run in parallel. And we have to have streams of letters between the
different tasks a perfect application for queues. One queue is between the Morse task
and the receiver task, and a second one between the Coordinator and the Morse task.
The trainer has several states like initializing, training, traineeLost, and endOfTraining. These
states can be changed by two tasks, and therefore are a shared resource that has to be protected. Do
we have other shared resources? As we saw before, the keyboard will be used by the receiver and the
coordinator task. The same applies to the display. So we need three tasks, two
queues, and three shared resources. Now we can start programming. I decided
to use Visual Micro for this project because I want to use a hardware debugger. But
you can use the Arduino IDE without problems for your RTOS projects. It uses the same code.
Our sketch looks like all other sketches: Setup and loop. There is a secret we never cared
about: Setup and loop also are RTOS tasks. Our three tasks are created here as the last
step in setup(). They immediately start to run. Now, our ESP32 runs more than five
tasks in parallel: The network process, Setup, Loop, Coordinator, Receiver, and Morse.
Our three tasks get the same priority, a decent amount of private stack memory, and
are pinned to core 1. I do not want to fight with the WiFi tasks and therefore leave core
0 altogether to RTOS. We will later see that the ESP32 is fast enough to run the whole
application on one core without problems. Why did I assign the same
priority to all my tasks? To reduce complexity. I strongly suggest assigning
the same priority to tasks if one task can be executed during one time slot. Otherwise,
you quickly get nasty things like deadlocks. As said before, the RTOS scheduler
chops the processor time in slices and assigns it to tasks. It uses the round-robin
concept, where each ready task gets its slot. One after the other if they have the same priority.
If not, the task with the higher priority always gets the next available slot. Important:
Unless it gives control back to the scheduler, a task runs during its entire slot. The duration
of a slot on the ESP32 is one millisecond, BTW. Now comes the best: You can treat each task
as it would run alone on an ESP32. Even if your code seems to block like in this while loop
waiting for a keyboard entry, the scheduler will interrupt it and assign the next slot to another
task. This leads to simple and readable programs and is very convenient for the programmer.
Let s look at the Morse task. It reads a letter from the morse queue and switches the Morse signal
on or off. Let s assume it has to send an E at a speed of 60 WPM. This means it has to switch the
signal on and wait for the next 66 milliseconds. Because we can program as if it would be the only
sketch running on the ESP32, we use delay(66) and jump back to the beginning, where it automatically
switches the signal off and waits for another 66 ms. This creates no harm because the receiver
task still gets its time to read the keyboard. In RTOS, we use vTaskdelay() because it signals to
the RTOS scheduler that the Morse task will not be ready for the next 65 ms. So the scheduler can
use these 65ms for other tasks. Very efficient! As soon as the scheduler gets control, it
selects the next ready task. For example, the coordinator task that creates a
new word and stuffs it into the queue. After its execution, there is no need to wait,
and it gives control back to the scheduler using taskYIELD(). The next task is the receiver task
that waits for the keyboard. And so on. When the 66 ms are up, the Morse task gets a slot again.
From a user s perspective, all three tasks run in parallel. Which is precisely
what we want. And the programmer wrote three nearly independent programs.
FreeRTOS does the rest. How cool is that? What we nearly forgot: The scheduler also
gives slots to the WiFi and other tasks on core 0. And also to loop(), which also runs
on core1 and just handles OTA in our case. Now we know how to deal with tasks and can go on
with queues. They conveniently pass information from task to task. They usually are FIFO, first
in, first out, and have a defined maximum length. Let s look at morseQueue between
the Coordinator and the Morse task. How does the Morse task read
the queue? With this command. morseQueue is the queue's name, letterForMorse
the letter to read, and portMAX_DELAY means that the sketch waits forever if the queue
is empty. Again, blocking code without harm. And how can the Coordinator fill the queue?
With this command. Again we see the queue name, the letter, and portMAX_DELAY. Here, the
task has to wait when the queue is full. You also see a significant advantage of these
queues: We can fill a whole word at a time if there is enough space and do not have to care
about queue management. Really convenient! But if we have to transfer more than one
variable from one task to the other? For example, I want to transfer the sent letter as well as its
transmit time from the Morse to the Receiver task. Do we need two parallel queues? No, this is not
necessary if we use a structure. Such a structure behaves like one variable and can contain as many
other variables as you want. You can read and write the internal variables by using this format.
Passing data from one task to another is really simple because FreeRTOS does all the queue
management. Also our code is very readable. Next: How do we protect shared
resources or data from being used or changed by two tasks in parallel?
One example is the OLED. The Coordinator and the Receiver task use the display. Because these tasks
run in parallel, the chance for gibberish in the display is high if we do not make sure that the
display can only be used by one task at a time. The same applies to the variable trainerStatus,
which also can be updated by two tasks. Also here, we have to avoid that two tasks
change the variable without coordination. We use so-called semaphores used as mutexes
to give exclusive access to a shared resource. Mutex means: Mutual exclusive.
Here you see how they are defined. We need one per shared resource.
As soon as a task wants exclusivity, it takes the mutex.
xSemaphoreTake(KEYBOARDMutex, portMAX_DELAY); Also here, using portMAX_DELAY forces the
program to block till the mutex is available. As soon as the mutex is granted, this task has
exclusive access to the protected resource. Until it gives the mutex back:
xSemaphoreGive(KEYBOARDMutex); Now another task can use it. Keep in mind: Not
the resource itself is protected; the mutex helps us protecting it. Mutexes, therefore, only work if
you protect all accesses to a particular resource. AFAIK you only need to protect shared
variables if several tasks update them. If they are updated by only one task and used by
other tasks, this seems ok without protection. A remark: If you use RTOS in
interrupt service routines, you have to use slightly different commands.
The last thing we have to know: How do we protect critical sections which must not be interrupted
at all? We have to disable interrupts which also disables the scheduler for a short moment.
Commands are: portENTER_CRITICAL(&timerMux);
portEXIT_CRITICAL(&timerMux); or
taskENTER_CRITICAL(&timerMux); taskEXIT_CRITICAL(&timerMux);
They work like other mutexes but are called spinlocks because they keep
control inside the task. So please pay attention when you use them and keep the protected
section short. They block everything else. BTW: On the ESP32, it does not matter
which of the two commands you use. Both call the same function, which
blocks interrupts on both cores. So you know the essential commands. If
you need more, you can still go to the FreeRTOS documentation and search for it.
Now comes, of course, the critical question: When do we use RTOS, and when a standard
sketch is ok? And are there caveats? I suggest using an RTOS:
- If several things have to run in parallel. A typical sign is
having problems with delay() statements or while loops in your conventional sketch
- If you want to use both cores, you have to use RTOS tasks
- If you need data streams between different parts of your code and would have to program your own
queue management, it is much easier to use an RTOS Many things are easier with RTOS tasks and
queues. But there are also caveats: Because everything runs in parallel, you have to pay more
attention to the synchronization between tasks. This is why it is so important to find
shared resources and protect them. Debugging can sometimes be more challenging
because the reason for a problem can be hidden in another task. But, after
a while, you learn to live with it. A hardware debugger can help a lot and,
if you want to be even more sophisticated, a logic analyzer shows you how your code
moves through the different tasks. Maybe you watch video #76 if you want to know more.
Here the logic analyzer shows us, for example, that the Morse code is blocked for 66 seconds,
and the receiver task runs every millisecond. This was all for today. Maybe you share your
experience with FreeRTOS in the comments? As always, you find all the
relevant links in the description. I hope this video was useful or at
least interesting for you. If true, please consider supporting the channel to
secure its future existence. Thank you! Bye