In embedded software development, we inevitably need to touch upon the concept of priority. Mastering the concept of priority is particularly important for designing a good software system.
The main content of this note includes the following aspects:
1. Interrupt Priority
2. Task Priority in Operating Systems
3. Equal Priority Handling
4. Interrupt Nesting
Today, let’s discuss the secrets of these concepts using the familiar STM32F103 platform.
First, let’s start with a variable increment in a bare-metal system.
There are three variables, A, B, and C, where the variable B is incremented not only in the main function but also in the interrupt handler.
Here we consider two cases for B: one where the increment in main is executed before the increment in the interrupt, and another where the increment in the interrupt is executed before the increment in main.
No matter which case occurs, when the program executes at position C, the value of B remains the same.
Of course, the above analysis is done from the perspective of C language. If analyzed from the assembly perspective (the increment operation in assembly is divided into three steps), you will find that there is actually a third case:
Here, B’ can be considered as a register, which is a copy of variable B.
It is precisely because of the existence of this 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, an analytical reader might raise a question: why did we only consider the possibility of B++ in the main function being interrupted, but not the possibility of B++ in the interrupt (orange part) being interrupted? Did Osprey overlook this?
No, this actually involves today’s theme, priority.
In a bare-metal system, the interrupt priority is higher than the handling of the main function, which means that once an interrupt occurs, regardless of where the main function is executing, the interrupt program will be executed first. 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!
Let me insert two questions:
How does one enter the interrupt function?
When an interrupt request (which can be thought of as a kind of level signal, represented by a specific bit in the register) occurs, the hardware is responsible for storing some registers onto the stack (a special data structure), which includes the PC register (used to indicate the execution position of the next instruction). It then finds the entry address of the interrupt handler from the vector table and begins 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, as long as the values previously saved onto the stack are restored after executing the interrupt handler, the CPU can 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 earlier topic of priority, because the execution priority of interrupts is higher than that of the main, the B++ in the interrupt will not be interrupted. This is also why sometimes we do not need to perform critical 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.
What is meant by maskable? This means that this interrupt can be masked; even if the interrupt occurs, it will not allow the CPU to 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.
And non-maskable means that this interrupt cannot be masked; for example, a reset interrupt (isn’t it unbelievable that the first instruction executed is actually inside the interrupt handler?), if the reset interrupt is masked, then the system will not 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 as preemptive priority and non-preemptive priority (depending on the microcontroller, the number of bits that can be set for preemptive and non-preemptive priorities can differ and can allocate their respective bits, known as interrupt grouping, for instance, STM32F103 has four bits, and by setting interrupt grouping, the number of bits for preemptive and non-preemptive priorities can be determined).
Preemptive priority means that if the priority of interrupt 1 is higher than that of interrupt 2, once interrupt 1 issues an interrupt request, even if interrupt 2 is currently being executed, 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.
In the case of the same preemptive priority, the non-preemptive priority will begin to take effect.
If interrupt 1 and interrupt 2 have the same preemptive priority but different non-preemptive priorities, when both interrupts simultaneously issue requests, the interrupt with the higher non-preemptive priority will be processed first.
However, if they do not occur simultaneously? Then the interrupt requests will be processed in order, and during the processing of one interrupt, it cannot be interrupted by another interrupt. Also, if a new request comes in for this interrupt, 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 preemptive and non-preemptive priorities are set the same? If both interrupts occur simultaneously, which one should be executed first? Randomly?
This involves hardware priority.
In the above diagram, each interrupt actually has a fixed default priority, which must be different. Therefore, when both preemptive and non-preemptive priorities are the same, the one with the higher default priority will be executed first when both interrupts occur simultaneously.
Look at the diagram:
Speaking of interrupts, one cannot ignore the issue of how to disable interrupts.
In normal operations, we will use global interrupt disable to prevent interrupt handling. Once global interrupts are disabled, all interrupts except non-maskable interrupts will be masked; thus, if an interrupt occurs after disabling interrupts, it will not be executed.
However, once interrupts are enabled, the previously masked interrupts will immediately start executing (there is an interrupt pending bit that indicates the occurrence of an interrupt; only when the CPU executes the interrupt handler and clears the corresponding status bit will this pending bit be cleared).
If two interrupts are sent during the disabling of interrupts, for example, if an external interrupt occurs twice, when interrupts are enabled, only one interrupt will be responded to because the pending bit can only hold one bit (unlike a queue that can hold multiple status bits).
For general functionality, globally disabling interrupts is indeed useful and very effective for protecting global variables, but it can have a certain impact on the entire system.
If the time to disable interrupts is very short, it is indeed inconsequential, but if it needs to be disabled for a longer time (on the order of milliseconds), it becomes a delay that cannot be ignored for those interrupts that need to be processed in a timely manner.
In operating systems, in order to protect global variables, the operation of disabling interrupts often occurs. Is there a method to mask part of the 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 have a priority higher than a specific value.
For example, if this register is set to 3, interrupts with priority levels 0 to 2 will not be masked.
Therefore, in operating systems, we can modify the interrupt disabling code so that it does not mask high-priority interrupts, thus increasing the real-time performance for high-priority interrupts.
uCOS II by default directly disables global interrupts (this can be modified), but FreeRTOS can mask part of the interrupts, using the aforementioned register; of course, this feature requires support from the microcontroller itself.
The above is the content regarding interrupt priorities. If one only knows bare-metal programming, then that’s about it. However, if it’s an operating system, then we need to introduce the concept of task priority.
A task can also be considered a type of interrupt, except that this special interrupt has a lower priority than all hardware-triggered interrupts.
The priority of interrupts overrides all tasks.
In other words, once an interrupt occurs, regardless of which task the CPU is currently executing, under the condition of global interrupts being enabled, it will immediately execute the program inside the interrupt.
In an interrupt, interrupt nesting can occur, which means that the current interrupt is interrupted by another interrupt of higher priority (i.e., preemption). The interrupted interrupt must wait until the high-priority task is completed before it can continue execution. In embedded real-time operating systems, in order to better handle real-time tasks, tasks are generally set to be preemptible (also known as deprivable).
The priority handling of interrupts is managed by the kernel, which refers to the microcontroller kernel; for example, the kernel of STM32F103 is Cortex-M3 (more accurately, it is managed by NVIC).
Once the relevant registers are set, as soon as an interrupt occurs, the interrupt program will be automatically processed. This work is done by hardware, which will choose the highest priority to process when multiple interrupts occur simultaneously; it will also interrupt the current interrupt execution if a higher-priority interrupt occurs while an interrupt is being executed.
But the operating system is purely a software behavior, so who manages the task priority in the operating system? How is it managed?
The answer lies in the Systick interrupt.
Since the operating system needs to manage the priorities of all tasks, that is, to choose 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 are above tasks, able to deprive tasks of execution at any time, thus obtaining CPU usage rights. Therefore, it is appropriate to choose interrupts as the core of the operating system.
However, with so many interrupts, which interrupt is more suitable to choose? There is no interrupt more suitable than the Systick interrupt, as it was born for this purpose.
Systick is essentially a timer, but different from ordinary timers, its function is quite singular; it is just a counter. Therefore, 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. Each time an interrupt occurs, the Systick handler (i.e., the operating system kernel) will select the highest-priority task to execute from all tasks, meaning that the system always runs the highest task.
This feature also leads to the fact that your high-priority task cannot execute indefinitely without actively releasing the CPU, because once a high-priority task runs indefinitely, the low-priority tasks will never have the chance to execute, giving the illusion of a system freeze.
Some readers may wonder why the idle task does not need to call the system delay function to actively release the CPU usage rights?
That’s because the idle task itself has the lowest priority among all tasks. If it actively releases the CPU while other tasks are suspended, then who would 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 find the highest-priority task from all tasks and allow that task to have the opportunity to run (using the PendSV interrupt to switch to tasks, simulating the interrupt switching process), functioning similarly to an interrupt manager.
Because the operating system will only look 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 actively release the CPU.
The most commonly used function to actively release the CPU is the system delay function. After calling this function, the task will delay for a period before continuing execution, and during the delay, the operating system can call other tasks to execute. This is why the operating system appears efficient.
Although the operating system requires interrupts to deprive all tasks of execution, thereby gaining control of the CPU, in general, its priority is the lowest among all interrupts because it only needs to be higher than tasks. If set higher, it may affect the interrupts that truly need to be processed with high priority. Since the processing of the Systick interrupt is quite frequent and heavy, if set too high, lower-priority interrupts will not be processed during Systick processing, which is not the result we want to see.
Setting the interrupt priority to the lowest allows for task execution deprivation and timely processing of high-priority interrupts, thus improving the system’s real-time performance.
Along with Systick, there is another interrupt called PendSV. This priority is generally set the same as Systick. Usually, this interrupt is triggered by the operating system kernel (the interrupt is triggered by software when switching tasks), unlike Systick, which is passively triggered at intervals. For more specific descriptions of these two interrupts, please refer to the “Cortex-M3 Authoritative Guide”.
Since interrupts can be set to the same priority, tasks should also be able to be set the same priority. Indeed, most operating systems allow for tasks with the same priority to be set (uCOS II does not allow this, but uCOS III, FreeRTOS, and RT-Thread do). How does the operating system handle tasks with the same priority?
Generally, during task initialization, a time slice is set for the task. This time slice only takes effect when the task priorities are the same.
For example, if task 1 is set to 5 time slices (i.e., Systick interrupt time), and task 2 is set to 10 time slices, if both tasks have the same priority, then within 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 a higher priority than tasks 1 and 2 be executed? The answer is at any time; as long as a high-priority task is needed, regardless of whether tasks 1 and 2 actively release the CPU, the operating system will forcibly switch to the high-priority task (completed by Systick, so there may be a slight delay).
What about tasks with lower priority than them? That depends on their awareness. If they actively release the CPU (e.g., 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 is needed to illustrate the entire system’s priority relationship:
Finally, Osprey would like to talk about how to set task priorities.
Many people set task priorities in the order of 0, 1, 2, 3; in reality, this setting is unreasonable because once the requirements change later, adding an intermediate priority may cause problems in the program.
In fact, we can take inspiration from the interrupt priorities of Cortex-M3, leaving some unused priority levels for future expansion. For example, when designing priorities, we can set them to 3, 5, 7, 9, 11, leaving the highest priorities 0-2 for possibly high-priority tasks and leaving one or two priority levels blank for expansion. This way, if additional 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).
‧‧‧‧‧‧‧‧‧‧‧‧‧‧‧‧ END ‧‧‧‧‧‧‧‧‧‧‧‧‧‧‧‧
The above content is reprinted from the public account “Osprey Talks Microcontrollers”. Osprey’s public account mainly shares content aimed at readers advancing in software development, covering knowledge including but not limited to C language, KEIL, STM32, 51, etc.
Osprey has compiled a lot of practical content; it is recommended to follow his WeChat public account “Osprey Talks Microcontrollers”, scan the QR code below to follow.
Long press to go to the public account contained in the image to follow