测试环境如下
stm32F103C8T6
MDK keil5
stm32cube + FreeRTOS
概述
在多任务处理系统中,如果一个任务开始访问资源,但在脱离运行状态之前没有完成其访问,则有可能出错。 如果任务使资源处于不一致状态,则通过任何其他任务或中断访问同一资源可能导致数据损坏或其他类似问题
以下是一些例子:
案例一:
- 访问外设考虑以下场景,其中两个任务试图写入液晶显示器(LCD)。
- 任务A执行并开始将字符串“HelloWorld”写入LCD
- 任务A在输出字符串的开头-“Hello w”之后被任务B抢占”。
- 任务B在进入阻塞状态之前将“中止、重试、失败?”写入LCD。
- 任务A从它被继续,并完成输出其字符串的剩余字符-“orld”。
液晶显示器现在显示损坏的字符串“Hellow中止,重试,失败?orld”。
案例二:
- 在此场景中,任务A更新并写回PORTA的过时值。 任务A获取PORTA值的副本后,任务B修改PORTA,在任务A将其修改后的值写入PORTA寄存器之前。 当任务A写入PORTA时,它会覆盖任务B已经执行的修改,从而有效地损坏PORTA寄存器值。
- 此示例使用外围寄存器,但在对变量执行读、修改、写操作时,同样的原则也适用。
案例三:对变量的非原子访问
更新结构的多个成员,或更新大于体系结构的自然字大小的变量(例如,在16位机器上更新32位变量),都是非原子操作的例子。 如果它们被中断,它们可能导致数据丢失或损坏
案例四:
- 功能恢复
如果从多个任务或从两个任务和中断调用函数是安全的,则函数是“可重入的。 返回函数被称为“线程安全”,因为它们可以从多个执行线程访问,而不会有数据或逻辑操作损坏的风险。
每个任务维护自己的堆栈和自己的一组处理器(硬件)寄存器值。 如果函数不访问存储在堆栈上或保存在寄存器中的数据以外的任何数据,则函数是可重入的,线程是安全的。
清单112是一个可重入函数的示例。
清单113是一个不能重入的函数的示例。
long lAddOneHundred( long lVar1 )
{
/* This function scope variable will also be allocated to the stack or a register, depending on the compiler and optimization level. Each task or interrupt that calls this function will have its own copy of lVar2. */
long lVar2;lVar2 = lVar1 + 100;return lVar2;
}
long lVar1;
long lNonsenseFunction( void )
{
/* lState is static, so is not allocated on the stack. Each task that calls this function will access the same single copy of the variable. */
static long lState = 0;
long lReturn;switch( lState ){
case 0 : lReturn = lVar1 + 10;lState = 1;break;case 1 : lReturn = lVar1 + 20;lState = 0;break;} }
互斥确保数据一致性在任何时候都保持,对任务之间或任务与中断之间共享的资源的访问必须使用**“互斥”技术进行管理**。 目标是确保一旦一个任务开始访问一个不可重入且不线程安全的共享资源,同一任务就具有对资源的独占访问权限,直到资源返回到一致状态为止
FreeRTOS提供了几个可以用来实现互斥的特性,但是最好的互斥方法是(只要有可能,因为它不实用)设计应用程序,使资源不被共享,并且每个资源只从一个任务访问。
本章旨在给读者一个很好的理解
- 软件定时器的特性与任务的特性相比。
- 何时以及为什么资源管理和控制是必要的。
- 多么关键的一节
- 相互排斥意味着什么。
- 暂停调度程序意味着什么
- 如何使用互斥体
- 如何创建和使用把关任务
- 什么是优先级反转,以及优先级继承如何减少(但不移除)其影响。
关键代码不长,占用不长 (临界区方法)
基本的关键部分是代码的区域,它们分别被对宏
- taskENTER_CRITICAL()
- taskEXIT_CRITICAL()
的调用所包围。 临界区也称为临界区。
案例如下:
void vPrintString( const char *pcString )
{
/* Write the string to stdout, using a critical section as a crude method of mutual exclusion. */taskENTER_CRITICAL();{
printf( "%s", pcString );fflush( stdout );}taskEXIT_CRITICAL();
}
以这种方式实施的关键部分是提供相互排斥的非常粗糙的方法。 它们通过禁用中断来工作,或者完全禁用中断,或者直到使用的自由RTOS端口上的configMAX_SYSCALL_INTERRUPT_PRIORITY—depending设置的中断优先级。 先发制人的上下文切换只能从中断内部发生,因此,只要中断仍然被禁用,调用taskENTER_CRITICAL()的任务就保证保持在运行状态,直到关键部分退出为止
基本的关键部分必须保持很短,否则会对中断响应时间产生不利影响。 对taskENTER_CRITICAL()的每一次呼叫都必须与对taskEXIT_CRITICALtaskENTER_CRITICAL()的呼叫密切配合。 因此,不应该使用关键部分(如清单115所示)来保护标准输出(stdout,或者计算机写入输出数据的流),因为写入终端可能是一个相对较长的操作。
在中断回调函数中的使用:
taskENTER_CRITICAL_FROM_ISR()返回必须传递给taskEXIT_CRITICAL_FROM_ISR()的匹配调用的值。 如清单116所示。
void vAnInterruptServiceRoutine( void )
{
UBaseType_t uxSavedInterruptStatus;uxSavedInterruptStatus = taskENTER_CRITICAL_FROM_ISR();taskEXIT_CRITICAL_FROM_ISR( uxSavedInterruptStatus );
}
使用更多的处理时间来执行进入并随后退出关键部分的代码是浪费的,而不是执行实际受到关键部分保护的代码。 基本的关键部分进入速度非常快,退出速度非常快,而且总是确定性的,当代码被保护的区域非常短时,它们的使用就很理想
关键代码较长(关闭调度方法)
关键部分也可以通过暂停调度程序来创建。 暂停调度程序有时也被称为“锁定”调度程序。
基本关键部分保护代码区域 不被其他任务和中断访问。 通过暂停调度程序实现的关键部分只保护代码区域不被其他任务访问,因为中断仍然启用。
一个关键部分太长,不能通过简单地禁用中断来实现,相反,可以通过暂停调度程序来实现。 然而,当调度程序被暂停时,中断活动可以使调度程序恢复(或“不暂停”)是一个相对较长的操作,因此必须考虑哪种方法是在每种情况下使用的最佳方法。
通过调用 vTaskSuspendAll()来暂停调度程序。 暂停调度程序可以防止上下文切换的发生,但允许中断。 如果中断在调度程序暂停时请求上下文切换,则请求将被挂起,并且只有在调度程序恢复时才执行(未暂停)。 当调度程序暂停时,不能调用免费的RTOS API函数。
案例:
void vPrintString( const char *pcString )
{
/* Write the string to stdout, suspending the scheduler as a method of mutualexclusion. */vTaskSuspendScheduler();{
printf( "%s", pcString );fflush( stdout );}xTaskResumeScheduler();
}
互斥体(和二进制信号量)
本章的例子探讨了替代解决方案!!!
互斥信号是一种特殊类型的二进制信号量,用于控制对两个或多个任务之间共享的资源的访问。
当在互斥场景中使用时,互斥对象可以被认为是与共享资源相关联的令牌。 要使任务合法地访问资源,它必须首先成功地“获取”令牌(成为令牌持有者)。 当令牌持有人完成了资源时,它必须“将令牌归还。 只有当令牌已返回时,另一个任务才能成功地获取令牌,然后安全地访问相同的共享资源。 任务不允许访问共享资源,除非它持有令牌。
尽管互斥体和二进制信号量具有许多特性,但图63所示的场景(其中互斥体用于互斥)与图53所示的场景(其中二进制信号量用于同步)完全不同)。 主要的区别是信号量在得到后会发生什么:
- 用于互斥的信号量必须始终返回
- 用于同步的信号量通常被丢弃而不返回
整体流程如下:
每个任务都希望访问资源,但不允许任务访问资源,除非它是互斥(令牌)持有者。
任务A试图接受互斥体。 由于互斥对象是可用的,任务A成功地成为互斥对象,因此允许访问资源。
任务B执行并尝试使用相同的互斥量。 任务A仍然具有互斥体,因此尝试失败,任务B不允许访问被保护的资源
任务B选择进入阻塞状态等待互斥-允许任务A再次运行。 任务A完成了资源,所以“给出”互斥对象。
将互斥对象返回的任务A导致任务B退出阻塞状态(互斥对象现在可用)。 任务B现在可以成功地获得互斥对象,并且在这样做之后允许访问资源
当任务B完成访问资源时,它也会将互斥对象返回。 互斥对象现在再次可用于两个任务。
xSemaphoreCreateMutex()
参数说明:略
SemaphoreHandle_t xSemaphoreCreateMutex( void );
案例一
osThreadId TaskLed1Handle;
osThreadId TaskLed2Handle;
osMutexId xMutexHandle;
osSemaphoreId myCountingSem01Handle;osThreadDef(TaskLed1, TaskLed1Func, osPriorityLow, 0, 128);TaskLed1Handle = osThreadCreate(osThread(TaskLed1), "Task 1 ***************************************\r\n");/* definition and creation of TaskLed2 */osThreadDef(TaskLed2, TaskLed1Func, osPriorityBelowNormal, 0, 128);TaskLed2Handle = osThreadCreate(osThread(TaskLed2), "Task 2 ---------------------------------------\r\n");
任务如下:
static void prvNewPrintString( const char *pcString )
{
xSemaphoreTake( xMutexHandle, portMAX_DELAY );{
printf( "%s", pcString );}xSemaphoreGive( xMutexHandle );
}
/*** @brief Function implementing the TaskLed1 thread.* @param argument: Not used * @retval None*/
/* USER CODE END Header_TaskLed1Func */
void TaskLed1Func(void const * argument)
{
/* USER CODE BEGIN TaskLed1Func */char*pcStringToPrint = (char *)argument;const TickType_t xMaxBlockTimeTicks = 0x20;/* Infinite loop */for(;;){
prvNewPrintString(pcStringToPrint);
// vTaskDelay( ( rand() % xMaxBlockTimeTicks ) );vTaskDelay(1000u);/* USER CODE END TaskLed1Func */}
}
结果:
互斥信号量-潜在陷阱之一优先级被翻转
一。 所描述的执行顺序显示,较高优先级的任务2必须等待较低优先级的任务1放弃互斥对象的控制。 以这种方式被较低优先级任务延迟的较高优先级任务称为“优先级反转’。 如果中等优先级任务开始执行,而高优先级任务正在等待信号量,则这种不受欢迎的行为将被进一步夸大-结果将是等待低优先级任务的高优先级任务-即使无法执行低优先级任务。 这个最坏的情况如图66所示。
说白就是,中等任务可能会打断低等任务,导致高等任务一直处于阻塞状态,
优先级倒置可能是一个重要的问题,但在小型嵌入式系统中,通过考虑如何访问资源,通常可以在系统设计时避免。
优先权继承性(低优先级任务继承高优先级任务的优先级)
FreeRTOSS互斥量和二进制信号量非常相似-不同的是互斥量包括一个基本的“优先级继承”机制,而二进制信号量则不包括。 优先级继承是一种最小化优先级反转负面影响的方案。 它不是“修复”优先级反转,而是通过确保反转总是有时间限制来减轻其影响。 然而,优先级继承使系统时序分析复杂化,依赖它进行正确的系统操作是不好的做法
优先级继承通过将互斥对象的优先级暂时提高到试图获得相同互斥对象的最高优先级任务的优先级来工作。 持有互斥对象的低优先级任务“继承”等待互斥对象的任务的优先级。 如图67所示。 当互斥对象返回时,互斥对象的优先级将自动重置为其原始值。
返回互斥对象的LP任务导致HP任务作为互斥对象退出阻塞状态。 当HP任务完成互斥对象时,它会将其返回。 只有当HP任务返回到阻塞状态时,MP任务才会执行,因此MP任务永远不会占用HP任务。
正如刚才所看到的,优先级继承功能影响使用互斥对象的任务优先级。 因此,互斥对象不能从中断服务例程中使用。
死锁(或致命的拥抱)!!!
死锁”是使用互斥对象进行相互排斥的另一个潜在陷阱。 死锁有时也被更戏剧化的名字“致命的拥抱”所知’。 当两个任务都在等待另一个任务所持有的资源而无法进行时,就会发生死锁。 考虑以下场景,任务A和任务B都需要获取互斥体X和互斥体Y才能执行操作:
- 任务A执行并成功获取互斥X。
- 任务A被任务B抢占任务,任务A阻塞。
- 任务B成功地使用互斥量Y,然后尝试也使用互斥量X-但是互斥量X是由任务A持有的,所以任务B不可用。 任务B选择进入阻塞状态等待互斥X被释放
- 任务A继续执行。 它试图使用互斥体Y-但是互斥体Y是由任务B持有的,所以任务A无法使用。 任务A选择进入阻塞状态等待互斥量Y被释放
额。。。这样导致两个任务都被锁住了
在此场景结束时,任务A等待任务B持有的互斥体,而任务B等待任务A持有的互斥体。 已发生死锁,因为两个任务都无法继续。
与优先级反转一样,避免死锁的最佳方法是在设计时考虑其潜力,并设计系统以确保死锁不会发生。 特别是,正如本书前面所述,任务无限期等待(没有超时)以获得互斥对象通常是不好的做法。 相反,使用一个比预期的最长时间稍长的超时时间来等待互斥-那么在该时间内无法获得互斥将是设计错误的一个症状,这可能是死锁。
在实践中,死锁在小型嵌入式系统中不是一个大问题,因为系统设计人员可以对整个应用程序有很好的理解,因此可以识别和删除可能发生死锁的区域
递归互斥
任务本身也有可能陷入僵局。 如果一个任务尝试多次使用相同的互斥对象,而不首先返回互斥对象,则会发生这种情况
- 任务A成功获得互斥
- 在保存互斥对象时,任务调用库函数。
- 库函数的实现尝试取相同的互斥量,进入Blocked状态等待互斥量变为可用状态
在此场景结束时,任务处于阻塞状态,等待互斥对象返回,但任务已经是互斥对象。 出现死锁是因为任务处于阻塞状态等待自己
可以通过使用递归互斥来代替标准互斥来避免这种类型的死锁。 一个递归互斥对象可以被同一任务“接受”不止一次,并且只有在一个调用“给予”之后才会返回,对于之前的每一个调用“接受”递归互斥对象,递归互斥对象都已经被执行
标准互斥体和递归互斥体的创建和使用方式类似:
- 标准互斥体是使用xSemaphoreCreateMutex()创建的。 递归互斥是使用xSemaphoreCreateRecursiveMutex()创建的。 两个API函数具有相同的原型。
- 标准互斥量是“采取”使用 xSemaphoreTake()。 递归互斥是“采取”使用xSemaphoreTakeRecursive()。 两个API函数具有相同的原型
- 标准互斥体是“给定”使用 xSemaphoreGive()。 递归互斥是“给定”使用xSemaphoreGiveRecursive()。 两个API函数具有相同的原型。
互斥对象和任务调度
优先级不同:
如果两个优先级不同的任务使用相同的互斥量,则FreeRTOS调度策略使任务执行的顺序明确;能够运行的最高优先级任务将被选择为进入运行状态的任务。 例如,如果高优先级任务处于阻塞状态以等待由低优先级任务持有的互斥对象,那么当低优先级任务返回互斥对象时,高优先级任务将预先排除低优先级任务。 然后,高优先级任务将成为互斥对象持有者。 这个场景已经在图67中看到
说白了就是高优先级会先执行!!!
相同优先级时:
对任务执行的顺序作出不正确的假设是很常见的。 如果任务1和任务2具有相同的优先级,并且任务1处于阻塞状态以等待由任务2持有的互斥体,那么当任务2‘给出’互斥体时,任务1将不会先发制人。 相反,任务2将保持在运行状态,任务1将简单地从阻塞状态移动到就绪状态。 此场景如图68所示,其中垂直线标记发生滴答中断的时间
说白了就是优先级会先执行!!!
- -任务2执行一个时间片,在此期间它“接受”互斥
- 任务1在下一次切片开始执行
- -任务1试图“获取”任务2持有的互斥体,并进入阻塞状态,等待互斥体可用
- -任务2执行剩余的时间片,由于任务1被阻塞,保持在运行状态进入下一个时间片
- 5-任务2‘给出’互斥,解除阻塞任务1
- 6-任务1直到下一次切片开始时才重新进入运行状态
68所示的场景中,Free RTOS调度程序不会使任务1在互斥对象可用时立即成为运行状态任务 因为一下原因:
任务1和任务2具有相同的优先级,因此除非任务2进入阻塞状态,否则直到下一个滴答中断(假设configUSE_TIME_SLICING在FreeRTOS Config.h中设置为1),才会发生切换到任务1的情况)。
如果任务在紧密循环中使用互斥对象,并且每次任务“给出”互斥对象时都会发生上下文切换,那么任务只会在短时间内保持运行状态。 如果两个或多个任务在紧密循环中使用相同的互斥体,那么通过在任务之间快速切换来浪费处理时间。
如果一个互斥对象在一个紧循环中被多个任务使用,并且使用互斥对象的任务具有相同的优先级,那么必须注意确保任务获得大约相等的处理时间。 图69演示了任务可能不能获得相同处理时间的原因,图69显示了如果以相同的优先级创建清单125所示任务的两个实例,则可能发生的执行序列。
单125中的注释指出,创建字符串是一个快速操作,更新显示是一个缓慢操作。 因此,当互斥对象在更新显示器时被保持时,任务将在其大部分运行时间内保持互斥对象
- 1-任务2执行一个时间片,在此期间它“接受”互斥
- 任务1从下一次切片开始执行
- 3-任务1试图“获取”任务2所持有的互斥体,并进入阻塞状态以等待互斥体可用
- 4-任务2执行剩余的时间片,由于任务1被阻塞,保持在运行状态进入下一个时间片
- 5-任务2‘给出’互斥,解除阻塞任务1
- 任务2再次“接受”互斥
- 7-任务1在下一次切片开始时开始执行,尝试“获取”任务2所持有的互斥体,并再次进入阻塞状态以等待互斥体可用
任务1将被阻止获得互斥对象,直到时间片的开始与任务2不是互斥对象的短时间之一重合为止。
所示的场景可以通过在xSemaphoreGive()之后向taskYIELD()添加调用来避免。 清单126中演示了这一点,如果任务保持互斥()时,tick计数发生变化,则调用taskYIELD()。
案例:
void vFunction( void *pvParameter )
{
extern SemaphoreHandle_t xMutex;
char cTextBuffer[ 128 ];
TickType_t xTimeAtWhichMutexWasTaken;for( ;; ){
vGenerateTextInALocalBuffer( cTextBuffer );xSemaphoreTake( xMutex, portMAX_DELAY );xTimeAtWhichMutexWasTaken = xTaskGetTickCount();vCopyTextToFrameBuffer( cTextBuffer );xSemaphoreGive( xMutex );if( xTaskGetTickCount() != xTimeAtWhichMutexWasTaken ){
taskYIELD();}} }
看门人任务(门卫任务)(推荐)
提供了一种干净的方法实现互斥,而不存在优先级倒置或死锁的风险
守门任务提供了一种干净的方法来实现互斥,而不存在优先级倒置或死锁的风险。 把关任务是对资源拥有唯一所有权的任务。 只有看门人任务才允许直接访问资源-任何其他需要访问资源的任务只能通过使用看门人的服务间接访问。
案例二:看门人的任务
为vPrint String()提供另一种替代实现。 这一次,使用一个看门人任务来管理标准输出的访问。 当一个任务想写一条消息来标准输出时,它不直接调用print函数,而是将消息发送给看门人。
看门人任务的大部分时间都处于阻塞状态,等待消息到达队列。 当消息到达时,看门人只需将消息写入标准输出,然后返回到阻塞状态等待下一条消息。 清单128显示了看门人任务的实现
中断可以发送到队列,因此中断服务例程也可以安全地使用看门人的服务向终端写入消息。 在本例中,每200个滴答就使用一个勾勾函数来写出一条消息。
其实就是在再建一个任务去处理而不是调用函数。