vesperW · 2023年12月01日

RTOS内功修炼记(一)—— 任务到底应该怎么写?

本篇文章讲述了任务的三大元素:任务控制块、任务栈、任务入口函数,并讲述了编写RTOS任务入口函数时三个重要的注意点。

1. 知识点回顾

在正式开始讲解内容之前,我会先回顾一下基础知识点,请确保你已经了解并掌握。

1.1. 任务的创建方法

在用户层调用API创建一个任务,通常的流程如下:

① 创建一个数组作为任务栈:

#define TASK1_STACK_SIZE    512  
k_stack_t task1_stack[TASK1_STACK_SIZE];  

② 创建一个任务控制块:

k_task_t    task1;  

③ 编写任务入口函数:

void task1_entry(void *arg)  
{  
    while(1)  
    {  
        printf("task1 is running\r\n");  
        tos_task_delay(1000);  
    }  
}  

④ 调用系统API创建任务:

ret = tos_task_create(&task1,  
                      "task1",  
                      task1_entry,  
                      NULL,  
                      TASK1_PRO,  
                      task1_stack,  
                      TASK1_STACK_SIZE,  
                      10);  

创建之后任务为就绪态(处于系统就绪队列中),等待系统调度器调度执行。

1.2. STM32内存分布

请先阅读文章:

阅读之后,你应该要知道,STM32(Cortex-M3)中Flash和SRAM的内存空间如下:

image.png

其中Flash存储空间中又分为文本段、只读数据段、复制数据段:

image.png

其中SRAM存储空间中又分为data数据段、bss数据段、堆空间、栈空间:

image.png

并且还要知道不同的变量类型,它对应的存储位置在哪里,如果没有,一定要阅读上文之后再回来看,这是理解之后内容的基础。

1.3. Cortex-M3/4系列内核

CrortexM3/4系列内核中的寄存器组都有16个寄存器,如图所示,寄存器组通常都是CPU用于数据处理和运行控制的,希望你可以大概知道每个寄存器的作用:

image.png

① R0-R12:通用寄存器,用于数据操作;

② R13:栈顶指针,有两个互斥的指针MSP和PSP,在任一时刻只能使用其中一个;

③ R14:连接寄存器,调用子程序时存放返回地址;

④ R15:程序计数器,PC指针指向哪里,CPU就执行哪里的代码;

在RTOS内核中,这16个寄存器组的值称之为「上下文环境」,即当前任务运行时这16个寄存器中的值称为上文环境,下一个任务运行时这16个寄存器的值称为下文环境,「上下文切换」就是指将这16寄存器组的值修改为下一个任务的值。

1.4. 栈

栈是一种「只能在一端插入或者删除元素」的数据结构,规则为:「先入后出」(FILO)。

image.png

在C语言程序运行的时候,栈是非常非常非常重要的,在裸机程序中,栈顶指针由寄存器R13给出。

栈的作用,一方面是局部变量的存储,局部变量的定义会被汇编为PUSH 指令,将局部变量中的内容压入栈中,在函数执行完毕之后出栈,该局部变量被销毁;另一方面是函数调用时的参数传递,也会被压入栈中,在函数执行完毕后出栈。

2. 任务控制块长啥样

任务控制块是一个任务的核心,广义的讲:「内核所有对任务的操作,其实都是在操作任务控制块」

任务控制块类型k_task_t是一个结构体类型:

typedef struct k_task_st    k_task_t; 

当定义了一个任务控制块时,该结构体变量没有初始值,所以「存储位置在STM32内部SRAM中的bss段内」

任务控制块的结构体类型定义如下:

/**  
 * task control block  
 */  
struct k_task_st {  
    k_stack_t          *sp;                     /**< task stack pointer. This lady always comes first, we count on her in port_s.S for context switch. */  
  
    knl_obj_t           knl_obj;                /**< just for verification, test whether current object is really a task. */  
  
    char                name[K_TASK_NAME_MAX];  /**< task name */  
    k_task_entry_t      entry;                  /**< task entry */  
    void               *arg;                    /**< argument for task entry */  
    k_task_state_t      state;                  /**< just state */  
    k_prio_t            prio;                   /**< just priority */  
  
    k_stack_t          *stk_base;               /**< task stack base address */  
    size_t              stk_size;               /**< stack size of the task */  
  
  
  
    k_list_t            stat_list;              /**< list for hooking us to the k_stat_list */  
  
    k_tick_t            tick_expires;           /**< if we are in k_tick_list, how much time will we wait for? */  
  
    k_list_t            tick_list;              /**< list for hooking us to the k_tick_list */  
    k_list_t            pend_list;              /**< when we are ready, our pend_list is in readyqueue; when pend, in a certain pend object's list. */  
      
  
    pend_obj_t         *pending_obj;            /**< if we are pending, which pend object's list we are in? */  
    pend_state_t        pend_state;             /**< why we wakeup from a pend */  
};  

此处引用的源码「不完整」,方便阅读起见,所有使用宏开关配置的定义全部省略。

任务控制块中的内容主要分为三部分:

① 任务栈栈顶指针sp:接下来会重点讲解;

② 任务的全部信息:任务名称、任务状态、任务优先级、任务入口函数及参数、任务栈地址和大小;

③ 任务的链表:后续文章中重点讲解。

3. 任务栈

3.1. 任务栈是什么

任务栈类型 k_stack_t 是一个 uint8_t 类型:

typedef uint8_t             k_stack_t; 

当定义了一个任务栈数组时:

#define TASK1_STACK_SIZE    512  
k_stack_t task1_stack[TASK1_STACK_SIZE]; 

本质上还是一个uint8_t类型的全局变量数组,该全局变量数组没有初始值,所以「存储位置仍在STM32内部SRAM中的bss段内」

在使用该数组的时候,只通过指针sp访问,假装它是一个栈,在使用上和栈的使用方式一模一样,所以称之为任务栈。

3.2. 任务栈中有什么(作用)

在创建任务的API中,有这样一句代码来初始化任务栈,并且返回任务栈的栈顶指针sp:

task->sp = cpu_task_stk_init((void *)entry, arg, (void *)task_exit, stk_base, stk_size);  

查看cpu_task_stk_init函数的定义,会发现「不同的CPU结构,该函数的实现不同」

image.png

为什么不同的CPU结构,会导致任务栈的初始化代码实现不同呢?

不急,让我们先来看看如何来初始化任务栈,「Cortex-M系列芯片的内核对应的都是ARM v7m架构」,选取此架构中的 cpu_task_stk_init 函数实现来探索问题的答案。

① 获取任务栈栈顶指针的地址并对齐:

cpu_data_t *sp;  
  
sp = (cpu_data_t *)&stk_base[stk_size];  
sp = (cpu_data_t *)((cpu_addr_t)sp & 0xFFFFFFF8); 

② PendSV异常发生时自动保存的寄存器:

/* auto-saved on exception(pendSV) by hardware */  
*--sp = (cpu_data_t)0x01000000u;    /* xPSR     */  
*--sp = (cpu_data_t)entry;          /* entry    */  
*--sp = (cpu_data_t)exit;           /* R14 (LR) */  
*--sp = (cpu_data_t)0x12121212u;    /* R12      */  
*--sp = (cpu_data_t)0x03030303u;    /* R3       */  
*--sp = (cpu_data_t)0x02020202u;    /* R2       */  
*--sp = (cpu_data_t)0x01010101u;    /* R1       */  
*--sp = (cpu_data_t)arg;            /* R0: arg  */  

③ 手动保存/加载的寄存器:

*--sp = (cpu_data_t)0x11111111u;    /* R11      */  
*--sp = (cpu_data_t)0x10101010u;    /* R10      */  
*--sp = (cpu_data_t)0x09090909u;    /* R9       */  
*--sp = (cpu_data_t)0x08080808u;    /* R8       */  
*--sp = (cpu_data_t)0x07070707u;    /* R7       */  
*--sp = (cpu_data_t)0x06060606u;    /* R6       */  
*--sp = (cpu_data_t)0x05050505u;    /* R5       */  
*--sp = (cpu_data_t)0x04040404u;    /* R4       */

④ 返回当前栈顶指针:

return (k_stack_t *)sp;  

初始化后任务栈中的内容如下:

image.png

任务切换的大致流程是触发PendSV异常,在异常处理函数中使用汇编语言实现任务切换,也就是「上下文切换」,在接下来的文章中会专门讲述任务切换。

当该任务被调度执行时,CPU会自动将任务栈中最前面的8个寄存器值加载到CPU寄存器中,完成「下文环境切换」,此时:

  • 栈顶指针寄存器R13中的值是该任务的任务栈的sp指针;
  • 程序计数器指针PC指向的是该任务的入口函数entry;

接下来CPU中的环境就是该任务的环境,该任务开始运行。

因为栈顶指针指向的是该任务的任务栈,所以此时若在任务的入口函数中传递参数,调用函数,创建局部变量,「所有数据都被压入到该任务的任务栈中」,与STM32内部的栈空间毫无关系。

同理,当任务执行完毕时(不一定是程序结束,而是调度器需要去调度执行别的任务了),因为栈具有「后入先出」的规则,CPU再将当前寄存器组的值压入到栈中,完成「上文环境保存」,下次再需要被加载时,这些寄存器组的值将首先出栈。

最后揭晓问题答案,因为「不同的CPU架构,CPU寄存器组的数量、功能都不同」,所以需要针对每种CPU架构都要有一个实现。

4. 任务到底应该怎么写

在学习RTOS的时候,我们的关注点都是“如何创建任务”,将重点放在了创建任务的API上,而忽略了一些最重要的问题。

重点①:「任务入口函数,并不是一个普通的函数」

任务入口函数,通常它都伪装成了一个普通函数,不像main函数那样鹤立鸡群,所以很多时候我们觉得它就是一个普通函数调用,实则不然。

「每一个任务的entry,首先应该是一个独立的裸机程序。」

为什么这么说?因为多任务操作系统的机制是抢占式调度和时间片轮转,无论再怎么牛逼,也无法改变CPU中只有一个CPU的事实,所以无论在任何一个时刻,系统中都只有唯一一个任务在运行。

重点②:「每写一行代码,都要思考任务栈是否足够」

在任务入口函数中创建的局部变量,函数调用,函数传参,都使用的是该任务的任务栈,和STM32内部栈空间没有任何关系,所以在编写的时候一定要时刻思考自己指定的任务栈大小是否足够,特别是在开辟局部变量数组的时候,调用一些库的API的时候。

而在任务入口函数中,如果定义的是static变量,则不会存放到任务栈中,存放位置在STM32内部SRAM中的bss区域内。

除此之外,其余代码都属于可执行代码,存放在Flash中Text区域中的Executable Code段,大可不必太在意。

重点③:「尽量尽量要主动释放CPU,切忌浪费CPU」

在裸机程序中,如果你动不动喜欢写个死循环延时,尚可原谅,但是在RTOS系统中,如果一个任务在死循环做无用功,而导致其它任务得不到调度执行,将是不可饶恕的。

在编写任务入口函数的时候,一定要遵循“不使用,就让出”的原则,做一个高素质的任务,最普遍的做法是使用系统提供的delay函数来延时。

这样做有非常多的优点,一方面是防止系统发生堵塞,导致其它任务得不到运行;另一方面是使系统中的空闲任务可以在空闲的时候回收系统内存资源,进入低功耗模式等骚操作。

本节内容就讲到这里,希望对你有所帮助,我是Mculover666,一个喜欢玩板子的小码农,下期文章再见~

作者:Mculover666
文章来源:Mculover666

推荐阅读

欢迎大家点赞留言,更多Arm技术文章动态请关注极术社区嵌入式客栈专栏欢迎添加极术小姐姐微信(id:aijishu20)加入技术交流群,请备注研究方向。
推荐阅读
关注数
2881
内容数
261
分享一些在嵌入式应用开发方面的浅见,广交朋友
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息