临界区
在介绍进程队列和进程调度时,我们曾简单的接触了一下临界区。 在进程调度函数xtos_schedule()的开始和结尾使用"__asm"成对地插入了两句汇编,分别用于关中断和开中断:
void xtos_schedule(void) {
__asm("CPSID I");
list_add_tail(&gp_xtos_next_task->list, &L0_tasks);
gp_xtos_next_task = list_first_entry(&L0_tasks, struct xtos_task_descriptor, list);
list_del(&gp_xtos_next_task->list);
xtos_context_switch();
__asm("CPSIE I");
}
关于临界区的描述不同的参考书里面的描述都多少有些不同,这里我认为关闭中断时系统就进入了临界区,虽然这种定义方式有点狭隘。
临界区存在的意义是保护公共资源,保证同一时间只有一个进程访问。比如说这里进程调度函数xtos_schedule()函数, 它在运行过程中要访问和修改系统的进程队列L0_tasks, 下次切换目标进程描述符指针gp_xtos_next_task。如果进程A主动触发进行切换进程, 在调度函数的执行过程中,有可能触发某些中断条件。如果没有关闭中断,假设此时系统时钟systick计时溢出就会通过中断服务函数再次进入调度函数。 这将导致系统的公共资源L0_tasks, gp_xtos_next_task被篡改,进而导致系统崩溃。
如果我们关闭了中断,那么xtos_schedule函数将不可能被打断,公共资源也就不可能因为进程之间的竞争关系而产生不可预期的问题。 在完成了对公共资源的访问后,应当及时的退出临界区。临界区的使用将延迟整个系统对中断的响应,这对于实时的操作系统而言是一个很重要的问题, 所以我们应当尽量减少系统运行临界区的时间。
1. 临界区的问题
虽然简单地嵌入两条汇编语句,我们就可以实现进入和退出临界区的操作,但这样是不严谨的。比如下面示例的两个函数f1()和f2()。 这两个函数都因为要访问一些公共资源而在函数开始和结束的地方成对的嵌套了语句__asm("CPSID I")和__asm("CPSIE I")。 而函数f2()在临界区中调用了函数f1()。
void f1(void) {
__asm("CPSID I");
... ...
__asm("CPSIE I");
}
void f2(void) {
__asm("CPSID I");
... ...
f1();
... ...
__asm("CPSIE I");
}
此时就会产生一个问题,函数f2()以为它的整个执行过程中都是在临界区的。而事实上,从函数f1()执行完毕返回的那一刻开始,
整个系统就已经退出了临界区,这对于f2()而言是不应该发生的事情。
2. μCOS的临界区
在μCOS中一共提供了三种进入和退出临界区的方法,通过宏定义OS_ENTER_CRITICAL()和OS_EXIT_CRITICAL()实现。 它的第一种方法跟我们之前的实现是一样的,只是简单的关闭和打开中断而已,其问题也很明显了就是不能嵌套使用。 μCOS之所以还要提供这种方法,是因为在一些处理器或者编译器环境下,这是唯一的方案。而另外两种方法是支持嵌套使用的。
第二种方法是先把当前的中断状态入栈后再关闭中断的,退出时直接把原来的中断状态出栈。类似如下的实现,这里直接引用μCOS的手册。
#define OS_ENTER_CRITICAL() \
asm(" PUSH PSW") \
asm(" DI")
#define OS_EXIT_CRITICAL() \
asm(" POP PSW")
这种实现方式可以保证在每次退出临界区时都能够恢复到进入时的中断状态,这样就可以嵌套了。但有点麻烦的是汇编语句的嵌套,
不同的编译器实现方式是不一样的,有可能不能获知堆栈指针因入栈操作而发生了改变,进而使得使用堆栈指针对局部变量的访问出错。
第三种实现方式就比较灵活一些,它把中断状态相关的寄存器保存在一个变量里,这样就不需要关心栈指针的问题了。 μCOS手册中伪代码如下,其中函数get_processor_psw()获取中断状态并将之保存在变量cpu_sr中,然后关闭中断进入临界区。 退出临界区时通过函数set_processor_psw()把变量cpu_sr中保存的状态再写回去,恢复进入临界区之前的状态。
void Some_uCOS_II_Service(arguments)
{
OS_CPU_SR cpu_sr;
.
cpu_sr = get_processor_psw();
disable_interrupts();
.
/* critical section code */
.
set_processor_psw(cpu_sr);
.
}
这段伪代码很好理解,使用起来也还算方便,不过看了μCOS在STM32上的实现,就显得有些别扭。它为了兼容前两种方法,
定义了OS_ENTER_CRITICAL和OS_EXTI_CRITICAL两个宏,如下面的代码所示。使用的时候就必须像下面的some_task()函数那样,
先定义一个叫做cpu_sr的变量。这个变量看起来好像没有在什么地方使用过,实际上在两个宏定义的替换内容中使用的。
这很容易给人误解,而且使用什么变量名称我们也不能把控。所以并不是一个很好的实现方式。
#define OS_CRITICAL_METHOD 3
#if OS_CRITICAL_METHOD == 3
#define OS_ENTER_CRITICAL() {cpu_sr = OS_CPU_SR_Save();}
#define OS_EXIT_CRITICAL() {OS_CPU_SR_Restore(cpu_sr);}
#endif
void some_task() {
#if OS_CRITICAL_METHOD == 3
OS_CPU_SR cpu_sr = 0;
#endif
OS_ENTER_CRITICAL();
...
OS_EXTI_CRITICAL();
}
3. 改进后的临界区出入口
通过对我们的和μCOS的临界区实现方式的分析,我认为μCOS的方式三是一个比较好的方案,但是它在STM32上的实现方式有点蛋疼。 所以,这里我要对它做一些修改满足我的使用习惯。
首先,我们用汇编语句编写函数xtos_lock()。事实上这个函数是有返回值的,它在一开始就把中断屏蔽寄存器PRIMASK的值保存到了R0中。 在函数运行结束时,寄存器R0中的值将作为return的数值返回。 PRIMASK只有最低位有定义, 用于打开或者屏蔽除NMI(Non-Maskable Interrupt,不可屏蔽中断)以及硬件错误异常(HardFault Exception)外的所有中断。
xtos_lock
MRS R0, PRIMASK ; 保存中断屏蔽寄存器PRIMASK到R0中
CPSID I ; 关闭中断
BX LR
函数xtos_lock()应当和xtos_unlock(int key)一起成对的使用。xtos_unlock用于退出系统的临界区,它需要一个参数key, 其值应当是我们进入临界区时保存的PRIMASK,即xtos_lock函数的返回值。
xtos_unlock
MSR PRIMASK, R0 ; 把R0的值写回到PRIMASK恢复进入临界区前的状态
BX LR
这两个函数的成对使用就好像是上锁和开锁的过程。通过函数xtos_lock对系统上锁,其他进程或者中断将不再能够进入临界区访问公共资源。
退出临界区时需要一把钥匙开锁,恢复系统上锁前的状态。我们在进程调度函数可以以如下的方式进入和退出临界区:
void xtos_schedule(void) {
int primask = xtos_lock();
list_add_tail(&gp_xtos_next_task->list, &L0_tasks);
gp_xtos_next_task = list_first_entry(&L0_tasks, struct xtos_task_descriptor, list);
list_del(&gp_xtos_next_task->list);
xtos_context_switch();
xtos_unlock(primask);
}
4. 总结
在写系统进程时,需要时刻注意着多个进程对公共资源的并发访问问题。临界区是保护公共资源的一种方式, 我们通过关闭系统中断来实现临界区,进而保证了临界区中的代码能够完整的执行,也就是一些参考书中所说的原子性。
以前我们简单的通过关闭和打开系统中断来进入和退出临界区。这种实现方式不支持嵌套的使用方式。 μCOS的提供了三种实现方式,这三种实现方式都或多或少有些问题。我们最终对其第三种实现方式做了简单的修改, 用汇编创建了xtos_lock和xtos_unlock两个成对使用的函数。 自我感觉很好,示例代码下载。