Understanding Priority in Embedded Systems

Source: WeChat Official Account [Osprey Talks on Microcontrollers]

Author: Osprey

ID: emOsprey

In embedded software development, we inevitably encounter the concept of priority. Mastering the concept of priority is crucial for designing a good software system.

The main content of this note includes the following aspects:

1. Interrupt priority

2. Task priority in the operating system

3. Handling of equal priority

4. Interrupt nesting

Today, Osprey will discuss some secrets using the familiar STM32F103 platform.

Understanding Priority in Embedded Systems

First, let’s start with a variable increment in a bare-metal system.

Understanding Priority in Embedded Systems

There are three variables: A, B, and C. The B variable increments not only in the main function but also in the interrupt handler.

Here we consider two scenarios for B: first, incrementing in the main function before the interrupt, and second, incrementing in the interrupt before the main function.

Regardless of which scenario occurs, when the program reaches position C, the value of B will be the same.

Of course, the above analysis is from the perspective of C language. If analyzed from the assembly perspective (the increment operation in assembly consists of three steps), you will find that there is actually a third scenario:

Understanding Priority in Embedded Systems

The B here can be considered as a register, which is a copy of variable B.

It is precisely because of the existence of the copy that during the writing process in the main function, the increment operation of B in the interrupt is lost.

For the program, it is as if the interrupt was never entered!

This is the hidden danger of using global variables.

However, a discerning friend might ask, why was only the possibility of B++ in the main function being interrupted considered, while the possibility of the interrupt (the orange part) interrupting B++ was not? Did Osprey overlook this?

No, actually this involves today’s topic, priority.

In a bare-metal system, the interrupt priority is higher than that of the main function, meaning that once an interrupt occurs, regardless of where the main function is executing, the interrupt program will be processed first, and only after the interrupt program is completed will the main function continue execution, so the B++ in the interrupt cannot be interrupted by the main function!

Here are two questions:

How does one enter the interrupt function?

When an interrupt request (which can be considered a level signal, represented as a flag bit in the register) occurs, the hardware saves some registers onto the stack (a special data structure), which includes the PC register (used to indicate the position of the next instruction to execute), and then finds the entry address of the interrupt handler from the vector table to begin executing the interrupt handler.

How does one return to the original position?

Because the values of the PC and other registers were saved before entering the interrupt, once the interrupt handler is finished executing, the previously saved values from the stack can be restored, allowing the CPU to continue executing from the interrupted instruction.

For more related interrupt behaviors, please refer to the “Authoritative Guide”; Osprey will not elaborate further.

Returning to the topic of priority, because the execution priority of the interrupt is higher than that of the main function, the B++ in the interrupt will not be interrupted. This is why sometimes we do not need to perform critical section protection on variables in interrupts.

In the Cortex-M3 core, interrupts are divided into maskable and non-maskable interrupts, and there are also programmable priority and non-programmable priorities.

Understanding Priority in Embedded Systems

A maskable interrupt means that this interrupt can be masked; even if it occurs, the CPU will not execute the program inside the interrupt.

For example, in our timer interrupt, if we do not enable the corresponding interrupt, even if the timer overflow interrupt occurs, it will not enter the interrupt handler.

A non-maskable interrupt means that this interrupt cannot be masked; for instance, the reset interrupt (isn’t it incredible that the first instruction executed is inside the interrupt handler?). If the reset interrupt were masked, the system would not be able to run.

Programmable means that the priority of this interrupt can be modified by software (non-programmable means the priority is fixed and cannot be modified).

The priority of interrupts can be set to either preemptive priority or non-preemptive priority (depending on the microcontroller, the number of bits that can be set for preemptive and non-preemptive priority may vary and can be allocated their respective bits, which is referred to as interrupt grouping; for example, STM32F103 has a total of four bits, and by setting interrupt grouping, we can determine the number of bits allocated for preemptive and non-preemptive priorities).

Preemptive priority means that if interrupt 1 has a higher preemptive priority than interrupt 2, once interrupt 1 requests an interrupt, even if interrupt 2 is currently executing, it will forcibly enter interrupt 1 for execution. This is similar to the relationship between the main function and interrupts, except that both are interrupts.

Understanding Priority in Embedded Systems

In the case of equal preemptive priority, non-preemptive priority will come into play.

If interrupts 1 and 2 have the same preemptive priority but different non-preemptive priorities, if both interrupts occur simultaneously, the interrupt with the higher non-preemptive priority will be processed first.

But what if they do not occur simultaneously? Then the interrupt requests will be processed in sequence, and during the processing of one interrupt, it cannot be interrupted by another interrupt. Furthermore, if a new request for the same interrupt occurs, it will not re-enter the interrupt handler.

That is, an interrupt cannot interrupt its own processing. In other words, an interrupt will not execute halfway and then re-enter its own interrupt handler due to a new interrupt request.

What if both the preemptive and non-preemptive priorities are set to be the same? In this case, if both interrupts occur simultaneously, how do we choose which one to execute first? Is it random?

This involves hardware priority.

In the diagram, each interrupt has a fixed default priority, which must differ. Thus, when both preemptive and non-preemptive priorities are the same, the one with the higher default priority will be executed first.

See the diagram:

Understanding Priority in Embedded Systems

When discussing interrupts, we cannot avoid the topic of how to disable interrupts.

In conventional operations, we use global interrupt disable to disable interrupt processing. Once global interrupts are disabled, all interrupts except non-maskable interrupts will be masked. If an interrupt occurs while interrupts are disabled, it will not execute.

However, once interrupts are enabled, the previously masked interrupts will immediately begin executing (there is a pending interrupt flag that represents the occurrence of an interrupt; only when the CPU executes the interrupt handler and clears the corresponding flag will this pending flag be cleared).

If two interrupts are sent while interrupts are being disabled, for example, if an external interrupt occurs twice, then after enabling interrupts, only one interrupt will be responded to because the pending flag only has one bit (unlike a queue that can hold multiple flags).

For general functionality, globally disabling interrupts is indeed useful and very effective for protecting global variables. However, it will have a certain impact on the whole system.

If the time to disable interrupts is very short, it is indeed inconsequential. However, if a longer duration is required (on the order of milliseconds), for interrupts that need to be processed in a timely manner, it becomes a non-negligible delay.

In operating systems, to protect global variables, operations to disable interrupts occur frequently. So, is there a way to mask partial interrupts while allowing high-priority interrupts to remain unmasked?

Yes, in the Cortex-M3 core, there is a register specifically for this purpose, called BASEPRI.

When this register is set, it masks all interrupts that do not exceed a specific priority value.

For example, if this register is set to 3, then interrupts with priorities of 0 to 2 will not be masked.

Thus, in operating systems, we can modify the code that disables interrupts so that it does not mask high-priority interrupts, increasing real-time performance for high-priority interrupts.

uCOS II defaults to globally disabling interrupts (this can be modified), but FreeRTOS can mask partial interrupts, using the aforementioned register. Of course, this feature requires support from the microcontroller itself.

This concludes the content on interrupt priority. If you are only familiar with bare-metal systems, this is about it. However, if you are dealing with operating systems, you need to add the concept of task priority.

A task can also be considered a type of interrupt, but this special interrupt has a lower priority than all hardware-triggered interrupts.

The priority of interrupts supersedes all tasks.

Understanding Priority in Embedded Systems

This means that once an interrupt occurs, regardless of which task the CPU is executing, under the condition of global interrupts being enabled, it will immediately execute the program inside the interrupt.

In interrupts, interrupt nesting can occur, where the current interrupt is interrupted by another higher-priority interrupt (i.e., preemption). The interrupted interrupt must continue executing only after the higher-priority task has completed. In embedded real-time operating systems, to better handle real-time tasks, tasks are generally set to be preemptible (also referred to as deprivable).

Understanding Priority in Embedded Systems

The priority handling of interrupts is managed by the kernel, where the kernel refers to the microcontroller’s core, such as the Cortex-M3 core of the STM32F103 (more accurately, it is managed by NVIC).

Once the corresponding registers are set, as soon as an interrupt occurs, the interrupt program will be automatically processed; this work is completed by the hardware, which will select the highest priority for processing when multiple interrupts occur simultaneously; it will also interrupt the execution of the current interrupt if a higher-priority interrupt occurs during the execution.

However, operating systems are purely software actions, so who manages the task priority in operating systems? How is it managed?

The answer lies in the Systick interrupt.

Since we need to manage the priority of all tasks, i.e., to select the highest-priority task to run at the appropriate time, the operating system itself must have the ability to deprive all tasks of execution, and interrupts, which supersede tasks, can deprive task execution at any time to gain CPU usage rights. Hence, it is appropriate to choose interrupts as the core of the operating system.

However, with so many interrupts, which interrupt is most suitable? None other than the Systick interrupt, as it was born for this purpose.

Systick is essentially a timer, but unlike ordinary timers, it has a relatively single function, just a counter. Thus, it is suitable to use it to manage tasks without occupying other timers.

So how does Systick manage tasks?

Generally, Systick is set to interrupt every few milliseconds. During each interrupt, the Systick handler (i.e., the operating system kernel) selects the highest-priority task to execute, meaning that the system always runs the highest task.

This characteristic also means that your high-priority tasks cannot execute indefinitely without voluntarily releasing the CPU, because if a high-priority task executes indefinitely, the low-priority tasks will never get a chance to execute, giving the illusion of a system freeze.

Understanding Priority in Embedded Systems

Understanding Priority in Embedded Systems

Some friends may wonder why the idle task does not need to call the system delay function to actively release the CPU’s usage rights?

This is because the idle task itself has the lowest priority among all tasks. If it voluntarily releases the CPU and all other tasks are in a suspended state, then who should the operating system let execute?

Therefore, the idle task needs to remain in a running state at all times.

From this perspective, the main function of the operating system is to periodically search for the highest-priority task from all tasks and allow that task to have the opportunity to run (using the PendSV interrupt to switch to the task, simulating the interrupt switching process), functioning similarly to an interrupt manager.

And because the operating system only looks for the highest-priority task to execute (this is the case for real-time operating systems; some operating systems may adopt a first-come-first-served strategy), it becomes particularly important for tasks to voluntarily release the CPU.

The most commonly used function to voluntarily release the CPU is the system delay function. After calling this function, the task will delay for a while before continuing execution. During the delay, the operating system can call other tasks to execute, which is why the operating system appears efficient.

Although the operating system needs interrupts to deprive all tasks of execution and thus gain control of the CPU, generally speaking, its priority is the lowest among all interrupts because its priority only needs to be higher than that of tasks. If it is set higher, it will affect the truly high-priority interrupts that need to be processed first, as the Systick interrupt is relatively frequent and burdensome. If set too high, lower-priority interrupts may not be processed during Systick handling, which is not what we want.

If set to the lowest interrupt priority, it can deprive tasks of execution while still allowing timely processing of high-priority interrupts when they occur, thus enhancing system real-time performance.

Along with Systick, there is also a PendSV interrupt, which usually has the same priority as Systick. Generally, this interrupt is triggered by the operating system kernel (triggered by software during task switching), unlike Systick, which is passively triggered at intervals. For a more detailed description of these two interrupts, please refer to the “Cortex-M3 Authoritative Guide”.

Since interrupts can be set to the same priority, tasks should be able to as well. Indeed, most operating systems can set tasks of the same priority (uCOS II cannot, but uCOS III, FreeRTOS, and RT-Thread can). So how does the operating system handle tasks of equal priority?

Generally, during task initialization, the task time slice is set. This time slice only takes effect when task priorities are the same.

For example, if task 1 sets 5 time slices (i.e., Systick interrupt time), and task 2 sets 10 time slices, if both tasks have the same priority, during 15 time slices, task 1 will execute for 5 time slices, then switch to task 2 for 10 time slices, and so on.

So when should tasks with higher priority than tasks 1 and 2 execute? The answer is anytime. As long as a high-priority task has a need, regardless of whether tasks 1 and 2 voluntarily release the CPU, the operating system will forcibly switch to the high-priority task for execution (this is done by Systick, so there may be a slight delay).

What about tasks with lower priority than them? This depends on their self-awareness. If they voluntarily release the CPU (for instance, by calling the system delay function), then low-priority tasks will have an opportunity to execute; otherwise, low-priority tasks will not execute!

A diagram should be used to illustrate the entire system’s priority relationship:

Understanding Priority in Embedded Systems

Finally, Osprey would like to discuss how to set task priorities.

Many people design task priorities in the order of 0, 1, 2, 3, but in reality, this setting is unreasonable. Because once the requirements change later, if a middle priority needs to be added, it may cause problems in the program after being added.

Actually, we can take inspiration from the interrupt priorities of the Cortex-M3, leaving some priority levels unused for future expansion. For example, when designing priorities, we can set them to 3, 5, 7, 9, 11, reserving the highest 0 to 2 for possibly high-priority tasks, leaving one or two priority levels in between for expansion. This way, if other priority tasks need to be added later, it will be exceptionally simple (there may be a slight extra memory overhead, but it is worth it).

Recommended Reading:
How to Calculate CPU Usage for Threads?
In the Future, You Will Thank Yourself for Writing Exception Handling Code
The Ultimate Serial Port Reception Method, Extreme Efficiency
Why You Must Master KEIL Debugging Methods?
The Evolution of Delay Functions (Collection)
Are Pointers Difficult? | Analyzing the Process and Significance of Pointers (Part 1)
How to Write a Robust and Efficient Serial Port Reception Program?
Those Things About KIEL Debugging — Variables (Part 2)
How Should We Set Breakpoints for Microcontroller Debugging After Many Years? | Subverting Cognition

-THE END-

If this is helpful to you, remember to forward and share it!

WeChat Official Account “Osprey Talks on Microcontrollers

Updated Weeklywith Microcontroller Knowledge

Understanding Priority in Embedded Systems

Long press to go to the WeChat official account shown in the image to follow

Leave a Comment

Your email address will not be published. Required fields are marked *