✅作者简介:嵌入式入坑者,与大家一起加油,希望文章能够帮助各位!!!!
个人主页:@rivencode的个人主页
系列专栏:玩转FreeRTOS
推荐一款模拟面试、刷题神器,从基础到大厂面试题点击跳转刷题网站进行注册学习

前言

本文正式开启对FreeRTOS内核的研究,首当其冲的就是就是任务调度器的实现,任务调度器作为FreeRTOS的核心,因为FreeRTOS的本质就是任务的轮流运行,为了循序渐进的学习,避免一口吃成一个大胖子,本文只实现两个任务的切换,先不管任务的优先级,本文的目的就是将任务切换的全过程分析的透透彻彻,在此之前要深入理解FreeRTOS链表操作,还有ARM架构的知识,我已经帮你们总结好了。
《FreeRTOS-链表的源码解析》
《FreeRTOS-ARM架构与程序的本质》
《FreeRTOS-ARM架构深入理解》

在《FreeRTOS-ARM架构深入理解》文章里面已经将任务切换的实质及其过程分析的很清楚了,本文主要看看代码如何实现一个任务调度器。

目录

  • 前言
  • 一.创建任务
    • 1.定义任务的栈
    • 2.定义任务函数
    • 3.定义任务控制块
    • 4.任务创建函数(重点)
  • 二.就绪链表
  • 三.任务调度器(重点)
    • 1.启动调度器
    • 2.任务切换
  • 总结

一.创建任务

什么是任务?
简单来说任务FreeRTOS中就是一个无限循环无法返回的函数,在多任务系统中,我们根据功能的不同,把整个系统分割成一个个独立的且无法返回的函数,这个函数我们称为任务,一个任务负责项目的一块功能,然后各任务在调度器的作用下轮流运行,只要任务切换的够快,比如说任务A运行1ms,又切换任务B运行1ms,然后又切换任务A运行1ms,如此反复切换任务,虽然它们是轮流运行但是1ms在人的眼里可以说是忽略不计了,所以我们看起来所有任务都是同时在运行。

而且一个任务函数需要满足一下要求:

  • 1.这个函数不能返回,无限循环。
  • 2.同一个函数,可以用来创建多个任务;换句话说,多个任务可以运行同一个函数。
  • 3.每个任务都有自己的栈,每个任务运行这个函数时任务A的局部变量放在任务A的栈里、任务B的局部变量放在任务B的栈里,函数内部所有栈的开销都是使用任务自己的栈。
  • 4.函数使用的全局变量、静态变量存放在内存的某个区域,所有任务共用,不过要防止使用冲突,所以尽量使用局部变量。

1.定义任务的栈

对于什么是栈,什么是堆,局部变量、全局变量、静态变量,代码存储什么地方。
请看《FreeRTOS-ARM架构与程序的本质》这些基础的ARM架构的知识我就不再赘述了

多任务系统中,每个任务都是独立的,互不干扰的,所以要为每个任务都分配独立的栈空间,这个栈空间通常是一个预先定义好的全局数组,也可以是动态分配的一段内存空间,但它们都存在于 RAM(内存中) ,其实就像我们裸机分配栈一样,栈就是一块空闲的空间只是存在一些使用的特性而已,除了任务分配的栈之外,还有一个主栈(就是在我们启动文件里面定义的栈)供FreeRTOS内核使用以及中断服务函数使用,而任务中的函数就使用该任务的栈。


所谓定义一个任务栈,其实就是分配一个空闲的内存空间(可以是一个全局的数组,也可以是malloc动态分配的空间(其实是在堆上分配一块内存))作为任务的栈,供任务使用,我们这里采用全局的数组来充当任务的栈,缺点是任务被销毁,这个任务栈无法回收。

在 FreeRTOS 中,凡是涉及到数据类型的地方,FreeRTOS 都会将标准的 C 数据类型用 typedef 重新取一个类型名。这些经过重定义的数据类型放在 portmacro.h。

2.定义任务函数


任务就是一个独立的函数,每个任务实现它相应的功能,各任务独立运行,轮流运行,当切换的够快时,相当于各任务在同时运行。

3.定义任务控制块

而在多任务系统中,任务的执行是由系统调度的。系统为了顺利的调度任务,为每个任务都额外定义了一个任务控制块,而这个任务块其实就是一个任务结构体,这个任务结构体包括任务的所有信息,比如任务的栈指针,任务名称,任务的形参,任务的优先级,任务的状态等。有了这个任务控制块,FreeRTOS 对任务的全部操作都可以通过这个任务控制块来实现。

由于我们先不考虑任务的优先级,所以把任务结构体的里面的优先级成员与任务状态成员删除了等后面专门出一篇文章来讲优先级,我们先实现任务调度器。

  • pxTopOfStack:栈顶指针,指向分配的任务栈顶部
  • xStateListItem:任务结点,这是一个内嵌在任务结构体中的链表结点,而这个任务结构体就是这个任务结点的容器container,任务结构体通过这个链表结点,可以将任务控制块插入任意链表中。在《FreeRTOS-链表的源码解析》中关于链表我讲得非常清晰了。
  • pxStack:任务栈的起始地址,其实就是我们分配的全局数组的首元素的地址。
  • pcTaskName:任务名称,字符串形式,长度由宏 configMAX_TASK_NAME_LEN来控制,该宏在 FreeRTOSConfig.h 中定义,默认为 16。

然后一个任务定义一个任务块(任务结构体)。

4.任务创建函数(重点)

任务除了任务结构体上面的属性之外,不是每个任务都有一个函数和一个函数参数嘛,他们也作为任务的信息为啥没有在任务结构体中出现呢,玄机就在任务创建函数之中,任务的栈,任务的函数实体及其任务的参数,任务的控制块最终需要联系起来才能由系统进行统一调度。那么这个联系的工作就由任务创建函数 xTaskCreateStatic()来实现

  1. TaskFunction_t pvTaskCode :函数指针,指向任务函数,就是函数名(函数地址)

    TaskFunction_t 就是一个函数指针类型,而pvTaskCode 就是用这个类型定义出来的函数指针变量(存储一个函数的地址或者说指向一个函数)。
    关于函数指针的知识请看:《指针从入门到熟练掌握》基础知识只能是提一下不然知识体量太大了讲不完。

  2. const char * const pcName:任务的名字,FreeRTOS内部不使用它,仅仅起调试作用,这个没啥好讲的可以看看const关键字的作用:《static,const,volatile,extern,register关键字深入解析》

  3. const uint32_t ulStackDepth:表示定义的任务栈的深度,其实就是栈的大小单位是字(四个字节)

  4. void * const pvParameters:任务函数的形参,参数的类型任意所以用void * 类型

  5. StackType_t * const puxStackBuffer:任务栈的起始地址,在这其实就是我们分配的全局数组的首元素的地址。

  6. TCB_t * const pxTaskBuffer:任务控制块指针(传入任务控制块的地址)

函数实体如下详情看注释


任务的创建有两种方法,一种是使用动态创建,一种是使用静态创建。动态创建时,任务控制块和栈的内存是创建任务时动态分配的,任务删除时,内存可以释放。静态创建时,任务控制块和栈的内存需要事先定义好,是静态的内存 , 任务删除时内存不能释放,这里采用的是静态创建
当执行完下面的代码

在xTaskCreateStatic()函数里面又调用了一个prvInitialiseNewTask()函数进行任务的初始化。

prvInitialiseNewTask()函数实体如下图:

接下来就是逐句分析代码:

  • 1.定义一个任务栈顶指针,再定义一个无符号整形变量x


2.获取任务栈顶地址


3.任务栈顶地址向下做8字节对齐

  • 什么是8字节对齐?
    简单来说栈顶地址要是8整数倍。

  • 为什么要8字节对齐?
    我们知道M3是32位的(4字节),但是涉及到浮点运算,比如double类型的浮点数是8字节的为了兼容他们采用8字节对齐。

  • 如何实现8字节对齐?

  • 4.将任务名字存储该任务控制块name数组成员中

  • 5.初始化结点,设置结点的拥有者

    初始化结点,就一个操作:

    设置结点拥有者,不就是任务结构体(TCB)嘛,不懂的去查《FreeRTOS-链表的源码解析》

  • 6.初始化任务栈(这个是关键的关键)

    调用一个任务栈初始化函数pxPortInitialiseStack

    这个函数是我们的重点:它要去伪造一个现场,伪造什么现场?
    《FreeRTOS-ARM架构深入理解》里面我们已经知道切换任务需要去保存CPU寄存器的数值

下面8个寄存器由硬件帮我们保存


还有R4~R11需要我们自己保存。

  • 但是刚开始任务还没有运行的时候,谁来保存这些寄存器?,肯定需要我们自己去伪造一个现场,为什么需要这样?

原因就是为了统一使用中断函数来启动或切换任务,全权交给系统去做,而不是说由我们用户去启动一个任务,所以我们需要去伪造一个现场(由我们自己来保存全部的寄存器因为没有硬件来帮我们保存)

那这个现场应该怎样保存,等它恢复的时候才能顺利启动第一个任务?
逐句代码来分析一下:

1).伪造xPSP寄存器:

  • 为什么xPSP寄存器第24位要为1?


入栈后:

  • 2).伪造PC寄存器(中断返回地址),弄成任务函数地址(重点)


入栈后:

  • 3).伪造LR(返回地址),因为任务函数是一个无限循环函数,一般不会返回,返回就是发送了错误

    入栈后:
  • 4).伪造的R12, R3, R2 ,R1 默认初始化为0


入栈后:

  • 5).伪造的R0函数参数(重点)

    入栈后:
  • 6).伪造最后的R4~R11初始化为0就好了


自此现场伪造完毕。

  • 7).返回栈顶指针,存入任务结构体中的pxTopOfStack成员变量中,下次就可以从该栈顶位置恢复伪造的现场,启动第一个任务


    总结:
    创建任务函数最重要的就是伪造现场,而伪造现场最重要的就是将任务函数地址放入PC寄存器,任务函数参数放入R0寄存器,当启动第一个任务时将现场恢复时(将栈里的寄存器数值恢复到CPU寄存器中),则PC寄存器就存放了任务函数的地址,R0就存放了函数的参数,则PC为程序计数器,则会程序会跳转至该任务函数执行,而R0寄存器就是该任务函数的参数传递过去。
  • 7.xTaskCreateStatic()函数最后一步就是将让任务句柄指向任务控制块,这个任务句柄就指向初始化完成了任务控制块,有了这个任务句柄就要操作这个任务的一切东西。

此时任务创建全部完毕
总结一下:
除了去初始化任务控制块的成员变量,最重要的一步还是去任务栈里面伪造现场,这下就能解释为什么任务控制块里面没有体现出任务函数及其参数,其实都保存在任务的栈中,然后再保存新的栈顶指针到任务控制块(方便下次从该位置恢复寄存器),等启动任务时恢复现场将运行第一个任务。

二.就绪链表

任务创建好之后,我们需要把任务添加到就绪链表里面,表示任务已经就绪,系统随时可以调度。


就绪链表实际上就是一个 List_t 类型的数组,数组的大小由决定最 大 任 务 优 先 级 的 宏 configMAX_PRIORITIES 决 定 , configMAX_PRIORITIES 在FreeRTOSConfig.h 中默认定义为 5,最大支持 256 个优先级。数组的下标对应了任务的优先级,同一优先级的任务统一插入到就绪列表的同一条链表中,任务调度的时候就是从高优先级的链表开始遍历,确保高优先级的任务先执行。

  • 就绪列表初始化

    初始化链表之后:
    五个双向循环链表,代表五个优先级,同样优先级的任务挂载在同一个链表之中,然后任务调度的时候到就绪链表中先挑最高优先级的任务开始执行。

链表的知识请看:《FreeRTOS-链表的源码解析》

将任务插入到就绪列表

现在我们先不谈任务优先级,后面专门出文章来深入研究,所以我们随便插入到一个链表中就行,但是如何将任务控制块插入到一个链表中嘞,任务控制块中会有一个链表结点,通过这个结点可以将任务控制块间接的挂载到链表之中,而通过这个结点的pvowner这个成员可以访问包含它的任务控制块。

将任务控制块添加到就绪链表中
其实就是调用一个链表插入结点函数将任务挂入链表之中。

就绪链表的数组的下标对应的是任务的优先级,但是现在先不讲优先级,所以 Task1 和 Task2 任务在插入到就绪列表的时候,可以随便选择插入的位置。,我们选择将 Task1 任务插入到就绪列表下标为 1 的链表中,Task2 任务插入到就绪列表下标为 2 的链表中。
像下图一样

三.任务调度器(重点)

任务调度器就是操作系统的核心,而任务调度的核心保存现场和恢复现场(就是那些CPU寄存器)。

1.启动调度器

调度器的启动由 vTaskStartScheduler()函数来完成

pxCurrentTCB :用于指向当前正在运行或者即将要运行的任务的任务控制块。目前我们还不支持优先级,则手动指定第一个要运行的任务。

调用函数 xPortStartScheduler()启动调度器


然后去启动第一个任务prvStartFirstTask();


逐句代码分析:

  • 1.设置主堆栈的指针MSP的值



其实一步多余了,在上电执行Reset_Handler函数时已经更新为向量表的起始值,即指向主栈的栈顶指针。
详情请看:STM32-启动文件详解

  • 2.使用 CPS 指令把全局中断打开


    详情看:《FreeRTOS-ARM架构深入理解》

3.调用SCV中断函数启动第一个任务(重点)


1)将任务的栈顶指针赋给R0寄存器



关键来了,此时R0就是原来任务1初始化栈(保存现场)后保存的栈顶地址,现在就是从栈顶任务1的栈顶开始恢复现场启动第一个任务。

2)从栈里恢复R4~ R11数值到CPU R4~R11寄存器中
关于指令ldmia r0!, {r4-r11} 用法请看《FreeRTOS-ARM架构深入理解》


3)将r0的值(任务的栈顶指针)更新到psp(任务的栈指针),准备触发中断返回恢复硬件保存的那8个寄存器


4)设置 basepri 寄存器的值为 0,即打开所有中断。basepri 是一个中
断屏蔽寄存器,大于等于此寄存器值的中断都将被屏蔽。

5)当从 SVC 中断服务退出前,通过向 r14 寄存器最后4位按位或上0x0D,使得硬件在退出时使用进程堆栈指针 PSP完成出栈操作并返回后进入线程模式、返回 Thumb 状态。在 SVC 中断服务里面,使用的是 MSP 堆栈指针,特权模式。

通过向 r14 寄存器最后4位按位或上0x0D,使得硬件在退出时使用进程堆栈指针 PSP完成出栈操作并返回后进入任务模式、返回 Thumb 状态。

设置好特殊中断返回值后,利用下列指令返回BX LR(0xFFFF FFFD)返回到线程模式然后使用任线程堆栈指针PSP(指向任务的栈前面已经设置好了指向任务1的栈顶位置从这里开始恢复那8个寄存器)。

异常返回,这个时候出栈使用的是 PSP 指针,自动将栈中的剩下内容加载到 CPU 寄存器: xPSR,PC(任务函数入口地址),R14,R12,R3,R2,R1,R0(任务的形参)同时 PSP 的值也将更新,即指向任务栈的栈顶,此时程序跳转至任务函数处执行则R0做为函数的参数,由PSP栈指针维护任务1的栈,开始运行第一个任务。

中断返回的细节以及双堆栈机制(MSP与PSP)请参考:《FreeRTOS-ARM架构深入理解》

  • 这里为什么pxTopOfStack没有更新到栈顶呢?
    因为pxTopOfStack只是记录保存现场之后的栈顶位置(方便下一次恢复现场从什么位置开始恢复),而且维护任务栈的是PSP与pxTopOfStack无关,所以pxTopOfStack只在保存现场完之后更新。

2.任务切换

任务切换就是在就绪列表中寻找优先级最高的就绪任务,然后去执行该任务。但是目前我们还不支持优先级,仅实现两个任务轮流切换,任务切换函数 taskYIELD()

任务切换其实是由PendSV中断来完成的,portYIELD 的实现很简单,实际就是将 PendSV 的悬起位置 1,当没有其它中断运行的时候响应 PendSV 中断,去执行我们写好的 PendSV 中断服务函数(因为PendSV中断优先级最低,是为了不影响其它中断(紧急的事情)的响应),在PendSV实现任务切换。

  • 怎么悬起PendSV中断?


悬起PendSV中断后,等没有中断运行的时候,PendSV中断服务函数开始执行开始切换任务。

接下来就是逐句分析代码:

假设是任务A切换至任务B

任务A正在运行时,突然发生的PendSV中断,发生中断的瞬间需要保存现场PSP栈指针指向任务A的栈顶,在PSP位置硬件开始保存8个寄存器: xPSR,PC(任务入口地址),R14,R12,R3,R2,R1,R0(任务的形参)这些 CPU 寄存器的值会自动存储到任务A的栈中。

1)将PSP的值存入R0寄存器,准备开始软件保存(自己保存)R4~R11。

2)将当前任务控制块指针存入R2寄存器(为后面更新pxTopOfStack(记录保存现场的位置方便下次恢复))。

3)软件保存R4~R11到任务A栈里


4)重点来了,此时我们已经保存完了任务A的现场,但是我们需要记录栈顶位置方便下次从这里恢复现场,此时由pxTopOfStack来保存。

5)入栈R3与R14到主栈(因为中断是使用MSP主栈指针),虽然中断中使用的是主堆栈MSP,只代表此时的SP是MSP指向主堆栈,并不是说在中断服务函数里面不能操作任务的栈,只要知道任务栈的地址就可以操作

  • 为什么要入栈R3与R14?

R3寄存器前面已经保存了pxCurrentTCB的地址,而R14是进入中断前R14被更新为一个特殊值(用于中断返回硬件恢复寄存器),而后面马上要调用函数vTaskSwitchContext一旦调用函数LR(函数返回地址)的值将被修改,R3的值在函数vTaskSwitchContext也可能被改所以先入栈保存。


6)关中断去保护就绪列表的数据被其他中断修改,而我们没有实现优先级但是也要修改当前pxCurrentTCB(是一个全局变量)指针指向下一个任务,要防止其他中断打断pendSV修改它,保护临界资源

7)调用函数 vTaskSwitchContext。该函数在 task.c 中定义,作用只有
一个,选择优先级最高的任务,然后更新 pxCurrentTCB。目前我们还不支持优先级,则手动切换,不是任务 1 就是任务 2


经过任务切换pxCurrentTCB指向任务B

8)关中断,退出临界区

9)从主栈中恢复R3,R14寄存器的数值到CPU寄存器中。

10)取出下一个任务(pxCurrentTCB任务B)的栈顶地址存入R0寄存器


11)软件恢复下一个任务(任务B)的R4~R11


11)让PSP指向任务B的栈顶等下异常退出时,会以 psp 作为基地址,将任务栈中剩下的内容(R0(任务形参)、R1、R2、R3、R12、R14
(LR)、R15(PC)和 xPSR)自动加载到 CPU 寄存器。


12)最后一步中断返回bx r14(LR在进中断之前被更新为特殊值),返回到线程模式,使用进程栈指针PSP(指向任务B的栈由PSP来维护任务B的栈),R0(任务形参)、R1、R2、R3、R12、R14(LR)、R15(PC)和 xPSR从栈里恢复到CPU中


自此任务切换完毕,任务B开始运行,由进程栈指针PSP维护任务B的栈。

总结:

  • 1.其他8个寄存器由硬件帮我们保存在栈里,寄存器R4~R11由我们自己保存在栈中,而这个所谓的栈就是当前任务的栈。
  • 2.保存当前PSP数值(保存当前任务的栈的位置,等下一次切换到它的时候方便找到该任务的栈,然后恢复该任务的现场)
  • 3.找到下一个任务的栈顶,恢复下一个任务的上一次保存在栈里的R4~R11
  • 4.将PSP设置为下一个任务的栈顶位置准备中断返回
  • 5.利用中断返回,硬件自动恢复上次保存在栈中的8个寄存器

总结一句话就是:任务切换,就是保存上一个任务的现场,然后恢复下一个任务的现场,其中PSP进程栈指针在切换任务时也要跟着切换到指向下一个任务的栈,这样才能保存一个任务一个栈自己使用自己的栈互不干扰。而还有一个主栈由系统内核使用或者中断服务函数使用。

总结

任务调度器是FreeRTOS的核心中的核心,本篇文章在于把任务切换的整个过程画图的形式呈现出来,深入理解任务调度的本质,其中涉及的ARM相关基础知识,基础汇编指令,中断返回机制,操作系统双堆栈机制(主堆栈指针MSP与线程堆栈指针PSP),处理器的操作模式以及两个特权模式,最后的SysTick、SVC、PendSV中断的作用详情参考:《FreeRTOS-ARM架构深入理解》相信我等你看完这篇文章会对任务调度有更深层次的理解,彻底理解任务调度器。

结束语:
最近发现一款刷题神器,如果大家想提升编程水平,玩转C语言指针,还有常见的数据结构(最重要的是链表和队列)后面嵌入式学习操作系统的时如freerots、RT-Thread等操作系统,链表与队列知识大量使用。
大家可以点击下面连接进入牛客网刷题

点击跳转进入网站(C语言方向)
点击跳转进入网站(数据结构算法方向)