7. 任务管理

7.1. 任务的基本概念

从系统的角度看,任务是竞争系统资源的最小运行单元。绝大多数的RTOS支持多任务的操作系统。 任务可以使用或等待CPU、使用内存空间等系统资源,并独立于其他任务运行,任何数量的任务可以共享同一个优先级, 还可选择让处于就绪态的多个相同优先级任务将会以时间片切换的方式共享处理器。

简而言之RTOS的任务可认为是一系列独立任务的集合。每个任务在自己的环境中运行。 在任何时刻,只有一个任务得到运行,RTOS调度器决定运行哪个任务。调度器会不断的启动、停止每一个任务, 宏观看上去所有的任务都在同时在执行。作为任务,不需要对调度器的活动有所了解,在任务切入切出时保存上 下文环境(寄存器值、栈内容)是调度器主要的职责。为了实现这点,每个RTOS任务都需要有自己的栈空间。 当任务切出时,它的执行环境会被保存在该任务的栈空间中,这样当任务再次运行时,就能从栈中正确的恢复上次的运行环境, 任务越多,需要的栈空间就越大,而一个系统能运行多少个任务,取决于系统的可用的SRAM。

RTOS的可以给用户提供多个任务单独享有独立的栈空间,系统可用决定任务的状态,决定任务是否可以运行, 同时还能运用内核的IPC通信资源,实现了任务之间的通信,帮助用户管理业务程序流程。 这样用户可以将更多的精力投入到业务功能的实现中。

FreeRTOS中的任务是抢占式调度机制,高优先级的任务可打断低优先级任务,低优先级任务必须在高优先级任务阻塞或结束后才能得到调度。 同时FreeRTOS也支持时间片轮转调度方式,只不过时间片的调度是不允许抢占任务的CPU使用权。

任务通常会运行在一个死循环中,也不会退出,如果一个任务不再需要,可以调用RTOS中的任务删除API函数接口显式地将其删除。

7.2. 任务调度器的基本概念

FreeRTOS中提供的任务调度器是基于优先级的全抢占式调度:在系统中除了中断处理函数、调度器上锁部分的代码 和禁止中断的代码是不可抢占的之外,系统的其他部分都是可以抢占的。系统理论上可以支持无数个优先级(0 ~ N, 优先级数值越小的任务优先级越低,0为最低优先级,分配给空闲任务使用,一般不建议用户来使用这个优先级。 假如使能了configUSE_PORT_OPTIMISED_TASK_SELECTION这个宏(在FreeRTOSConfig.h文件定义,可在STM32CubeIDE中配置), 在系统中,当有比当前任务优先级更高的任务就绪时,当前任务将立刻被换出,高优先级任务抢占处理器运行。 CMSIS-RTOS封装后的FreeRTOS优先级数目默认为56。

一个操作系统如果只是具备了高优先级任务能够“立即”获得处理器并得到执行的特点,那么它仍然不算是实时操作系统。 因为这个查找最高优先级任务的过程决定了调度时间是否具有确定性,例如一个包含n个就绪任务的系统中,如果仅仅从头找到尾, 那么这个时间将直接和n相关,而下一个就绪任务抉择时间的长短将会极大的影响系统的实时性。

FreeRTOS内核中采用两种方法寻找最高优先级的任务,第一种是通用的方法,在就绪链表中查找从高优先级往低查找, 因为在创建任务的时候已经将优先级进行排序,查找到的第一个就是我们需要的任务, 然后获取对应的任务控制块。第二种方法则是特殊方法,利用计算前导零指令CLZ, 直接在任务就绪表变量中直接得出优先级最高的那位,这样子就知道哪一个优先级任务能够运行, 这种调度算法比普通方法更快捷,但受限于平台(适用在STM32中)。

FreeRTOS内核中也允许创建相同优先级的任务。相同优先级的任务采用时间片轮转方式进行调度(也就是通常说的分时调度器), 时间片轮转调度仅在当前系统中无更高优先级就绪任务存在的情况下才有效。为了保证系统的实时性, 系统尽最大可能地保证高优先级的任务得以运行。任务调度的原则是一旦任务状态发生了改变, 并且当前运行的任务优先级小于优先级队列组中任务最高优先级时, 立刻进行任务切换(除非当前系统处于中断处理程序中或禁止任务切换的状态)。

7.3. 任务状态迁移

FreeRTOS系统中的每一个任务都有多种运行状态,他们之间的转换关系是怎么样的呢?从运行态任务变成阻塞态, 或者从阻塞态变成就绪态,这些任务状态是如何进行迁移?下面就让我们一起了解任务状态迁移吧,具体见图1。

API调用关系

图任务状态迁移图

图1(1):创建任务→就绪态(Ready):任务创建完成后进入就绪态,表明任务已准备就绪,随时可以运行,只等待调度器进行调度。

图1(2):就绪态→运行态(Running):发生任务切换时,就绪列表中最高优先级的任务被执行,从而进入运行态。

图1(3):运行态→就绪态:有更高优先级任务创建或者恢复后,会发生任务调度, 此刻就绪列表中最高优先级任务变为运行态,那么原先运行的任务由运行态变为就绪态,依然在就绪列表中, 等待最高优先级的任务运行完毕继续运行原来的任务(此处可以看做是CPU使用权被更高优先级的任务抢占了)。

图1(4):运行态→阻塞态(Blocked):正在运行的任务发生阻塞(挂起、延时、读信号量等待)时, 该任务会从就绪列表中删除,任务状态由运行态变成阻塞态,然后发生任务切换,运行就绪列表中当前最高优先级任务。

图1(5):阻塞态→就绪态:阻塞的任务被恢复后(任务恢复、延时时间超时、读信号量超时或读到信号量等), 此时被恢复的任务会被加入就绪列表,从而由阻塞态变成就绪态;如果此时被恢复任务的优先级高于正在运行任务的优先级, 则会发生任务切换,将该任务将再次转换任务状态,由就绪态变成运行态。

图1(6)(7)(8):就绪态、阻塞态、运行态→挂起态(Suspended): 被挂起的任务得不到CPU的使用权,也不会参与调度,除非它从挂起态中解除。

图1(9):挂起态→就绪态:如果被恢复任务的优先级高于正在运行任务的优先级,则会发生任务切换, 将该任务将再次转换任务状态,由就绪态变成运行态。

7.4. 任务状态的概念

FreeRTOS系统中的每一任务都有多种运行状态。系统初始化完成后,创建的任务就可以在系统中竞争一定的资源,由内核进行调度。

任务状态通常分为以下四种:

  • 就绪(Ready):该任务在就绪列表中,就绪的任务已经具备执行的能力,只等待调度器进行调度,新创建的任务会初始化为就绪态。

  • 运行(Running):该状态表明任务正在执行,此时它占用处理器,FreeRTOS调度器选择运行的永远是处于最高优先级的就绪态任务, 当任务被运行的一刻,它的任务状态就变成了运行态。

  • 阻塞(Blocked):如果任务当前正在等待某个时序或外部中断,我们就说这个任务处于阻塞状态, 该任务不在就绪列表中。包含任务被挂起、任务被延时、任务正在等待信号量、读写队列或者等待读写事件等。

  • 挂起态(Suspended):处于挂起态的任务对调度器而言是不可见的,我们可以这么理解挂起态与阻塞态的区别, 当任务有较长的时间不允许运行的时候,我们可以挂起任务,这样子调度器就不会管这个任务的任何信息, 直到我们调用恢复任务的API函数;而任务处于阻塞态的时候,系统还需要判断阻塞态的任务是否超时,是否可以解除阻塞。

7.5. 常用的任务函数讲解

相信大家通过前面的学习,对任务创建以及任务调度的实现已经了大致的认识, 下面就补充一些CMSIS-RTOS提供给我们对任务操作的一些常用函数。

7.5.1. osThreadGetId返回当前线程ID函数

首先我们需要明确的一点是:线程ID,在CMSIS-RTOS中我们称之为线程ID,在FreeRTOS称为线程句柄、任务句柄。 在本教程中我们可能有时候会称为线程ID或者线程句柄、任务句柄,但它们都是指同一个东西。 线程ID主要用于分辨不同的线程任务,当我们需要对线程进行挂起、唤醒、删除等操作时就需要知道相对应的线程ID。 除了创建线程函数osThreadNew外,还可以通过使用osThreadGetId函数获取本线程的线程ID,函数源码如下所示

cmsis_os2.c
1
2
3
4
5
6
7
osThreadId_t osThreadGetId (void) {
osThreadId_t id;

   id = (osThreadId_t)xTaskGetCurrentTaskHandle();

   return (id);
}

在osThreadGetId函数中调用了FreeRTOS提供的xTaskGetCurrentTaskHandle获取线程句柄。

7.5.2. osThreadSuspend任务挂起函数

挂起指定任务。被挂起的任务绝不会得到CPU的使用权,不管该任务具有什么优先级。 任务可以通过调用 osThreadSuspend()函数都可以将处于任何状态的任务挂起,被挂起的任务得不到CPU的使用权, 也不会参与调度,它相对于调度器而言是不可见的,除非它从挂起态中解除。函数源码如下

cmsis_os2.c
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
osStatus_t osThreadSuspend (osThreadId_t thread_id) {
TaskHandle_t hTask = (TaskHandle_t)thread_id;
osStatus_t stat;

//并不能在中断中使用挂起函数
if (IS_IRQ()) {
   stat = osErrorISR;
}
//判断任务句柄的有效性
else if (hTask == NULL) {
   stat = osErrorParameter;
}
//使用vTaskSuspend挂起线程
else {
   stat = osOK;
   vTaskSuspend (hTask);
}

return (stat);
}

参数thread_id :线程ID,可由osThreadNew或者osThreadGetId得到。

返回值:当成功时返回osOK,失败返回osErrorISR或osErrorParameter。

7.5.3. osThreadResume任务恢复函数

既然有任务的挂起,那么当然一样有恢复,不然任务怎么恢复呢,任务恢复就是让挂起的任务重新进入就绪状态, 恢复的任务会保留挂起前的状态信息,在恢复的时候根据挂起时的状态继续运行。 如果被恢复任务在所有就绪态任务中,处于最高优先级列表的第一位,那么系统将进行任务上下文的切换。 函数源码如下所示

cmsis_os2.c
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
osStatus_t osThreadResume (osThreadId_t thread_id) {
   TaskHandle_t hTask = (TaskHandle_t)thread_id;
   osStatus_t stat;

   //并不能在中断中使用恢复函数
   if (IS_IRQ()) {
      stat = osErrorISR;
   }
   //判断任务句柄的有效性
   else if (hTask == NULL) {
      stat = osErrorParameter;
   }
   //使用vTaskResume函数恢复线程
   else {
      stat = osOK;
      vTaskResume (hTask);
   }
   return (stat);
}

参数thread_id :线程ID,可由osThreadNew或者osThreadGetId得到。

返回值:当成功时返回osOK,失败返回osErrorISR或osErrorParameter。

7.5.4. 任务删除函数

如果想要使用任务删函数,需要将FreeRTOSConfig.h 中把INCLUDE_vTaskDelete定义为1(默认为1)。 删除的任务将从所有就绪,阻塞,挂起和事件列表中删除,CMSIS-RTOS为我们提供了两个任务删除函数, 分别为osThreadExit和osThreadTerminate。

7.5.4.1. osThreadExit

osThreadExit函数用于终止当前运行线程的执行,其函数源码如下

cmsis_os2.c
1
2
3
4
5
6
__NO_RETURN void osThreadExit (void) {
#ifndef USE_FreeRTOS_HEAP_1
   vTaskDelete (NULL);
#endif
   for (;;);
}

需要注意的是当我们使用FreeRTOS提供的heap_1作为动态内存分配策略时,禁止删除线程。 关于FreeRTOS的动态内存分配策略将在以后的章节讲解。

7.5.4.2. osThreadTerminate

CMSIS-RTOS提供了osThreadTerminate函数可用于删除其他的线程,其函数源码如下

cmsis_os2.c
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
osStatus_t osThreadTerminate (osThreadId_t thread_id) {
   TaskHandle_t hTask = (TaskHandle_t)thread_id;
   osStatus_t stat;
#ifndef USE_FreeRTOS_HEAP_1
   eTaskState tstate;

   //并不能在中断中使用线程结束函数
   if (IS_IRQ()) {
      stat = osErrorISR;
   }
   //判断任务句柄的有效性
   else if (hTask == NULL) {
      stat = osErrorParameter;
   }
   else {
      //获取线程状态
      tstate = eTaskGetState (hTask);

      if (tstate != eDeleted) {
         stat = osOK;
         vTaskDelete (hTask);  //使用vTaskDelete删除线程
      } else {
         stat = osErrorResource;
      }
   }
#else
   stat = osError;
#endif

   return (stat);
}

参数thread_id :线程ID,可由osThreadNew或者osThreadGetId得到。

返回值:当成功时返回osOK,失败返回osErrorISR或osErrorParameter或osError。

7.5.5. osDelay延时函数

osDelay()用于阻塞延时,调用该函数后,任务将进入阻塞状态,进入阻塞态的任务将让出CPU资源。 延时的时长由形参ticks决定,单位为系统节拍周期,比如系统的时钟节拍周期为1ms, 那么调用osDelay(1)的延时时间则为1ms。

osDelay()延时是相对性的延时,它指定的延时时间是从调用 osDelay()结束后开始计算的, 经过指定的时间后延时结束。比如 osDelay(100),从调用osDelay()结束后,任务进入阻塞状态, 经过100个系统时钟节拍周期后,任务解除阻塞。因此,osDelay()并不适用与周期性执行任务的场合。 此外,其它任务和中断活动,也会影响到osDelay()的调用(比如调用前高优先级任务抢占了当前任务), 进而影响到任务的下一次执行的时间。其函数源码如下

cmsis_os2.c
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
osStatus_t osDelay (uint32_t ticks) {
   osStatus_t stat;

   //不能在中断中使用延时函数
   if (IS_IRQ()) {
      stat = osErrorISR;
   }
   else {
      stat = osOK;

      if (ticks != 0U) {
         vTaskDelay(ticks); //调用vTaskDelay进行延时
      }
   }

   return (stat);
}

参数ticks :延时的时长,单位为系统节拍周期。

返回值 :当成功时返回osOK,失败时返回osErrorISR。

7.6. 任务的设计要点

作为一个嵌入式开发人员,要对自己设计的嵌入式系统要了如指掌,任务的优先级信息,任务与中断的处理, 任务的运行时间、逻辑、状态等都要知道,才能设计出好的系统,所以,在设计的时候需要根据需求制定框架。 在设计之初就应该考虑下面几点因素:任务运行的上下文环境、任务的执行时间合理设计。

FreeRTOS中程序运行的上下文包括:

  • 中断服务函数。

  • 普通任务。

  • 空闲任务。

  1. 中断服务函数:

中断服务函数是一种需要特别注意的上下文环境,它运行在非任务的执行环境下(一般为芯片的一种特殊运行模式(也被称作特权模式)), 在这个上下文环境中不能使用挂起当前任务的操作,不允许调用任何会阻塞运行的API函数接口。 使用CMSIS-RTOS封装过的FreeRTOS,都会判断是否在中断环境中运行,如果是则直接退出。 另外需要注意的是,中断服务程序最好保持精简短小,快进快出,一般在中断服务函数中只做标记事件的发生,然后通知任务, 让对应任务去执行相关处理,因为中断服务函数的优先级高于任何优先级的任务,如果中断处理时间过长, 将会导致整个系统的任务无法正常运行。所以在设计的时候必须考虑中断的频率、中断的处理时间等重要因素, 以便配合对应中断处理任务的工作。

  1. 任务:

任务看似没有什么限制程序执行的因素,似乎所有的操作都可以执行。但是做为一个优先级明确的实时系统, 如果一个任务中的程序出现了死循环操作(此处的死循环是指没有阻塞机制的任务循环体),那么比这个任务优先级低的任务都将无法执行, 当然也包括了空闲任务,因为死循环的时候,任务不会主动让出CPU,低优先级的任务是 不可能得到CPU的使用权的,而高优先级的任务就可以抢占CPU。这个情况在实时操作系统中是必须注意的一点, 所以在任务中不允许出现死循环。如果一个任务只有就绪态而无阻塞态,势必会影响到其他低优先级任务的执行, 所以在进行任务设计时,就应该保证任务在不活跃的时候,任务可以进入阻塞态以交出CPU使用权, 这就需要我们自己明确知道什么情况下让任务进入阻塞态,保证低优先级任务可以正常运行。 在实际设计中,一般会将紧急的处理事件的任务优先级设置得高一些。

  1. 空闲任务:

空闲任务(idle任务)是FreeRTOS系统中没有其他工作进行时自动进入的系统任务。 因为处理器总是需要代码来执行——所以至少要有一个任务处于运行态。FreeRTOS为了保证这一点, 当调用 vTaskStartScheduler()时,调度器会自动创建一个空闲任务,空闲任务是一个非常短小的循环。 用户可以通过空闲任务钩子方式,在空闲任务上钩入自己的功能函数。通常这个空闲任务钩子能够完成一些额外的特殊功能, 例如系统运行状态的指示,系统省电模式等。除了空闲任务钩子,FreeRTOS系统还把空闲任务用于一些其他的功能, 比如当系统删除一个任务或一个动态任务运行结束时,在执行删除任务的时候,并不会释放任务的内存空间, 只会将任务添加到结束列表中,真正的系统资源回收工作在空闲任务完成,空闲任务是唯一一个不允许出现阻塞情况的任务, 因为FreeRTOS需要保证系统永远都有一个可运行的任务。

对于空闲任务钩子上挂接的空闲钩子函数,它应该满足以下的条件:

  • 永远不会挂起空闲任务;

  • 不应该陷入死循环,需要留出部分时间用于系统处理系统资源回收。

  1. 任务的执行时间:

任务的执行时间一般是指两个方面,一是任务从开始到结束的时间,二是任务的周期。

在系统设计的时候这两个时间候我们都需要考虑,例如,对于事件A对应的服务任务Ta,系统要求的实时响应指标是10ms, 而Ta的最大运行时间是1ms,那么10ms就是任务Ta的周期了,1ms则是任务的运行时间,简单来说任务Ta在10ms内完成对事件A的响应即可。 此时,系统中还存在着以50ms为周期的另一任务Tb,它每次运行的最大时间长度是100us。 在这种情况下,即使把任务Tb的优先级抬到比Ta更高的位置,对系统的实时性指标也没什么影响, 因为即使在Ta的运行过程中,Tb抢占了Ta的资源,等到Tb执行完毕,消耗的时间也只不过是100us,还是在事件A规定的响应时间内(10ms), Ta能够安全完成对事件A的响应。但是假如系统中还存在任务Tc,其运行时间为20ms,假如将Tc的优先级设置比Ta更高, 那么在Ta运行的时候,突然间被Tc打断,等到Tc执行完毕,那Ta已经错过对事件A(10ms)的响应了,这是不允许的。 所以在我们设计的时候,必须考虑任务的时间,一般来说处理时间更短的任务优先级应设置更高一些。

7.7. 任务管理实验

在STM32MP157中,除了USART1是属于A7内核专属的,其他7个串口都能选择分配给M4还是A7。 由于UART4已经默认作为Linux信息输出,这里使用USART3作为本次串口实验。需要我们将J15 J16跳线帽拔掉后, 使用杜邦线将RXD_1连接到UART3_RX,将TXD_1连接到UART3_TX上,如下图所示。

串口连接图

任务管理实验是将任务常用的函数进行一次实验,通过创建两个任务,一个是LED任务, 另一个是按键任务,LED任务是显示任务运行的状态,而按键任务是通过检测按键的按下与否来进行对LED任务的挂起与恢复, 本实验涉及的硬件有LED、按键以及串口外设,本教程的重点在于RTOS的使用,对于这些的外设使用不再赘述,详情可参考 《STM32MP157 Cortex-M4内核开发实战指南-基于hal库》。

在STM32CubeIDE上设置LED任务。设置如下,仅在原来默认的线程下修改了任务名以及线程入口函数。

创建LED任务

创建按键任务,通常情况下按键任务的优先级相对于其他任务应该高些,以确保按键任务的即时响应。 其配置如下。

创建按键任务

7.7.1. app_freertos.c

在STM32CubeIDE生成的FreeRTOS配置代码都在app_freertos文件上,完整代码请打开工程查看。

7.7.1.1. 与LED任务相关代码

cmsis_os2.c
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
osThreadId_t LEDTaskHandle;                              //定义线程ID
const osThreadAttr_t LEDTask_attributes = {
   .name = "LEDTask",                                                              //LED线程名
   .priority = (osPriority_t) osPriorityNormal, //线程优先级
   .stack_size = 128 * 4                                                        //栈大小
};

void LED_Task(void *argument)
{
/* USER CODE BEGIN LED_Task */
   printf("这是一个[野火]-STM32全系列开发板-CMSIS_RTOS任务管理实验!\n\n");
   printf("按下KEY1挂起任务,按下KEY2恢复任务\n");
   /* Infinite loop */
   for(;;)
   {
      LED1_TOGGLE;
      printf("LED_Task Running......\r\n");
      osDelay(500);   /* 延时500个tick */

   }
/* USER CODE END LED_Task */
}

7.7.1.2. 与按键任务相关代码

cmsis_os2.c
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
osThreadId_t KEYTaskHandle;                                          //定义线程ID
const osThreadAttr_t KEYTask_attributes = {
   .name = "KEYTask",                                                        //按键任务
   .priority = (osPriority_t) osPriorityNormal1,     //线程优先级
   .stack_size = 128 * 4                                                     //栈大小
};

void KEY_Task(void *argument)
{
   osStatus_t osStatus;

   /* USER CODE BEGIN KEY_Task */
   /* Infinite loop */
   for(;;)
   {
      if( Key_Scan(KEY1_GPIO_PORT,KEY1_PIN) == KEY_ON )
      {/* K1 被按下 */
         osStatus = osThreadSuspend(LEDTaskHandle);/* 挂起LED任务 */
         if( osOK == osStatus )
         {
            printf("挂起LED任务成功!\n");
         }
      }
      if( Key_Scan(KEY2_GPIO_PORT,KEY2_PIN) == KEY_ON )
      {/* K2 被按下 */
         osStatus = osThreadResume(LEDTaskHandle);/* 恢复LED任务 */
         if( osOK == osStatus )
         {
            printf("恢复LED任务成功!\n");
         }
      }
      osDelay(20);
   }
}

7.7.2. main函数

main.c
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
int main(void)
{

   HAL_Init();               //初始化时钟

   if(IS_ENGINEERING_BOOT_MODE())
   {
      /* Configure the system clock */
      SystemClock_Config();  //时钟配置
   }


   MX_GPIO_Init();            //初始化GPIO
   MX_USART3_UART_Init(); //初始化串口

   osKernelInitialize();  //初始化内核状态
   MX_FREERTOS_Init();        //创建任务

   osKernelStart();          //启动任务调度器,

   //当成功启动任务调度器之后以下代码将不会被执行
   while (1)
   {

      printf("test.....................\n");
      HAL_Delay(500);
   }

}

7.8. 任务管理实验现象

将程序下载到开发板中, 在调试助手中看到串口的打印信息,在开发板可以看到,LED在闪烁, 按下开发版的KEY1按键挂起任务,按下KEY2按键恢复任务;我们按下KEY1试试,可以看到开发板上的灯也不闪烁了, 同时在串口调试助手也输出了相应的信息,说明任务已经被挂起,我们按下KEY2试试,可以看到开发板上的灯也恢复闪烁了, 同时在串口调试助手也输出了相应的信息,说明任务已经被恢复,具体见下图。

实验现象