FreeRTOS学习笔记十二【资源管理】

FreeRTOS学习笔记十二【资源管理】

  • 目的
  • 资源管理的必要性
  • 关键部分代码与暂停调度器
    • 基本的关键部分代码
    • 暂停(或锁定)调度程序
      • vTaskSuspendAll()
      • xTaskResumeAll()
  • 互斥锁(和二值信号量)
    • xSemaphoreCreateMutex()
    • 优先级反转
    • 优先级继承
    • 死锁
    • 递归互斥锁
    • 互斥锁和任务调度
  • 关守任务

目的

  • 何时需要资源管理以及为什么需要资源管理。
  • 什么是关键部分代码。
  • 什么是互斥。
  • 暂停调度程序会怎样。
  • 如何使用互斥锁。
  • 如何创建和使用关守任务。
  • 什么是优先级反转,以及优先级继承如何减少(但不能消除)其影响。

资源管理的必要性

在多任务系统中,如果一个任务开始访问资源,但在运行态时没有对其完成操作,则可能会出错。如果任务时资源处于不一致的状态,则其他任务或者中断对同一资源的访问都可能导致数据损坏或其他问题。例如:

  1. 访问外设
    两个任务操作液晶显示器(LCD):
    (1) 任务A执行并准备写入"Hello world"到LCD。
    (2) 在写入到"Hello w"时,任务B抢占了任务A。
    (3) 任务B写入"Abort, Retry, Fail?“到LCD中,然后阻塞。
    (4) 任务A从它被抢占的点继续执行,并写入剩余的字符"orld”。
    此时LCD上显示的将是Hello wAbort, Retry, Fail?orld”。
  2. 读取、修改、写入操作
    下列代码中给出了一行C代码以及C代码转换成汇编代码的示例。可以看出,PORTA的值首先从存储器读入寄存器,在寄存器中修改,然后写回存储器,这称为“读取、修改、写入操作”。
/* 一行C代码 */
PORTA |= 0x01;

/* 上面C代码对应的汇编代码 */
LOAD R1,[#PORTA]               ; 读取部分
MOVE R2,#0x01                  ; 常量1
OR R1,R2                       ; 修改部分
STORE R1,[#PORTA]              ; 写入部分

这是一个“非原子”操作,因为它需要多条指令才能完成,并且可以被中断。考虑有两个任务尝试更新PORTA寄存器的情况:
(1) 任务A将PORTA的值加载到寄存器中(读取部分)。
(2) 任务A在完成完成修改并写入前被任务B抢占。
(3) 任务B更新PORTA的值,然后进入阻塞态。
(4) 任务A从它被抢占的点继续执行,在将更新后的值写回PORTA时,它会修改被任务B跟新的值。
这里,任务A更新并写回了PORTA过期的值,它会覆盖任务B已执行的修改,破坏了PORTA的值。示例中使用的是外设寄存器,对于变量,也是一样的道理。

  1. 非原子访问变量
    更新结构体的多个成员或者更新大于处理器自然字大小的变量(如更新16机器上的32位变量)都是非原子操作的情况,如果它们被中断,则可能导致数据丢失或损坏。
  2. 函数重入
    如果从多个任务或任务与中断中调用的函数是安全的,则函数是可重入的。可重入函数被称为线程安全,因为它们可以从多个执行的线程(任务)访问,而不会有数据或逻辑被破坏的风险。每个任务都维护自己的堆栈和自己的一组处理器(硬件)寄存器值。如果函数不访问存储在堆栈或保存在寄存器中的数据以外的任何数据,则该函数是可重入的,并且线程安全。例如下面一个函数是可重入的,一个是不可重入的。
/* 可重入函数 */
/* 参数传递给函数时是在堆栈上传递,或在处理器寄存器中传递。无论哪种方式都是
安全的,因为调用该函数的每个任务或中断都维护自己的堆栈和它自己的寄存器值集,因此调用该函数
的每个任务或中断都将拥有自己的lVar1副本。*/
long lAddOneHundred( long lVar1 )
{
	/* 调用此函数的每个任务或中断都有自己的lVar2副本。 */
	long lVar2;
	lVar2 = lVar1 + 100;
	return lVar2;
}

/* 不可重入函数 */
/* 在这种情况下,lVar1是一个全局变量,因此调用lNonsenseFunction的每个任务都将访问该变量的
同一个副本。 */
long lVar1;
long lNonsenseFunction( void )
{
	/* lState是静态的,因此不会在堆栈上分配。 调用此函数的每个任务都将访问该变量的同一个副本 */
	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()包围的代码区域。它也可称为关键区域。这两个宏不接受任何参数,没有返回值。使用示例如下:

/* 进入关键区域。确保在关键区域内,不会中断对PORTA寄存器的访问 */
taskENTER_CRITICAL();
/* 在调用taskENTER_CRITICAL()和调用taskEXIT_CRITICAL()之间不能切换到另一个任务。
中断仍然可以在允许中断嵌套的FreeRTOS移植上执行,但只能在逻辑优先级高于分配给
configMAX_SYSCALL_INTERRUPT_PRIORITY常量的值的中断上执行,并且这些中断不允许调用
FreeRTOS API函数。*/
PORTA |= 0x01;
/* 退出关键区域。对PORTA的访问已经完成,因此退出关键部分是安全的。*/
taskEXIT_CRITICAL();

前面的示例中调用的vPrintString()函数是将字符串输出的标准输出。它的简单实现如下:

void vPrintString( const char *pcString )
{
	taskENTER_CRITICAL();
	{
		printf( "%s", pcString );
		fflush( stdout );
	}
	taskEXIT_CRITICAL();
}

以这种方式实现关键部分代码提供的是一种简单粗糙的互斥方法,它们的工作方式完全是禁用中断或者达到由configMAX_SYSCALL_INTERRUPT_PRIORITY设置的中断优先级(具体取决于FreeRTOS的移植)。抢占式的上下文切换(任务调度)只能发生在中断(滴答中断)内,因此,只要中断被禁用,就能保证调用taskENTER_CRITICAL()的任务保持在运行态,直到退出临界区(关键区域)。
基本关键部分代码必须很短,否则会对中断响应时间产生影响。taskENTER_CRITICAL()与taskEXIT_CRITICAL()必须配对调用。因此标准输出(stdout)不应是使用临界区保护,因为写入终端可能是一个相当长的操作。关键区域是可以嵌套的,因为内核会保留嵌套深度的计数。只有当嵌套深度为0时才退出临界区。并且调用taskENTER_CRITICAL()和taskEXIT_CRITICAL()是任务改变运行FreeRTOS的处理器中断启用状态的唯一合法方式,通过其他任何方式更改中断启用状态都将使嵌套深度的计数无效。
taskENTER_CRITICAL()和taskEXIT_CRITICAL()不能从ISR中调用,它们的中断安全版为taskENTER_CRITICAL_FROM_ISR()和taskEXIT_CRITICAL_FROM_ISR(),并且中断安全版也仅在允许中断嵌套的FreeRTOS的移植中才提供,否则它们也将会使不可用的。同时在使用它们时必须将taskENTER_CRITICAL_FROM_ISR()的返回值传递给taskEXIT_CRITICAL_FROM_ISR()。使用示例如下:

void vAnInterruptServiceRoutine( void )
{
	UBaseType_t uxSavedInterruptStatus;
	
	uxSavedInterruptStatus = taskENTER_CRITICAL_FROM_ISR();
	{
		/* 处理中断事件 */
	}
	taskEXIT_CRITICAL_FROM_ISR( uxSavedInterruptStatus );
}

暂停(或锁定)调度程序

除了调用上面的宏,也可以通过挂起调度程序来创建关键部分代码,有时也成为锁定调度程序。基本的关键部分保护代码区域不被其他任务和中断访问。通过挂起调度程序实现的关键部分仅保护代码区域不被其他任务访问,而中断任然可以访问,因为中断处于启用状态。这种方式可以实现一个比较长而无法通过简单地禁用中断来实现的关键部分,但是调度程序挂起时的中断活动可以使调度程序恢复(取消挂起),因此在使用使用时必须考虑哪种方式更好。

vTaskSuspendAll()

调用vTaskSuspendAll()可以暂停调度程序。挂起调度程序后可防止发生上下文切换,但会启用中断,如果中断在调度程序挂起时请求上下文切换,则该请求保存挂起,在调度程序恢复时执行切换。在调度程序挂起时,不得调用FreeRTOS API函数。它的原型如下:·

void vTaskSuspendAll( void );

xTaskResumeAll()

调用xTaskResumeAll()可以恢复调度程序,在调度程序挂起时请求的上下文切换将保持挂起状态,仅在恢复调度程序时执行。它的原型如下:

BaseType_t xTaskResumeAll( void );

返回值:

  • pdTRUE
    在xTaskResumeAll()返回之前执行挂起的上下文切换。
  • pdFALSE
    没有执行上下文切换。

同样的,vTaskSuspendAll()和xTaskResumeAll()也是可以嵌套的,内核会保存嵌套深度,仅在嵌套深度为0时恢复调度程序。下面是对vPrintString()的改进实现。

void vPrintString( const char *pcString )
{
	vTaskSuspendScheduler();
	{
		printf( "%s", pcString );
		fflush( stdout );
	}
	xTaskResumeScheduler();
}

互斥锁(和二值信号量)

互斥锁是一种特殊的二值信号量,用于控制两个或多个任务之间共享资源的访问。要使用互斥锁必须将FreeRTOSConfig.h中的configUSE_MUTEXES设置为1。
可以将互斥锁是为共享资源相关联的令牌,想要合法访问资源首先必须成功获得令牌(作为令牌的持有者),当令牌持有者完成对资源的使用时,必须释放令牌,只有令牌被释放后其他的任务才能成功获取令牌,然后安全的访问该共享资源,如果任务未持有令牌则不允许访问共享资源。互斥锁和二值信号量有许多共同的特性,但也有区别,其主要区别在于信号量(二值信号量和互斥锁)获得后发生的变化:

  • 用于互斥的信号量必须返回。
  • 用于同步的信号量会被丢弃而不返回。

用队列来类比它们,即用于同步的信号量在使用完后直接丢弃,不用在放回队列中,因为同步看的时这个资源是否可用(这个资源用了就没有了,需要其他地方再次产生,如从UART收到的数据);而用于互斥的信号量在使用完后要放回队列中,互斥看的时这个资源是否正在被其他任务使用(这个资源一直存在,只是不能被同时访问,如外设(UART、SPI 等),变量等)。

下图将描述互斥锁的使用场景:
FreeRTOS学习笔记十二【资源管理】_第1张图片

xSemaphoreCreateMutex()

使用互斥锁前必须闯将它,调用xSemaphoreCreateMutex()可以创建一个互斥锁,它的原型如下:

SemaphoreHandle_t xSemaphoreCreateMutex( void );

返回值:

  • NULL
    创建失败,没有足够的内存空间存储互斥锁。
  • 非NULL
    创建成功,返回的时互斥锁的句柄。

使用互斥锁对vPrintString()重新实现:

void vPrintString( const char *pcString )
{
    /* 使用xMutex前需要调用xSemaphoreCreateMutex()创建互斥锁 */
	xSemaphoreTake( xMutex, portMAX_DELAY );
	{
		printf( "%s", pcString );
		fflush( stdout );
	}
	xSemaphoreGive( xMutex );
}

优先级反转

使用互斥锁实现互斥存在一些潜在缺陷。即较高优先级的任务必须等待优先级较低的任务释放互斥锁后才能执行,这种情况被称为优先级反转。如果在高优先级任务中等待信号量时低优先级任务开始执行,则会进一步放大这种不良行为。如下图所描述。
FreeRTOS学习笔记十二【资源管理】_第2张图片
优先级反转是一个很重要的问题,但在小型嵌入式系统中,通常可以在设计时考虑如何避免这种情况。

优先级继承

互斥锁和二值信号量非常相似,区别在于互斥锁包含基本的优先级继承机制,而二值信号量则没有。优先级继承是一种最小化优先级反转问题的方案。它不会解决优先级反转问题,而只是限制反转的时间类减轻这种影响。但是优先级继承会使系统时序分析变得复杂,并且依靠它来实现正确的系统操作并不是一个好的方案。
优先级继承的工作原理是将互斥锁持有者的优先级暂时提高到尝试获取相同互斥锁的最高优先级任务的优先级。保持互斥锁的低优先级任务继承等待互斥锁的任务的优先级。如下图,当互斥锁持有者返回时,互斥锁持有者的优先级会自动重置为原始优先级。
FreeRTOS学习笔记十二【资源管理】_第3张图片
如上图所述,优先级继承会影响使用互斥锁的任务的优先级。 因此,不得在中断服务程序中使用互斥锁。

死锁

死锁是使用互斥锁实现互斥的另一个潜在缺陷。当两个任务都在等待对方所持有的资源时就会产生死锁。例如:

  1. 任务A执行,并成功获取互斥锁X。
  2. 任务B抢占了任务A。
  3. 任务B成功获取互斥锁Y后尝试获取互斥锁 X,但互斥锁X有任务A保留,因此任务B进入阻塞态等待互斥锁X被释放。
  4. 任务A继续执行,并尝试获取互斥锁Y,但互斥锁Y由任务B保留,因此任务A也进入阻塞态等待互斥锁Y被释放。

和优先级的反转 一样,避免死锁 的方法是在设计程序避免,以确保此通不会发生死锁。但在设计时可以使用超时机制,使用比预期等待互斥锁的最长时间稍长的超时,如果在该时间内获取互斥锁失败则可能是错误的设计,就可能产生了死锁。还有一种方式就是在每个任务中始终使用相同的顺序获取和释放互斥锁,不要在任务中交叉获取和释放互斥锁就可以避免死锁发生。

递归互斥锁

任务也可能与自身发生死锁。如果任务尝试多次使用相同的互斥锁,而不释放互斥锁,则会发生这种情况。例如:

  1. 任务成功获取互斥锁。
  2. 在保持互斥锁的同时,任务调用库函数。
  3. 库函数的实现尝试使用相同的互斥锁,并进入阻塞态等待互斥锁变为可用。

这种情况下,任务处于阻塞态等待互斥锁释放,但该任务以及持有了互斥锁,这是就发生了死锁,因为任务处于阻塞态以等待自身。
使用递归互斥锁替代标准互斥锁,就可以避免这种类型的死锁。递归互斥锁可以被同一任务多次获取,但获取多少次也就需要释放多少次。标准互斥锁和递归互斥锁的创建和使用方式类似,并且它们的参数和返回值相同:

操作 标准互斥锁 递归互斥锁
创建互斥锁 xSemaphoreCreateMutex() xSemaphoreCreateRecursiveMutex()
获取互斥锁 xSemaphoreTake() xSemaphoreTakeRecursive()
释放互斥锁 xSemaphoreGive() xSemaphoreGiveRecursive()

下面是递归互斥锁的使用示例:

/* 递归互斥锁的句柄 */
SemaphoreHandle_t xRecursiveMutex;

void vTaskFunction( void *pvParameters )
{
	const TickType_t xMaxBlock20ms = pdMS_TO_TICKS( 20 );
	/* 创建 */
	xRecursiveMutex = xSemaphoreCreateRecursiveMutex();
	/* 检测创建是否成功,这个宏在后面介绍 */
	configASSERT( xRecursiveMutex );
	for( ;; )
	{
		/* ... */
		/* 第一次获取 */
		if( xSemaphoreTakeRecursive( xRecursiveMutex, xMaxBlock20ms ) == pdPASS )
		{
			/* 第二次获取 */
			xSemaphoreTakeRecursive( xRecursiveMutex, xMaxBlock20ms );
			/* ... */
			/* 第一次释放 */
			xSemaphoreGiveRecursive( xRecursiveMutex );
			/* 第二次释放 */
			xSemaphoreGiveRecursive( xRecursiveMutex );
		}
	}
}

互斥锁和任务调度

如果两个优先级不同的任务使用相同的互斥锁,则调度程序会选择就绪态中优先级最高的任务运行。但当任务具有相同优先级时,任务的调度顺序将是不可预测的。例如:任务1和任务2具有相同的优先级,并且任务1处于阻塞态等待任务2持有的互斥锁,当任务2释放互斥锁后,任务1不会抢占任务2,只是简单的将任务1切换到就绪态,如下图所示,图中的垂线是滴答中断发生的时间。
FreeRTOS学习笔记十二【资源管理】_第4张图片
图中可看出 ,互斥锁可用时,调度程序就不会将任务1切换到运行状态,因为:

  1. 任务1和任务2具有相同的优先级,因此除非任务2进入阻塞状态,否则在下一个滴答中断发生之前不应该切换到任务1(假设在FreeRTOSConfig.h中将configUSE_TIME_SLICING设置为1)。
  2. 如果任务在短小的循环中使用互斥锁,并且每次任务释放互斥锁时都发生上下文切换,则该任务将仅在非常短的时间内保持运行态。如果两个或多个任务在短小的循环中使用相同的互斥锁,则在任务之间快速切换将浪费很大CPU时间。

如果多个任务在短小的循环中使用互斥锁,并且使用互斥锁的任务具有相同的优先级,则必须注意确保任务大致有相等的CPU时间。如果以相同优先级创建下列任务,则任务可能无法获得相同的CPU时间。

/* 下列函数实现了在短小的循环中使用互斥锁。该任务在本地缓冲区中创建文本字符串,然后将字符串写入显示器,并通过互斥锁保护对显示器的访问。 */
void vATask( void *pvParameter )
{
	extern SemaphoreHandle_t xMutex;
	char cTextBuffer[ 128 ];
	for( ;; )
	{
		/* 生产文本字符串,这是一个快速的操作 */
		vGenerateTextInALocalBuffer( cTextBuffer );
		/* 获取互斥锁 */
		xSemaphoreTake( xMutex, portMAX_DELAY );
		/* 将文本写入显示器,这是一个比较缓慢的操作 */
		vCopyTextToFrameBuffer( cTextBuffer );
		/* 释放互斥锁 */
		xSemaphoreGive( xMutex );
	}
}

代码中,将文本写入显示器是一个缓慢的操作,因此任务将大部分时间留在互斥锁内。调度情况如下图。
FreeRTOS学习笔记十二【资源管理】_第5张图片
图中的step7显示了任务1重新进入阻塞态,这是发生在xSemaphoreTake()函数的内部。图中任务1被阻止获取互斥锁,直到时间片开始时间与任务2互斥锁持有周期不一致时,才可以获取。
在调用xSemaphoreGive()后添加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 );
		/* 如果在持有互斥锁期间发生了滴答中断,则调用taskYIELD() */
		if( xTaskGetTickCount() != xTimeAtWhichMutexWasTaken )
		{
			taskYIELD();
		}
	}
}

关守任务

关守任务提供一种实现互斥的简洁方法,并且没有优先级反转或者死锁的风险。关守任务是具有资源唯一使用权的任务,其他需要访问资源的任务都必须通过它来间接访问。

下面将提供vPrintString()的另一种实现。实现中,关守任务用于管理对标准输出的访问,当其他任务想要将消息写入标准输出时,它不直接调用打印函数,而是将消息发送给关守任务。其中关守任务使用队列来序列化对标准输出的访问,任务的内部实现不必考虑互斥,因为它是唯一访问标准输出的任务。关守任务大部分时间处于阻塞态,等待消息到达队列,当有消息到达时,关守任务将消息写入标准输出,然后继续进入阻塞态等待下一条消息。中断可以发送数据到队列,因此中断服务程序也可以安全的使用关守任务将消息写入终端。示例中使用滴答中断hook函数,它是内核在每次滴答中断时调用的函数。
滴答中断hook函数原型:

/* 要使用这个函数,必须将FreeRTOSConfig.h中的configUSE_TICK_HOOK设置为1 */
void vApplicationTickHook( void );

滴答中断hook函数在滴答中断的上下文中执行,因此代码必须短,并且不能调用任务非中断安全的API函数。调度程序将会在hook函数之后执行,因此hook函数中使用中断安全的API时不需要使用pxHigherPriorityTaskWoken参数,直接将其设置为NULL即可。
下面是示例代码:

/* 关守任务 */
static void prvStdioGatekeeperTask( void *pvParameters )
{
	char *pcMessageToPrint;
	for( ;; )
	{
		/* 从队列总获取消息 */
		xQueueReceive( xPrintQueue, &pcMessageToPrint, portMAX_DELAY );
		/* 输出消息 */
		printf( "%s", pcMessageToPrint );
		fflush( stdout );
	}
}
/* 普通任务 */
static void prvPrintTask( void *pvParameters )
{
	int iIndexToString;
	const TickType_t xMaxBlockTimeTicks = 0x20;
	iIndexToString = ( int ) pvParameters;
	for( ;; )
	{
		/* 将要输出的消息写入队列 */
		xQueueSendToBack( xPrintQueue, &( pcStringsToPrint[ iIndexToString ] ), 0 );
		
		vTaskDelay( ( rand() % xMaxBlockTimeTicks ) );
	}
}

/* 滴答hook函数 */
void vApplicationTickHook( void )
{
	static int iCount = 0;
	/* 每200次滴答中断输出一次消息 */
	iCount++;
	if( iCount >= 200 )
	{
		xQueueSendToFrontFromISR( xPrintQueue, &( pcStringsToPrint[ 2 ] ), NULL );
		iCount = 0;
	}
}

static char *pcStringsToPrint[] =
{
"Task 1 ****************************************************\r\n",
"Task 2 ----------------------------------------------------\r\n",
"Message printed from the tick hook interrupt ##############\r\n"
};
/*-----------------------------------------------------------*/
/* 消息队列 */
QueueHandle_t xPrintQueue;
/*-----------------------------------------------------------*/
int main( void )
{
	/* 创建队列 */
	xPrintQueue = xQueueCreate( 5, sizeof( char * ) );
	if( xPrintQueue != NULL )
	{
		/* 创建两个普通任务 */
		xTaskCreate( prvPrintTask, "Print1", 1000, ( void * ) 0, 1, NULL );
		xTaskCreate( prvPrintTask, "Print2", 1000, ( void * ) 1, 2, NULL );
		/* 创建关守任务 */
		xTaskCreate( prvStdioGatekeeperTask, "Gatekeeper", 1000, NULL, 0, NULL );
		
		vTaskStartScheduler();
	}
	for( ;; );
}

运行结果:
FreeRTOS学习笔记十二【资源管理】_第6张图片

你可能感兴趣的:(Free,RTOS)