一,任务管理

1,创建与删除

        整个单片机程序,称之为 应用程序(application)

        用 FreeRTOS 时,可以在 应用程序 中创建多个 任务 (task),(有些地方称为线程 thread)。可以引入很多概念:任务状态 State,优先级 Priority,栈 Stack,事件驱动,协调式调度……

(1)任务

        在 FreeRTOS 中,任务就是一个函数,原型:

void ATaskFunction( void *pvParameters );

     注意:

  • 这个函数不能返回,函数内部尽量用局部变量
  • 同一个函数,可以用来创建多个任务运行
  • 每个运行任务都有自己的栈,不同任务运行函数时对于局部变量,都有自己的副本
  • 函数中用到全局变量、静态变量时要防止冲突
// 示例如下:
void ATaskFunction( void *pvParameters )
{
    /* 对于不同的任务,局部变量放在任务的栈里,有各自的副本 */
    int32_t lVariableExample = 0;

    /* 任务函数通常实现为一个无限循环 */
    for( ;; )
    {
        /* 任务的代码 */
    }

    /* 如果程序从循环中退出,一定要使用vTaskDelete删除自己*/
    vTaskDelete( NULL );

    /* 程序不会执行到这里, 如果执行到这里就出错了 */
}

(2)创建任务

        创建任务时可以使用 2 个函数:动态分配内存、静态分配内存

        1)动态分配内存函数如下:
BaseType_t xTaskCreate(
    TaskFunction_t pxTaskCode,  // 函数指针, 任务函数
    const char * const pcName,  // 任务的名字
    const configSTACK_DEPTH_TYPE usStackDepth, // 栈大小,单位为word,10表示40字节
    void * const pvParameters,  // 调用任务函数时传入的参数
    UBaseType_t uxPriority,     // 优先级
    TaskHandle_t * const pxCreatedTask );      // 任务句柄, 以后使用它来操作这个任务
        2)静态分配内存函数如下:
TaskHandle_t xTaskCreateStatic (
    TaskFunction_t pxTaskCode, // 函数指针, 任务函数
    const char * const pcName, // 任务的名字
    const uint32_t ulStackDepth, // 栈大小,单位为word,10表示40字节
    void * const pvParameters, // 调用任务函数时传入的参数
    UBaseType_t uxPriority, // 优先级
    StackType_t * const puxStackBuffer, // 静态分配的栈,就是一个buffer
    StaticTask_t * const pxTaskBuffer // 静态分配的任务结构体的指针,用它来操作这个任务
);

        区别在于最后两个参数:puxStackBuffer --- 静态分配的栈内存,可以传入一个数组 (大小为 usStackDepth*4;pxTaskBuffer --- 静态分配的 StaticTask_t 结构体的指针)

示例 1:创建多个任务

// 示例代码
static StackType_t g_pucStackOfLightTask[128];
static StaticTask_t g_TCBofLightTask;
static TaskHandle_t xLightTaskHandle;

static StackType_t g_pucStackOfColorTask[128];
static StaticTask_t g_TCBofColorTask;
static TaskHandle_t xColorTaskHandle;

BaseType_t ret;

  /* 动态创建任务: 声 */
  ret = xTaskCreate(PlayMusic, "SoundTask", 128, NULL, osPriorityNormal, &xSoundTaskHandle);

  /* 静态创建任务: 光 */
  xLightTaskHandle = xTaskCreateStatic(Led_Test, "LightTask", 128, NULL, osPriorityNormal, g_pucStackOfLightTask, &g_TCBofLightTask);

  /* 静态创建任务: 色 */
  xColorTaskHandle = xTaskCreateStatic(ColorLED_Test, "ColorTask", 128, NULL, osPriorityNormal, g_pucStackOfColorTask, &g_TCBofColorTask);

示例 2:用同一个函数创建 2 个任务,完成不一样的操作

struct DisplayInfo {
    int x;
    int y;
    const char *str;
};

void vTaskFunction( void *pvParameters )
{
    struct DisplayInfo *info = pvParameters;
    uint32_t cnt = 0;
    uint32_t len;

    /* 任务函数的主体一般都是无限循环 */
    for( ;; )
    {
        /* 打印任务的信息 */
        len = LCD_PrintString(info->x, info->y, info->str);
        LCD_PrintSignedVal(len+1, info->y, cnt++);

        mdelay(500);
    }
}

        函数的 info 来自参数 pvParameters,用 xTaskCreate 创建任务时,第 4 个参数就是 pvParameters

/* 使用同一个函数创建不同的任务 */
xTaskCreate(LcdPrintTask, "task1", 128, &g_Task1Info, osPriorityNormal, NULL);
xTaskCreate(LcdPrintTask, "task2", 128, &g_Task2Info, osPriorityNormal, NULL);
xTaskCreate(LcdPrintTask, "task3", 128, &g_Task3Info, osPriorityNormal, NULL);

注意:

  • 打印信息时 i2c 传输不能被多任务打断(需要用加锁保护)
  • 用全局变量模拟互斥锁时不可靠,一定要用 mdelay 确保任务切换
  • 后续会解答:为什么 Task3 最后创建,却最先运行

估计栈的大小:       

        简化粒度,宁大勿小(按占用内存最多的一层推算),结合硬件(ARM Cortex-M 的栈以 4 字节为单位)

        多层调用时需关注局部变量用最多的一次,如 PlayMusic 占用估计是:36(4层调用)+4*28(局部结构体)+64(现场) = 250 【<128*4 预留的字节】

  • 4层调用每层估算:4(返回地址)+5(寄存器备份)=9 字节,4 层 × 9 字节 = 36 字节
  • 28由7个变量组成的结构体推算
  • 现场保护开销:核心寄存器(R0-R11)48 字节,PSR+LR+PC寄存器12 字节,栈对齐补齐4 字节。总计 64 字节(嵌入式 RTOS 中线程现场保护的经典估值)。

(3)任务的删除

void vTaskDelete( TaskHandle_t xTaskToDelete );

        xTaskToDelete传入的参数是创建任务得到的句柄 (类型是TaskHandle_t ),传入 NULL 时表示删除自己。

  • 自杀:vTaskDelete(NULL)
  • 被杀:别的任务执行 vTaskDelete(pvTaskCode)pvTaskCode 是自己的句柄
  • 杀人:执行 vTaskDelete(pvTaskCode)pvTaskCode 是别的任务的句柄
while (1)
{
    /* 读取红外遥控器 */
    if (0 == IRReceiver_Read(&dev, &data))
    {
        if (data == 0xa8) /* play */
        {
            /* 创建播放音乐的任务 */
            extern void PlayMusic(void *params);
            if (xSoundTaskHandle == NULL)
            {
                LCD_ClearLine(0, 0);
                LCD_PrintString(0, 0, "Create Task");
                ret = xTaskCreate(PlayMusic, "SoundTask", 128, NULL, osPriorityNormal, &xSoundTaskHandle);
            }
        }
        else if (data == 0xa2) /* power */
        {
            /* 删除播放音乐的任务 */
            if (xSoundTaskHandle != NULL)
            {
                LCD_ClearLine(0, 0);
                LCD_PrintString(0, 0, "Delete Task");
                vTaskDelete(xSoundTaskHandle);
                PassiveBuzzer_Control(0); /* 停止蜂鸣器 */
                xSoundTaskHandle = NULL;
            }
        }
    }
}

注意:

  • 单纯的删除任务有缺陷,屏幕仍在显示,蜂鸣器保持最后声调。【需增加操作:清屏、停止蜂鸣器】
  • 频繁创建、删除任务有缺陷,xTaskCreate 动态分配内存,多次删除后导致内存碎片化。【一般不用 vTaskDelete ,任务内做停止操作即可】

2,任务优先级和 Tick

(1)任务优先级

        有些任务对响应速度要求高,这时候需提高优先级。

        优先级的取值范围是:0~(configMAX_PRIORITIES – 1),数值越大优先级越高。

FreeRTOS的调度器可用如下两种方法快速找出优先级最高、可运行的任务:

通用方法(C 函数实现) 架构优化方法(汇编指令实现)
实现方式

纯 C 代码循环遍历所有优先级,找出最高可运行任务

利用 MCU 架构专属汇编指令(如 CLZ/BSR),从 32 位掩码中快速找最高位 1
configMAX_PRIORITIES 限制 无强制限制(理论可设任意值) 最大只能设 32(因为汇编指令仅支持 32 位数据)
内存占用 优先级数越多,内存占用越高(线性增长) 固定占用少量内存(仅需 32 位掩码),不受优先级数影响
执行效率 低(遍历耗时,优先级数越多越慢) 极高(单条汇编指令完成,耗时固定)
跨平台性 好(所有 MCU 架构通用) 差(仅支持有对应汇编指令的架构,如 ARM Cortex-M 系列)
触发条件

configUSE_PORT_OPTIMISED

_TASK_SELECTION = 0 或未定义

configUSE_PORT_OPTIMISED_TASK

_SELECTION = 1

(2)Tick

        FreeRTOS 用定时器产生固定间隔的中断。这叫 Tick 滴答,比如每10ms 发生一次时钟中断。两次中断之间的时间称为时间片 time slice,时间片的长度由configTICK_RATE_HZ 决定。

        相同优先级的任务的切换方式:任务2 从 t2 发生 tick 中断(进入 tick 中断处理函数),切换到任务1,任务1执行到 t3 也发生 tick中断。【任务运行的时间并不是严格从 t1,t2,t3 处开始

        有了 Tick 概念后,就可以用 Tick 来衡量时间了,比如:

vTaskDelay(2);  // 等待2个Tick,假设configTICK_RATE_HZ=100, Tick周期时10ms, 等待20ms

// 还可以使用pdMS_TO_TICKS宏把ms转换为tick
vTaskDelay(pdMS_TO_TICKS(100));  // 等待100ms
        注意,基于Tick实现的延时并不精确,比如vTaskDelay(2)的本意是延迟2个Tick周期,
有可能经过1个Tick多一点就返回了。

        建议以 ms 为单位,使用 pdMS_TO_TICKS 把时间转换为 Tick,增强代码健壮性,让Delay 的时间与配置项 configTICK_RATE_HZ 无关。

// 修改优先级为“Normal+1”,让音乐播放任务在并行系统中更流畅
ret = xTaskCreate(PlayMusic, "SoundTask", 128, NULL,
osPriorityNormal+1, &xSoundTaskHandle);

// 引入了 bug : 控制任务优先级低于音乐任务,无法关闭了
// 解决:用 vTaskDelay 延时主动放弃 cpu 资源

(3)修改优先级

 获得任务的优先级:

UBaseType_t uxTaskPriorityGet( const TaskHandle_t xTask );

设置任务的优先级:

void vTaskPrioritySet( TaskHandle_t xTask, UBaseType_t uxNewPriority );
// 使用参数 xTask 来指定任务,设置为 NULL 表示设置自己的优先级

3,任务状态

        以上是完整的状态转换图,可以简单地把任务的状态分为 2 种:

  • 运行 (Runing)
  • 非运行 (Not Running)

        对于非运行状态可细分:

  • 阻塞状态 (Blocked)
  • 暂停状态 (Suspended)
  • 就绪状态 (Ready)

(1)阻塞状态

        实际产品中不会让一个任务一直运行,而是用“事件驱动”的方法让它运行:

  • 任务要等待某个事件,事件发生后它才能运行
  • 等待事件过程中,不消耗 CPU 资源
  • 在等待事件的过程中,这个任务就处于阻塞状态

        处于阻塞状态的任务,可以等待两种类型的事件

        1)时间相关的时间(等待一段时间 或 等待到某个时间点)

        2)同步事件,这个事件由别的任务 或 中断程序产生,同步事件来源有很多(队列、二进制信号量、计数信号量、互斥量、事件组、任务通知 等等)

             等待同步事件时,可加上超时时间,超过等待时间后超时返回。

(2)暂停状态

        进入暂停状态,唯一的方法是通过vTaskSuspend函数。函数原型如下:

void vTaskSuspend( TaskHandle_t xTaskToSuspend );
// 参数 xTaskToSuspend 表示要暂停的任务,如果为 NULL,表示暂停自己

  要退出暂停状态,只能由别人来操作:

  • 别的任务调用:vTaskResume
  • 中断程序调用:vTaskResumeFromISR

【实际开发中,暂停状态用得不多】

(3)就绪状态

        这个任务完全准备好了,随时可以运行,只是还轮不到它。这时它就处于就绪态。

        示例代码:通过遥控器暂停、播放音乐任务。

bRunning = 1;

if (data == 0xa8) /* play */
{
    /* 要么 suspend 要么 resume */
    if (bRunning)
    {
        LCD_ClearLine(0, 0);
        LCD_PrintString(0, 0, "Suspend Task");
        vTaskSuspend(xSoundTaskHandle);
        PassiveBuzzer_Control(0); /* 停止蜂鸣器 */
        bRunning = 0;
    }
    else
    {
        LCD_ClearLine(0, 0);
        LCD_PrintString(0, 0, "Resume Task");
        vTaskResume(xSoundTaskHandle);
        bRunning = 1;
    }
}

4,Delay 函数

     有两个 Delay 函数:

  • vTaskDelay:至少等待指定个数的 Tick Interrupt 才变为就绪状态
  • vTaskDelayUntil:等待到指定的绝对时刻才能变为就绪态(保证的是两次退出延迟的时间间隔至少为 n 个 Tick不管任务执行耗时多久都能稳定保持周期性)
void vTaskDelay ( const TickType_t xTicksToDelay );
// 等待多少个 Tick


BaseType_t xTaskDelayUntil( TickType_t* const pxPreviousWakeTime,
                            const TickType_t xTimeIncrement );
// pxPreviousWakeTime: 上一次被唤醒的时间
// xTimeIncrement: 要阻塞到(pxPreviousWakeTime + xTimeIncrement)

画图说明:

  • 使用 vTaskDelay(n)时,进入、退出 vTaskDelay 的时间间隔至少是 n 个 Tick 中断
  • 使用 xTaskDelayUntil(&Pre, n)时,前后两次退出 xTaskDelayUntil 的时间 至少是 n 个 Tick 中断(使用 xTaskDelayUntil 来让任务周期性地运行

示例代码:

void LcdPrintTask(void *params)
{
    struct TaskPrintInfo *pInfo = params;
    uint32_t cnt = 0;
    int len;
    BaseType_t preTime;
    uint64_t t1, t2;

    preTime = xTaskGetTickCount();

    while (1)
    {
        if (g_LCDCanUse)
        {
            g_LCDCanUse = 0;
            len = LCD_PrintString(pInfo->x, pInfo->y, pInfo->name);
            len += LCD_PrintString(len, pInfo->y, ":");
            LCD_PrintSignedVal(len, pInfo->y, cnt++);
            g_LCDCanUse = 1;
            mdelay(cnt & 0x3);
        }

        t1 = system_get_ns();

        //vTaskDelay(500); // 500000000
        vTaskDelayUntil(&preTime, 500);
        t2 = system_get_ns();

        LCD_ClearLine(pInfo->x, pInfo->y+2);
        LCD_PrintSignedVal(pInfo->x, pInfo->y+2, t2-t1);
        //用  vTaskDelayUntil 代替 vTaskDelay:实现精准的周期性执行
        //(固定 500ms 执行一次,不受任务执行耗时影响)
        // 而 vTaskDelay 周期会随任务执行时间偏移。
    }
}

5,空闲任务及其钩子函数

        空闲任务(Idle 任务)的作用之一:释放被删除的任务内存。任务退出后需要空闲任务释放。

        为什么必须要空闲任务?   ——  良好的程序都是事件驱动的(大部分时间处于阻塞状态)【任务不是死循环,结束后要加 vTaskDelete(NULL);  创建任务时,会初始化栈,如果一个任务不经过其他处理,执行结束直接返回会进入ptvTaskExitError(关闭所有中断并进入死循环)】

        当所以任务都不执行时调度器必须能找到一个可以运行的任务(即空闲任务)。

    使用 vTaskStartScheduler() 函数来创建、启动调度器时,内部会创建空闲任务:

  • 空闲任务优先级为0,不能阻碍用户任务的运行
  • 空闲任务要么处于就绪态,要么处于运行态,永远不会阻塞

        注意:用 vTaskDelete() 来删除任务,就要确保空闲任务有机会执行,否则无法释放被删除任务的内存。

        可添加一个空闲任务的钩子函数,空闲任务循环执行一次就调用钩子函数,其作用为:

  • 执行一些低优先级、后台的、需要连续执行的函数
  • 测量系统的空闲时间(即空闲任务占据的时间)
  • 让系统进入省电模式

        其限制为:不能导致空闲任务阻塞或暂停;用vTaskDelete()删除任务时,钩子函数要高效执行(空闲任务一直在钩子函数时无法正常释放内存)

使用钩子函数的前提:   (观察 FreeRTOS/Source/tasks.c 可知)

  • 把宏 configUSE_IDLE_HOOK 定义为 1
  • 实现 vApplicationIdleHook 函数

6,任务管理与调度

(1)引入优先级 —— 调度算法

        【回顾】单处理系统中,任何时间里只能有一个任务处于运行状态。非运行的任务处于 3 种状态之一:阻塞(Blocked)、暂停(Suspended)、就绪 (Ready)。就绪态的任务可以被调度器挑选出来切换为运行状态,调度器永远都是挑选最高优先级的就绪态任务进入运行状态。阻塞状态的任务等待事件发生时就会进入就绪状态(时间相关事件 或 同步事件)。

        【配置调度算法】

        为了确定哪个就绪态的任务可以切换为运行状态,通过配置一些配置项确定调度算法。

  • A:可抢占+时间片轮转+空闲任务让步
  • B:可抢占+时间片轮转+空闲任务不让步
  • C:可抢占+非时间片轮转+空闲任务让步
  • D:可抢占+非时间片轮转+空闲任务不让步
  • E:合作调度

        (1)配置项:configUSE_PREEMPTION:高优先级的任务能否优先执行,即抢占调度。若为0 则更高优先级的任务需等待当前任务主动让出 CPU 资源。

        (2)配置项:configUSE_TIME_SLICING:在可抢占的前提下,同优先级的任务是否轮流执行。若配置为 1 ,则同优先级的任务轮流执行;反之需要主动放弃或被高优先级任务抢占。

        (3)配置项:configIDLE_SHOULD_YIELD:在"可抢占"+"时间片轮转"的前提下,细化空闲任务是否让步于用户任务。若为 1,空闲任务低人一等,每执行一次循环就看看是否主动让位给用户任务;反之空闲任务与用户任务轮流执行。

最常用的机制:① 相同优先级的任务轮流运行; ② 最高优先级的任务先运行

(2)任务管理 —— 链表操作

        FreeRTOS设置中有优先级个数的设置(MAX_PRORITIRES),Normal对应24优先级,多个任务会放入一个链表里(pxReadyTasksLists[ ] ,xDelayedTaskList?[ ] 等等)

        调用osKernelStart启动内核时,会启动调度器 vTaskStartScheduler,进而创建空闲任务prvIdleTask(优先级为0)

        创建任务时,会把新任务添加进ReadyList (根据优先级找到),还有当前任务全局指针pxCurrentTCB的判断(解释了为何最后创建的任务最先运行

        主要关注的是tick中断和调度逻辑

        详细的各种状态的切换说明看 FreeRTOS 笔记 p22,或对应视频。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐