Hello, and welcome to the second part of my talk about the event-driven RTOS kernel called
Super-Simple Tasker (SST). In this part, you will see how SST works internally and,
specifically, how it maps to the hardware of ARM Cortex-M.
Earlier, in the introduction to event-driven real-time kernels, you saw that they break
up with the traditional, endless loop structure. Instead, tasks in event-driven
kernels are one-shot, run-to-completion activations without internal blocking.
This should remind you of something you know very well...
Yes, this is precisely how Interrupt Service Routines (ISRs) work. In any CPU design,
ISRs are one-shot, run-to-completion activations that are not allowed to block.
This is also no wonder because ISRs are handlers for hardware events,
and like all event handlers, they must be non-blocking.
However, in ARM Cortex-M processors, ISRs have three additional, rather unique properties:
1. The Cortex-M CPU has been specifically designed to allow programming ISRs as regular C functions.
This is unusual among embedded CPUs, and to understand the exact hardware mechanism
to support this, I recommend my video lesson #18: "How interrupts work on ARM Cortex-M?"
2. Interrupts in Cortex-M can preempt each other based on the configurable priorities.
This interrupt nesting and prioritization is managed entirely in hardware by the standard
component of all Cortex-M CPUs, called the Nested Vectored Interrupt Controller (NVIC). And
3. All interrupts in Cortex-M can be individually "pended," meaning triggered in software.
I hope you can see that all this looks quite close to how SST manages tasks, but of course,
there are several points to work out to "hijack" Cortex-M interrupts for SST tasks.
But before going into these details, let me quickly review how the NVIC manages interrupts
and, specifically, how it handles the stack. Consider the following scenario shown on the left.
The Cortex-M CPU is running in Privileged Thread mode, which is the mode it starts
executing the main function out of reset. The Main stack is used a little.
At some point, interrupt number-1 is triggered. Assuming that interrupts are not disabled,
the CPU immediately switches to the Handler mode and pushes the interrupt stack frame on
the Main stack. Here it is shown as the stack pointer dropping in value
because the Cortex-M stack grows down. The low-priority interrupt-1 becomes active.
At some later point, a higher-priority interrupt-2 is triggered. The NVIC determines that interrupt
preemption is needed, and the CPU pushes another interrupt stack frame to the Main stack.
The high-priority interrupt-2 becomes active, while the low-priority
interrupt-1 is preempted during its execution. Please note there is no need for a different
stack here because this is an asynchronous preemption of interrupt-1 by interrupt-2,
not a voluntary blocking of interrupt-1 (which interrupts cannot do, anyway).
At some later point, interrupt-2 finishes its run-to-completion (RTC) step and returns,
removing itself from the Main stack. The NVIC detects that the other interrupt-1
is still active, so its execution is resumed.
At some later point, interrupt-1 also completes its RTC step, removes itself from the Main stack,
and the CPU goes back to running the original main loop in the Privileged Thread mode.
This simple scenario dispels a misconception I often encounter that run-to-completion (RTC)
means monopolizing the CPU for the whole duration of the RTC step. As you can see here, this is not
the case. Preemption and RTC can happily coexist. Specifically, the RTC semantics of interrupt-1 is
not violated. The RTC step is only interrupted but then resumes and eventually completes.
Regarding the scenario on the right, the order of triggering the interrupts is reversed. The
high-priority interrupt-2 triggers first, and the low-priority interrupt-1 triggers later. This time
around, the NVIC does not initiate preemption, and interrupt-2 keeps running undisturbed.
The important point here is that interrupt-1 is NOT lost and remains
pended until interrupt-2 runs to completion. Only then interrupt-1 is activated.
Interestingly, in this case, the NVIC optimizes away the usual unstacking registers by the return
from interrupt-2 and stacking the registers by entry to interrupt-1. This hardware
optimization is called "tail chaining." In summary, the NVIC implements in hardware an
optimized, preemptive, priority-based scheduler, which works exactly according to the requirements
of the Rate Monotonic Scheduling (RMS) method. This non-blocking scheduler uses the single
stack (the Main stack of ARM Cortex-M CPU) to hold the context of all nested interrupts.
Now, because all this scheduling and management of interrupt nesting is implemented in the NVIC
hardware, the complexity of it snowballs with the number of supported interrupt priorities.
Therefore, various silicon vendors, who license ARM cores, can implement fewer
interrupt priority bits within limits specific to a given ARM architecture.
For example, ARMv6-M architecture of Cortex-M0/M+ implements only the two most-significant priority
bits, which results in 4 interrupt priorities. The diagram also shows the peculiar NVIC priority
numbering scheme, where lower numerical values correspond to a higher urgency of an interrupt.
ARMv7-M of Cortex-M3/M4 implements a minimum of 3 most-significant priority bits, but some silicon,
like STM32, implements 4 bits. This results in either 8 or 16 interrupt priorities.
The ARMv7-M architecture additionally supports priority grouping, but I will
assume that it is not used and that all implemented priority bits are available.
Now, finally getting to the SST kernel, here is how the highest NVIC priorities
are allocated to interrupts, also called IRQs, and lower priorities to SST tasks.
You can also see the SST priority numbering scheme, which is direct and not inverted like the
NVIC. In SST, the absolute lowest priority zero is assigned to the idle task, and higher numbers
correspond to SST tasks of higher priority. So, now let me show you how this repurposing of
NVIC IRQs for SST tasks looks in the code of the Blinky-Button example I discussed previously.
On the left, you can see the startup code for the STM32 L053 MCU and, specifically,
the vector table for that MCU. The Blinky-Button example needs 4 SST tasks, so I chose 4 IRQ
handlers not used in that particular application. On the right is the Board Support Package
(BSP) for the L053 MCU, where you can see the implementations of the selected IRQ handlers.
As mentioned, all of them are regular C functions consisting of activations of the
specific SST tasks. For example, the I-Squared-C-2 IRQ-Handler activates the Blinky1 SST task.
Every SST task must also know the associated IRQ number to pend the right IRQ in software.
This IRQ number is provided to the task via the call SST_Task_setIRQ().
Before finishing with the vector table, let me mention an alternative if you run out of
unused IRQs, which would be somewhat unusual. In that case you can often repurpose some of the
reserved IRQ entries in the vector table. This other alternative can be activated in the code
by commenting out the macro REGULAR_IRQs. Now, the SST task activation, which is all
the IRQ handler does, is implemented in the SST port to ARM Cortex-M.
This function consists of two main steps. The first step is to get the event out of
the event queue owned by this task. The second step is to call the task's
dispatch function to process this event to completion. In the earlier demonstration,
you saw examples of the dispatch functions for the various Blinky-Button tasks.
Regarding the first step of getting the event out of the queue, I'd like
to point out three important elements: First, the queue must have some events,
which is asserted in the precondition to the activate function.
Second, the event is de-queued from the tail index of the queue, but this tail index is
accessed only from the owner task; therefore, there is no need for a critical section.
On the other hand, a critical section is needed to decrement to the used counter of
the queue because this one is accessed in the generally accessible post-operation.
And third, if there are still some used entries in the queue, meaning it still has some events,
the IRQ associated with the task is pended. This is necessary because the pend-bit is
cleared automatically in hardware when the IRQ handler is activated.
By the way, you can see here how to pend an IRQ from the software. This specific
way is designed to generate good machine code consisting of just three instructions.
Now, regarding posting events to SST tasks, which is the main way of communication and
synchronization in this event-driven kernel, it is implemented in the SST_Task_post() function in
the platform-independent SST code sst.c. This is a standard algorithm to insert an
entry into a ring buffer at the head index. But this time, the whole algorithm executes in the
critical section because event posting can happen concurrently, both from tasks and interrupts.
Again, let me point out a few important elements: First, the queue must have room for the event,
which is asserted in the precondition. At first, you might object that overflowing
an event queue should not be treated as an error. But a failure to post an event in an event-driven
system is equivalent to a failure to call a function in a sequential system. Let me explain.
A sequential system, such as a traditional RTOS thread, might fail to call a function
when it runs out of private stack space. In that case, everybody understands that
all the private stacks must be sized adequately so that they don't overflow.
Event-driven tasks have no private stacks, but they have event queues instead.
For the same reason, these event queues must be sized adequately so that they don't overflow.
The second aspect of event-posting I'd like to point out is the pending
of the associated IRQ, which is the same you saw already in the
task-activate function. This code is specific to the ARM Cortex-M,
and therefore the platform-independent code calls a macro from the port.
In summary of this quick SST kernel overview, I'd like to remind you again that all SST source code
and examples are available on GitHub. Besides the SST in C you just saw, there is also SST in C++.
Furthermore, the SST repository also contains the even simpler, non-preemptive
implementation of the SST API called SST0, which is an interesting kernel as well.
Now, in the last few minutes of this presentation, I'd like to show you
how fast SST for Cortex-M really is. Of course, "fast" has no absolute meaning,
only relative to something else that is generally known. Therefore, for such comparison, I chose
the popular FreeRTOS traditional blocking kernel, to which I ported the Blinky-Button example. That
project is also available in the SST repository, in the directory FreeRTOS-comparison.
Let me open the FreeRTOS Blinky-Button example project in uVision and explain it bit because it
illustrates how to do event-driven programming in a traditional kernel. The project is located in:
Super-Simple-Tasker, FreeRTOS-comparison, examples, blinky_button, armclang.
So, here is, for instance, the Button2a task implemented with FreeRTOS. It is structured
as an event loop that blocks at the top on the FreeRTOS message queue associated with the task.
The queue is setup to block indefinitely as long as there are no events.
Once the queue unblocks and delivers an event, the Button2a_dispatch() function processes the
received event. This is the non-blocking, run-to-completion step of the event loop.
The Button2a_dispatch function is conceptually identical to the
previous one from the SST kernel, which I show for comparison on the right.
The only differences are that event posting to other tasks needs to be re-coded using
the FreeRTOS message queue API. You should note here that event-driven,
non-blocking code is far more versatile than blocking code. You can use event-driven code
everywhere: in a traditional RTOS, in an event-driven kernel, or even directly in
a "superloop." In contrast, code that makes any blocking calls is only usable with an RTOS that
provides the specific blocking primitives, like a blocking time delay, a semaphore,
or whatever other blocking mechanism is used. The other significant differences are in how tasks
are started, whereas in FreeRTOS you need to provide both a private stack and private
queue for every task. Again, the SST code is shown on the right for comparison.
Alright, so let me now build the FreeRTOS project and load it to the
same STM32 NUCLEO-L053 board as used in the SST demonstration. Again this is a low-end
Cortex-M0+ CPU running at a measly 2 MHz. With the FreeRTOS Blinky-Button version
loaded to the board, I will trigger the same scenario as before with the SST
version. I'll trigger on the Button line going low when the button is pressed.
OK, so we have a trace with FreeRTOS. For comparison,
I place the analogous trace recorded for SST. Well, the traces are apparently very different,
the main discrepancy being the SysTick rate. This is because the FreeRTOS SysTick had to
be reduced by order of magnitude from 1000 ticks per second, as it was in SST version,
to only 100 ticks per second. Otherwise, due to a much higher kernel overhead, the software would
overwhelm the slow CPU, which scientifically is called an "un-schedulable set of tasks."
Now, let's look at the scenario that plays out at the second SysTick after the button press because
it takes two ticks to debounce the button. So, here is the zoomed-in scenario with the same
time scales in both views. Unfortunately, I don't have the time to discuss all the
differences in detail, but let me only point out a few interesting spots.
First is the much longer task-level response of the FreeRTOS kernel, which is the time from the
end of the ISR to the beginning of the Button2a task that was scheduled in that SysTick.
Second, the arbitration between equal-priority tasks Button2a and
Button2b plays out differently in FreeRTOS than SST. The hardware scheduler built into
the NVIC apparently schedules Button2b ahead of Button2a because Button2b has a lower IRQ number.
FreeRTOS keeps running Button2a. This is not a critical issue, however, because
this is not a question of preemption, just arbitration of equal-priority tasks.
Now, regarding the preemption of medium-priority Button2b by high-priority Blinky3, both kernels
handle it the same way, except it takes almost 5 times longer in FreeRTOS than SST. The factors
contributing to the latency here are the more elaborate context switch of the traditional
kernel and also the performance differences of the event queues between the kernels.
Finally, to show you that, in fact, the same compiler optimizations and time scales have been
used in both views, I highlighted the pin-toggling times of the Button2a task, which are identical
because they don't depend on the kernel. In conclusion, Super Simple Tasker (SST) is an
event-driven, non-blocking, preemptive kernel with properties that allow it to be implemented almost
entirely in hardware on the popular ARM Cortex-M CPUs. Interestingly, in case you didn't notice,
this SST implementation was entirely in C and didn't require a single line in assembly.
But this talk was also about the larger issues of programming paradigms:
If you want your software to be more flexible and extensive--make it event-driven
If you want your software to be reliable--make it event-driven
If you want your software to be hard real-time--make it event-driven
If you want your software to be modern and suitable for modeling and automatic
code generation--make it event-driven! Finally, if you want your software to be
truly efficient--make it event-driven which also implies non-blocking
I'd like to close this presentation about the Super-Simple Tasker
with a quote from Austin Freeman: "Simplicity is the soul of efficiency."
Thank you for watching!