简介
在多任务环境下,多个任务访问同一个资源会面临挑战。若不加防护机制,多个任务或者中断对同一个资源的访问可能会导致错误。
例如,任务A需要在屏幕显示“Hello world”,他向屏缓冲区幕写入字符串"Hello w"时被任务B抢占,任务B也向屏幕写入字符串“Are you OK?”,然后任务B阻塞,任务A继续运行,写入字符串"orld",最终屏幕显示内容为“Hello wAre you OK?orld”。
同样的道理,多个任务访问同一个UART,同一个GPIO口,如果不加互斥保护,都可能会出现错乱。对变量的非原子性操作也是危险操作。原子性操作就是不可再划分的基本操作,那么相反非原子性操作就是对CPU来说需要连续执行多个指令的操作。例如 a++,这条代码编译后形成多条机器指令,它无法被CPU一次执行完毕,如果在执行这多条指令的中间被打断,而被其他任务同时访问,则会出现中间操作丢失或者与期望不一致的错乱情况。
可重入函数与线程安全性
如果一个函数可以被多个任务或者中断在任何时机调用且保证数据安全和逻辑安全,则他就是可重入的函数。可重入函数一定是线程安全的。在FreeRTOS中,每个任务维持他自己的任务栈。一个函数如果只使用自己栈上的变量,则他就是可重入的(且也是线程安全的)。
int ga=1;
//全局变量不存储在栈上,因此F1这个函数是不可重入的
void F1(void)
{
ga += 1;
}
//静态局部变量不存储在栈上,因此F2这个函数也是不可重入的
void F2(void)
{
static int val = 10;
val+=5;
}
同步与互斥的区别
何为同步:两个任务A和B协同完成一件事,任务A要先等任务B执行到某个地步时才能开始执行。 也就是任务A等待任务B创造条件,任务B创造条件后通知任务A继续开始执行,这就是同步。例如餐馆的后厨只有在前台服务员接单后才开始制作菜品。同步可以是两个任务之间,也可以是中断和任务之间。
何为互斥:互斥实际上是属于同步的一种特殊情况。互斥指的是为了防止多任务同时访问一个资源而导致非一致性问题,使用互斥机制使得某种资源一次只允许一个任务使用,即你在使用的时候我不能使用;我在使用的时候你不能使用。这也是一种同步。但是,为了求解实际问题,将同步与互斥加以区别是有好处的,因为这两种问题的求解方法是不同的。
互斥保护机制
前面提到的LCD,UART,全局变量等都属于“一种共享资源”,为了防止多个任务以及中断在同时访问这些资源时,发生不一致(错乱)的情况,必须使用互斥机制来对资源的访问加以保护。其目标是,当一个任务(或中断)访问一个非线程安全的资源时,必须临时对这个资源时独占,直到这个任务(或中断)结束对这个资源的访问。
FreeRTOS提供了一些实现互斥的机制。即便是这样,最好做法是尽量的不要让多个任务(或中断)访问同一个资源,尽量让每个独立的资源只被一个任务或中断访问,好的设计应该遵循这种设计原则。因为临界区会使部分代码受到一定的限制,如果稍不注意,可能出现意想不到的bug。
临界区(Critical Sections)
在FreeRTOS中,被taskENTER_CRITICAL() 和 taskEXIT_CRITICAL()包围的代码就是一个临界区。taskENTER_CRITICAL() 和 taskEXIT_CRITICAL()必须临近且成对出现,尽量临界区中的的代码简短,不要跨函数使用。
taskENTER_CRITICAL();
//在临界区中,调度器和任务切换被暂时停止
//在临界区中,会临时屏蔽一些中断,
//即逻辑优先等于级低于configMAX_SYSCALL_INTERRUPT_PRIORITY对应
//的优先级的中断,高于这个优先级的依然可以触发
taskEXIT_CRITICAL();
taskENTER_CRITICAL() 和 taskEXIT_CRITICAL()只能在任务中使用,不能在中断中使用。对于支持中断嵌套的硬件系统,FreeRTOS也提供了他们有对应的中断安全版本:taskENTER_CRITICAL_FROM_ISR() 和 taskEXIT_CRITICAL_FROM_ISR()。在使用时,taskENTER_CRITICAL_FROM_ISR的返回值必须传递给taskEXIT_CRITICAL_FROM_ISR,如下代码所示:
void vAnInterruptServiceRoutine( void )
{
UBaseType_t uxSavedInterruptStatus;
uxSavedInterruptStatus = taskENTER_CRITICAL_FROM_ISR();
//临界区
//这里只能被优先级高于configMAX_SYSCALL_INTERRUPT_PRIORITY对应的优先级的中断打断
taskEXIT_CRITICAL_FROM_ISR( uxSavedInterruptStatus );
}
FreeRTOS临界区的实现原理
在CM3内核上,FreeRTOS的进入临界区是将configMAX_SYSCALL_INTERRUPT_PRIORITY的值写入到basepri寄存器中来实现的,也就是临时屏蔽了优先级等于低于configMAX_SYSCALL_INTERRUPT_PRIORITY对应的优先级的所有中断来实现的(有些布不支持basepri寄存器的平台,例如CM0内核,则是屏蔽所有中断)。由于系统中断优先级最低,所以临界区也会临时关闭RTOS的任务的切换,因此一个进入到临界区的会任务一直处于运行态,直到退出临界区才可能被调度为其他状态。基于这个原因,我们在开发时,要保持临界区的代码简短迅速,否则它会影响任务和中断的响应实时性。且不能在临界区中调用FreeRTOS的内核函数,因为内核函数的执行依赖调度器运行,而在临界区中调度器是暂停的。
临界区也是可以嵌套的。内核为临界区维护了一个计数器uxCriticalNesting,他记录了临界区的嵌套深度,每次进入临界区会导致计数器变量增1,退出临界区导致计数器变量减1,只有当这个计数器变量为0时,才是完全退出了临界区,才会取消对中断的屏蔽。
有些RTOS,例如RTT,则是通过PRIMASK寄存器关闭所有中断(NMI FAULT 和硬 FAULT 除外)来实现临界区的,相比之下FreeRTOS的方案更加灵活。
//FreeRTOS在CM3内核上进入临界区的实现
void vPortEnterCritical( void )
{
//将configMAX_SYSCALL_INTERRUPT_PRIORITY的值写入到basepri寄存器中
portDISABLE_INTERRUPTS();
uxCriticalNesting++;
if( uxCriticalNesting == 1 )
{
configASSERT( ( portNVIC_INT_CTRL_REG & portVECTACTIVE_MASK ) == 0 );
}
}
//FreeRTOS在CM3内核上退出临界区的实现
void vPortExitCritical( void )
{
configASSERT( uxCriticalNesting );
uxCriticalNesting--;
if( uxCriticalNesting == 0 )
{
//将0写入到basepri寄存器中,取消屏蔽任何中断
portENABLE_INTERRUPTS();
}
}
调度器的挂起和恢复
在FreeRTOS中也可以通过挂起调度器来实现临界区。但是这种方法只是防止其他任务而非中断来同时访问同一个资源,因为在挂起调度器时不会屏蔽任何中断,所有优先级的中断都是可以被触发的。也就是说,如果你保证同样的资源只在不同任务中被访问,而没有在中断中操作,则是可以通过这种方式实现临界区的。不能在临界区中调用FreeRTOS的内核函数,因为内核函数的执行依赖调度器运行。
//挂起调度器,可以防止任务切换。但依然允许所有的中断运行。
//当在调度器挂起时,触发的中断中有请求任务切换的代码时,
//这个请求是会被保存但不会立刻执行的,只有当调度器恢复时才会被执行。
void vTaskSuspendAll( void );
//恢复调度器。如果在调度器挂起期间,有其他地方执行的任务切换请求,
//则这函数返回pdTRUE,否则返回pdFALSE。
BaseType_t xTaskResumeAll( void );
vTaskSuspendAll() 和xTaskResumeAll()也是可以嵌套调用的,通用内核也是在内部维护一个嵌套深度计数器。只有当调用xTaskResumeAll使得嵌套深度计数器为0时,调度器才会真正被恢复。
互斥锁(Mutexes)
我们可以发现,临界区实现资源互斥保护比较粗暴,它直接屏蔽了整个应用程序中除了本任务外剩余的所有任务和一些中断。但是很多时候我们只是在一组任务之间共同访问一个资源。例如在代码设计时,只有任务A,B,C这三个任务共同访问串口,那么当其中A任务占用串口时,只需要防止B和C同时访问串口就行了,其他的任务和中断则不用管,因为它们根本就没有访问串口的代码。
在FreeRTOS中,我们可以使用互斥锁来实现多个任务之间互斥使用同一个资源的安全操作。为了使用互斥锁,必须在FreeRTOSConfig.h设置configUSE_MUTEXES为1。请注意,在中断服务程序中不能使用任何类型的互斥锁。
用于互斥时, 互斥锁就像用于保护资源的令牌。 任务希望访问资源时,必须首先 获取 ('take') 令牌,任务只有在获取到令牌后才能访问资源。 使用资源后,必须“返回,释放”令牌,这样其他任务就有机会访问 相同的资源。
FreeRTOS中的互斥体本质是一种特殊的二进制信号量,他区别于普通的二进制信号量特殊在于它拥有优先级继承机制(后面会介绍什么是优先级继承机制以及为什么需要优先级继承机制)。
创建互斥锁
返回值:如果返回NULL,代表因为因为没有足够的堆内存而创建失败。否则返回互斥锁的句柄。
SemaphoreHandle_t xSemaphoreCreateMutex( void );
获取和释放互斥锁
调用xSemaphoreTake函数来尝试拿到互斥锁。如果指定了一个确定的最大阻塞时间,则需要判断xSemaphoreTake函数的返回值,只有返回pdTRUE时才代表成功拿到互斥锁。由于这里演示用的是portMAX_DELAY ,也就是只有成功拿到才会返回,否则一直阻塞,所以无需判断返回值。尝试拿互斥锁时,设置等待时间为无限长是非常糟糕的设计,应该设置一个确定超时时间。
使用xSemaphoreGive函数来释放一个互斥锁。在使用完资源后必须释放互斥锁。
xSemaphoreTake( xMutex, portMAX_DELAY ); //获取互斥锁
//使用互斥资源
xSemaphoreGive( xMutex ); //释放互斥锁
优先级反转问题
使用互斥体会遇到一种不好的情形。假设任务X的优先级为X,例如任务5优先级为5,任务4优先级为4。任务5和任务1使用了互斥体来访问一个共享资源,此外应用程序中还存在任务4、任务3和任务2。某一时刻任务1拿到了互斥锁,并开始使用资源,过程中被任务2抢占了(因为任务2的优先级高于任务1),则任务1进入到阻塞状态,但它依然拿着互斥锁,导致任务5继续等待无法运行。更夸张的情况,任务3继续抢占了任务2,任务4继续抢占了任务3...这样的结果是高优先级的任务5反而必须长时间等待低优先级的任务2、3、4执行,这就是优先级反转问题。
1:LP任务拿到了互斥体(在他被HP任务抢占前)
2:HP任务优先级高,所以他抢占了LP,去尝试拿互斥体,但是拿不到,于是HP任务进入阻塞等待状态
3:LP任务恢复执行,但是在他释放互斥体前,被中优先级比他高的MP任务给抢占了
4:LP阻塞,MP任务得到执行。此时HP任务依然阻塞等待LP释放互斥体,但是LP任务此时却根本无法执行
优先级继承解决方案
FreeRTOS的互斥体和二进制信号量非常相似,但是他们有根本的差异,就在于互斥体是带有优先级继承机制的,而二进制信号不具有。优先级继承机制是用于尽可能降低优先级反转问题带来的负面影响的一种手段,它无法完全解决优先级反转问题。
优先级继承的工作原理是将当前持有互斥体的任务的优先级临时提高到和尝试拿到同一个互斥体的且优先级最高的任务一样高。低优先级的持有互斥体的任务释放了互斥体后,其优先级又会恢复到它本身的优先级。也就是说,持有互斥体的任务继承了尝试拿到同一个互斥体的任务的最高优先级。
1:LP任务拿到了互斥锁(在他被HP任务抢占前)
2:HP任务优先级高,所以他抢占了LP,去尝试拿互斥体,但是拿不到,于是HP任务进入阻塞等待状态
3:LP任务继承了HP任务的优先级,因此LP任务无法被HP任务抢占,它可以继续运行,等到它释放互斥锁后,LP任务的优先级恢复到之前的优先级
4:LP任务释放了互斥锁,使得HP任务顺利拿到互斥锁并退出阻塞态,开始运行,最后释放互斥锁。当HP任务再次进入到阻塞态后,MP任务才得以运行
正如刚才所见,优先级继承功能会影响使用互斥锁的任务的优先级。因此,在中断服务程序中不能使用任何互斥锁,原因是:
①互斥锁使用的优先级继承机制要求 从任务中(而不是从中断中)拿走和释放互斥锁
②中断无法保持阻塞来等待一个被互斥锁保护的资源 变得可用
死锁问题
死锁是使用互斥体的另一种可能遭遇的严重问题。当两个任务都因为等待对方持有的资源而阻塞时,就是死锁。死锁问题需要设计代码时要有精细的考量才能尽可能避免。
一个例子:假设任务A和任务B都需要拿到互斥体X和互斥体Y。
1:任务A拿到了互斥体X
2:任务A被任务B抢占
3:任务B在尝试拿互斥体X前已经拿到了互斥体Y,现在它尝试拿X,但是X被A拿到了,所以他只能进入阻塞态等待X被释放
4:任务A继续运行,并尝试去拿互斥体Y,但是Y被B拿到了,所以他只能进入阻塞态等待Y被释放
5:任务A和任务B都等待对方持有的互斥体而无法继续向前运行,这就是死锁
递归互斥锁(Recursive Mutexes)
一个可能任务会出现在已经拿到一个互斥锁不释放的情况下,再继续尝试拿这个互斥锁,这就会导致任务被自己锁死了。例如,某个任务已经拿到了一个互斥锁,他继续调用了一个函数,而这个函数内部也尝试拿同一个互斥锁。如果出现这种情况,我们可以使用递归互斥锁而非常规互斥锁来避免这个问题。请注意,在中断服务程序中不能使用任何类型的互斥锁。
为了使用递归互斥锁,需在FreeRTOSConfig.h中配置configUSE_RECURSIVE_MUTEXES为1.
一个任务可以对一把递归互斥锁重复加锁。只有任务 为每个成功的 xSemaphoreTakeRecursive() 请求调用 xSemaphoreGiveRecursive() 后,互斥锁才会重新变为可用。例如,如果一个任务成功“加锁”相同的递归互斥锁 5 次, 那么任何其他任务都无法使用此互斥锁,直到该任务也把这个互斥锁“解锁”5 次,才是真正释放了这个递归互斥锁,此时其他任务才有拿到这个递归互斥锁的机会。
递归互斥锁内部为持有锁的任务维护了一个计数器变量,任务首次成功获取到这个递归互斥锁时,计数器为1,后续继续重复获取总是会成功,并简单的将计数器增1,而任务释放递归互斥锁则是简单的将计数器减1,直到最后一次计数器为0时,才真正释放递归互斥锁。
//创建递归互斥锁,这个函数与创建普通互斥锁的函数的原型相同
xSemaphoreCreateRecursiveMutex()
//尝试获取一个递归互斥锁,这个函数与获取普通互斥锁的函数的原型相同
xSemaphoreTakeRecursive()
//释放递归互斥锁,这个函数与释放普通互斥锁的函数的原型相同
xSemaphoreGiveRecursive()
门卫任务(Gatekeeper Tasks)
门卫任务提供了一种优雅的解决多任务环境下资源访问互斥的方案。它不会遇到“死锁”以及优先级反转问题。当我们有多个任务或者中断访问同一个资源的需求时,应该优先使用门卫任务而非临界区、互斥锁。
门卫任务对资源具有唯一使用权,只有门卫任务才能直接使用资源。其他任务只能发送请求给门卫任务来委托它间接使用的使用资源。门卫任务对资源具有唯一使用权,所以无需做互斥操作。
例如我们在应用程序中需要使用串口打印日志,那么让一个任务A作为串口这个硬件资源的门卫任务,其他任务如果需要输出,则将需要输出的数据发送到门卫任务管理的队列中。门卫任务不断等待队列中的数据到了,如果有数据,则取出并通过串口输出。
Tick回调
每次tick中断时,内核会在SysTick中断中调用用户实现的tick回调函数。用户实现的tick回调函数必须足够简单快速,尽可能少的栈内存,且只能使用以"FromISR"结尾的内核函数,因为tick回调函数是在SysTick中断环境下执行的。每次tick回调函数执行完成后,调度器会立刻执行,因此在tick回调函数中使用以"FromISR"结尾的内核函数时无需使用pxHigherPriorityTaskWoken 参数,总是传递NULL即可。
为了使用tick回调函数:
1. 在 FreeRTOSConfig.h中将 configUSE_TICK_HOOK 定义为1
2. 实现tick回调函数
//tick回调函数原型
void vApplicationTickHook( void );
void vApplicationTickHook( void )
{
static int iCount = 0;
iCount++;
if( iCount >= 200 )
{
//第三个参数pxHigherPriorityTaskWoken 使用NULL即可
xQueueSendToFrontFromISR( xPrintQueue,&( pcStringsToPrint[ 2 ] ),NULL );
iCount = 0;
}
}
总结
多个任务或者中断访问同一个资源需要加互斥保护,互斥保护优先使用门卫任务实现,尽量的不要让多个任务(或中断)访问同一个资源,尽量让每个独立的资源只被一个任务或中断访问,这样可以降低复杂性,毕竟临界区和互斥锁使用起来的限制很多,容易出差,好的设计应该遵循这种设计原则
临界区会暂时关闭调度器和一部分中断,我们在开发时,要保持临界区的代码简短迅速,否则它会影响任务和中断的响应实时性
不能在临界区中调用FreeRTOS的内核函数,因为内核函数的执行依赖调度器运行,而在临界区中调度器是暂停的
互斥锁的代价比临界区低,相比临界区,优先使用互斥锁
尽量避免在任务循环中频繁获取/释放互斥锁,因为可能会导致其他任务根本拿不到互斥锁。尽量在释放互斥锁后加一定的延时再尝试获取互斥锁
不能在中断中使用任何类型的互斥锁,互斥锁使用的优先级继承机制要求 从任务中(而不是从中断中)拿走和释放互斥锁,况且中断无法保持阻塞来等待一个被互斥锁保护的资源 变得可用