欢迎光临
我们一直在努力

FreeRTOS(内部机制1)

前言:在我们前面学习的 FreeRTOS 中,我们了解到 FreeRTOS 就是帮助我们在多个任务之间通过 tick 中断来互相切换运行,从而实现了人们视角上的同时运行多个任务。那么,什么是任务呢?

1. ARM架构基础与任务上下文

在深入理解FreeRTOS任务切换之前,有必要回顾一下ARM Cortex-M系列处理器的基本运行模型,因为任务切换的本质就是处理器上下文(Context)的保存与恢复。

1.1 核心硬件组件回顾

一个典型的ARM嵌入式系统包含:

  • CPU:执行指令的核心,内部包含通用寄存器、程序计数器(PC)、链接寄存器(LR)、堆栈指针(SP) 等。
  • 内存(RAM):用于存放运行时的数据,包括全局变量、堆(heap)以及每个任务的栈(Stack)。
  • Flash:存储程序代码(指令)和常量数据。CPU上电后从Flash中读取指令并执行。
  • 外设硬件:通过内存映射的寄存器进行控制。

1.2 指令执行与内存访问

CPU通过程序计数器(PC) 从Flash中取出指令,解码后执行。这些指令很多都涉及对内存的读写操作,例如:

  • 加载指令:如 LDR R0, [R1],将内存地址R1处的数据读入寄存器R0。
  • 存储指令:如 STR R0, [R1],将寄存器R0的值写入内存地址R1处。
  • 栈操作指令:如 PUSH {R0-R3} 将多个寄存器值压入当前栈(内存中),POP {R0-R3} 则从栈中恢复。

在这里插入图片描述

1.3 任务栈与CPU寄存器

这是理解任务切换的关键:

  • 每个任务拥有独立的栈:在FreeRTOS创建任务时,会为每个任务在内存(RAM)中分配一块独立的区域作为其任务栈(Task Stack)。栈用于保存:

    • 函数调用的返回地址、局部变量。
    • 任务被切换出去时,其完整的CPU寄存器状态(即任务上下文)。
  • CPU中的16个核心寄存器:以ARM Cortex-M3/M4为例,其核心寄存器组包括:

    • R0-R12:通用寄存器,用于数据操作和临时存储。
    • R13 (SP):堆栈指针,指向当前任务的栈顶。
    • R14 (LR):链接寄存器,保存函数调用的返回地址。
    • R15 (PC):程序计数器,指向下一条要执行的指令。
    • xPSR:程序状态寄存器,包含条件标志位(如零标志、进位标志)和执行状态。
  • 下面我们来举个例子:
    在这里插入图片描述
    在这里插入图片描述

    示例:函数调用中的栈与寄存器操作

    为了更好地理解栈和寄存器如何协作,我们来看一个简单的函数调用过程:

  • 进入函数,建立栈帧:当CPU开始执行一个函数时,会为该函数在内存中划分一小块区域作为其栈帧(Stack Frame)。编译器生成的代码通常会调整栈指针(SP)来“开辟”这块空间。
  • 保存上下文:接着,通过 PUSH 汇编指令,将一些需要保存的寄存器值按顺序压入这个栈帧中。例如:
    • 将链接寄存器(LR) 的值(即函数的返回地址)保存到栈中。
    • 将用作局部变量的寄存器(如 R3)的当前值也保存到栈中。
    • 可能还包括其他被调用者需要保存的寄存器(根据调用约定)。
      这样,函数执行期间就可以自由使用这些寄存器,而不会破坏调用者的数据。
  • 执行函数体:函数内部的代码开始运行,可能会使用栈帧来存放局部变量,或进行其他计算。
  • 恢复上下文并返回:函数执行完毕准备返回时,过程相反:
    • 通过 POP 汇编指令,按照与压栈相反的顺序,将之前保存的值从栈中弹出,写回对应的CPU寄存器。
    • 例如,将局部变量的值从栈中恢复回 R3 寄存器。
    • 最关键的一步:将之前保存的返回地址从栈中弹出,并写回程序计数器(PC) 寄存器(在ARM中,通常是通过弹出到LR,再通过 BX LR 等指令间接实现)。
  • 跳转执行:当PC寄存器被设置为返回地址后,CPU就会从该地址继续执行,即返回到调用该函数的下一条指令处。
  • 这与任务切换的联系:可以看到,单个函数调用过程中的“保存LR、局部变量到栈”和“从栈恢复、跳转返回”,其核心机制(栈保存现场、寄存器恢复现场)与FreeRTOS在任务切换时“保存所有寄存器到任务栈”和“从任务栈恢复所有寄存器”是高度相似的。任务切换可以看作是一个更宏大、更完整的“上下文”保存与恢复过程。

    1.4 任务切换的本质:上下文保存与恢复

    FreeRTOS的调度器(通常由tick中断触发)进行任务切换时,核心操作如下:

  • 保存当前任务上下文:将当前CPU中R0-R15, xPSR等所有关键寄存器的值,顺序压入当前任务的栈中(内存)。这相当于给当前任务拍了一张“快照”。
  • 切换任务栈指针(SP):将SP指向下一个要运行任务的栈顶。
  • 恢复下一个任务上下文:从下一个任务的栈中,按相反顺序将之前保存的寄存器值弹出(POP)到CPU的各个寄存器中,包括恢复PC。
  • 跳转执行:当PC寄存器被恢复后,CPU自然就从新任务上次被切换出去的地方继续执行。
  • 这个过程完全由汇编语言编写,是高效、原子的。

    #mermaid-svg-CeUuSkcKPXbIp3ad{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-CeUuSkcKPXbIp3ad .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-CeUuSkcKPXbIp3ad .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-CeUuSkcKPXbIp3ad .error-icon{fill:#552222;}#mermaid-svg-CeUuSkcKPXbIp3ad .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-CeUuSkcKPXbIp3ad .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-CeUuSkcKPXbIp3ad .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-CeUuSkcKPXbIp3ad .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-CeUuSkcKPXbIp3ad .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-CeUuSkcKPXbIp3ad .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-CeUuSkcKPXbIp3ad .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-CeUuSkcKPXbIp3ad .marker{fill:#333333;stroke:#333333;}#mermaid-svg-CeUuSkcKPXbIp3ad .marker.cross{stroke:#333333;}#mermaid-svg-CeUuSkcKPXbIp3ad svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-CeUuSkcKPXbIp3ad p{margin:0;}#mermaid-svg-CeUuSkcKPXbIp3ad .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-CeUuSkcKPXbIp3ad .cluster-label text{fill:#333;}#mermaid-svg-CeUuSkcKPXbIp3ad .cluster-label span{color:#333;}#mermaid-svg-CeUuSkcKPXbIp3ad .cluster-label span p{background-color:transparent;}#mermaid-svg-CeUuSkcKPXbIp3ad .label text,#mermaid-svg-CeUuSkcKPXbIp3ad span{fill:#333;color:#333;}#mermaid-svg-CeUuSkcKPXbIp3ad .node rect,#mermaid-svg-CeUuSkcKPXbIp3ad .node circle,#mermaid-svg-CeUuSkcKPXbIp3ad .node ellipse,#mermaid-svg-CeUuSkcKPXbIp3ad .node polygon,#mermaid-svg-CeUuSkcKPXbIp3ad .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-CeUuSkcKPXbIp3ad .rough-node .label text,#mermaid-svg-CeUuSkcKPXbIp3ad .node .label text,#mermaid-svg-CeUuSkcKPXbIp3ad .image-shape .label,#mermaid-svg-CeUuSkcKPXbIp3ad .icon-shape .label{text-anchor:middle;}#mermaid-svg-CeUuSkcKPXbIp3ad .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-CeUuSkcKPXbIp3ad .rough-node .label,#mermaid-svg-CeUuSkcKPXbIp3ad .node .label,#mermaid-svg-CeUuSkcKPXbIp3ad .image-shape .label,#mermaid-svg-CeUuSkcKPXbIp3ad .icon-shape .label{text-align:center;}#mermaid-svg-CeUuSkcKPXbIp3ad .node.clickable{cursor:pointer;}#mermaid-svg-CeUuSkcKPXbIp3ad .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-CeUuSkcKPXbIp3ad .arrowheadPath{fill:#333333;}#mermaid-svg-CeUuSkcKPXbIp3ad .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-CeUuSkcKPXbIp3ad .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-CeUuSkcKPXbIp3ad .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-CeUuSkcKPXbIp3ad .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-CeUuSkcKPXbIp3ad .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-CeUuSkcKPXbIp3ad .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-CeUuSkcKPXbIp3ad .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-CeUuSkcKPXbIp3ad .cluster text{fill:#333;}#mermaid-svg-CeUuSkcKPXbIp3ad .cluster span{color:#333;}#mermaid-svg-CeUuSkcKPXbIp3ad div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-CeUuSkcKPXbIp3ad .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-CeUuSkcKPXbIp3ad rect.text{fill:none;stroke-width:0;}#mermaid-svg-CeUuSkcKPXbIp3ad .icon-shape,#mermaid-svg-CeUuSkcKPXbIp3ad .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-CeUuSkcKPXbIp3ad .icon-shape p,#mermaid-svg-CeUuSkcKPXbIp3ad .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-CeUuSkcKPXbIp3ad .icon-shape .label rect,#mermaid-svg-CeUuSkcKPXbIp3ad .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-CeUuSkcKPXbIp3ad .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-CeUuSkcKPXbIp3ad .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-CeUuSkcKPXbIp3ad :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    tick中断触发

    调度器决定切换任务

    保存当前任务上下文PUSH R0-R3, R12, LR, PC, xPSR 等至当前任务栈

    切换堆栈指针(SP)SP = 下一个任务的栈顶

    从新任务栈中恢复上下文POP xPSR, PC, LR, R12, R0-R3 等

    中断返回,CPU从新任务上次断点处继续执行

    中断返回,原任务继续运行

    1.5 小结

    因此,任务栈在内存中保存了任务的“现场”,而CPU寄存器组是“现场”的具体内容。FreeRTOS通过精密的汇编代码,在tick中断服务程序中完成上述保存、切换、恢复的动作,实现了多个任务在单核CPU上的并发执行错觉。
    在这里插入图片描述

    所以,一个FreeRTOS任务本质上可以看作是一个“函数”和其专属“栈”的结合体:

    • 函数:定义了任务要执行的代码逻辑和指令序列。
    • 栈:为任务提供了独立的内存空间,用于保存函数调用链、局部变量以及最重要的——任务被切换时的完整CPU寄存器状态(即上下文)。

    创建任务函数的内部细节:

    第一个参数是创建的任务函数,因为函数本身就等价于函数的入口地址,所以填写时不用加&符合,这与数组相似

    第六个参数是,TCB结构体的指针(也就是句柄),这个参数是传出去的,不是传出来的,所以我们填入TaskHandle_t定义的指针,就是得到这个任务TCB结构体的地址,这个TCB结构体包含这个任务的所有信息
    在这里插入图片描述
    下面是TCB结构体的内部,我们可以发现这个结构体内部没有任务函数,也没有任务参数,这是为什么呢?
    在这里插入图片描述
    首先我们来解决栈的空间是从哪里分配的问题:从前面的知识,我们知道这是从内存中分配出一块堆出来,然后再从这块堆分配出栈,至于怎么分配大小,我之前都介绍过,你可以先通过估算的方法(返回地址,局部变量,现场这三块决定),然后分配好后,你可以调用相关函数来显示该任务的空闲栈大小,如果太多了,可以酌情减少

    接着让我们结合前面学到的“上下文保存与恢复”机制来理解为什么TCB结果没有任务函数和其参数:

  • 任务创建时的“初始化栈”:当调用 xTaskCreate 创建任务时,FreeRTOS 会为这个新任务预先初始化其任务栈。这个初始化过程,就相当于“伪造”了一次任务被中断并保存了上下文的现场。其中最关键的两步是:

    • 将任务函数的入口地址放入栈中对应 PC(R15)寄存器 的位置。
    • 将任务的参数(pvParameters) 放入栈中对应 R0 寄存器 的位置(根据 ARM 调用约定,第一个参数通过 R0 传递)。
  • 首次调度执行:当调度器第一次切换到该任务时,会执行标准的“上下文恢复”流程:从该任务的栈顶弹出所有寄存器的值。此时,PC 寄存器被恢复为任务函数的地址,R0 寄存器被恢复为任务的参数值。

  • 无缝衔接:中断返回后,CPU 从 PC 指向的地址(即任务函数)开始执行,并且该函数通过 R0 寄存器自然就拿到了传入的参数。对于任务函数来说,它就像是被直接调用的一样,完全感知不到自己的“第一次”执行其实是调度器通过恢复上下文“跳转”过来的。

  • 这种设计的精妙之处在于:

    • 统一性:任务的第一次执行和后续被切换回来,使用的是完全相同的上下文恢复机制。TCB 只需要管理栈顶指针(pxTopOfStack),调度器代码无需为“首次执行”做特殊处理。
    • 效率:函数地址和参数作为寄存器值保存在栈中,恢复时直接加载到 CPU 寄存器,是处理器最自然的执行方式,速度最快。
    • 灵活性:任务的入口函数和参数在逻辑上属于任务的“执行现场”的一部分,与任务运行时产生的局部变量、返回地址等一起,都由任务栈统一管理,职责清晰。

    所以,TCB 结构体(tskTaskControlBlock)中确实没有 pvTaskCode 和 pvParameters 这两个字段。它只保存了指向任务栈顶的指针(pxTopOfStack)以及其他管理信息(如任务状态、优先级、事件列表等)。任务函数和参数,作为任务“灵魂”的一部分,已经被“编译”进了它的栈内存镜像里,等待被调度器“唤醒”。

    深入了解队列:

    在这里插入图片描述
    在这里插入图片描述

    在之前的学习中,我们知道,实现互斥操作的关键步骤就是关中断或者关任务调度,所以为了防止多个任务同时写或读队列数据,造成数据不准确,我们要采用互斥操作访问队列,那怎么实现呢?就是关中断。
    在这里插入图片描述
    可以看到当一个任务调用写队列函数时,该函数内部有一个关中断和开中断的操作,这样就实现了互斥写队列的操作,读队列同理。

    从前面的学习我们还知道队列还能提高CPU的运行效率,怎么做到的呢?很简单,以读队列为例:当这个队列是空的时候,那么就让这个任务进入阻塞状态,直到队列有数据,然后唤醒该任务,所以这就涉及到链表了

    在这里插入图片描述
    之前的学习中,我们只知道,当这个任务进入阻塞状态时,该任务就会从就绪链表中移到对应的队列链表中,比如:在队列没有数据时,B任务就会被移到接收链表中,下面我们来仔细看一下内部如何实现该操作的
    在这里插入图片描述
    以读队列为例:先关闭中断(防止切换到其它任务执行,实现互斥操作),当有数据的时候:就复制数据(然后会调整数据的个数,减一,对应环形buffer的操作),然后会跑到发送链表,看看有没有任务,如果有就唤醒第一个任务,当没数据的时候:你可以选择返回错误,或者进入阻塞状态,进入阻塞状态就会将该任务放到等待链表中,然后将该任务从就绪链表移到阻塞链表中

    信号量:

    在这里插入图片描述

    信号量本质上是一种特殊的队列
    从流程图可以清晰地看到,获取信号量(xSemaphoreTake)的流程与从队列中读取数据(xQueueReceive)的流程在核心逻辑上高度一致。它们都遵循“关中断保护 → 检查资源可用性 → 资源可用则操作并唤醒等待者 → 资源不可用则选择阻塞或返回错误 → 开中断”这一套模式。

    为什么说信号量是“特殊的队列”?
    在这里插入图片描述

  • 数据结构复用:在 FreeRTOS 内部,信号量(包括二进制信号量、计数信号量、互斥量)都是基于队列(Queue)数据结构实现的。你可以把信号量看作是一个队列长度为 1、数据项大小为 0(即不存储实际数据)的特殊队列,所以可以看到上图中,是没有队列buffer的,只有队列头
  • 操作对象是计数值:这是最核心的区别。
    • 普通队列:存储和传递的是用户数据(如一个结构体、一个整数)。xQueueSend 是写入数据,xQueueReceive 是读取并移除数据。
    • 信号量:不存储用户数据,其“数据”是一个内部的计数值(uxMessagesWaiting)。xSemaphoreGive 相当于“发送”一个空消息,使计数值加一;xSemaphoreTake 相当于“接收”一个空消息,使计数值减一。流程图中“复制数据”的步骤对于信号量来说,就是简单地增减这个计数值。
  • 统一的阻塞/唤醒机制:正因为底层都是队列,所以它们共享同一套精妙的任务阻塞链表机制。当任务尝试获取信号量但计数值为 0(或尝试读空队列)时,任务会被挂起到同一个“等待(阻塞)链表”上。当另一个任务释放信号量(或写入队列数据)时,调度器会从对应的链表中唤醒等待的任务。您之前看到的“发送链表”和“接收链表”在信号量的场景下,同样用于管理等待“Give”和等待“Take”的任务。
  • 总结一下:
    信号量继承了队列所有的优秀特性:互斥访问(通过关中断)、高效的阻塞/唤醒机制、超时控制。它剥离了队列“传递数据”的职责,专注于提供一种轻量级的、用于任务间同步或资源计数的信号机制。因此,在 FreeRTOS 源码中,你会看到大量的队列 API(如 xQueueGenericSend, xQueueGenericReceive)被信号量的 API 直接调用或封装。

    互斥量:

    我们知道信号量有二进制型,这跟互斥量一样只有一个计数值,那为什么还要用互斥量呢?因为互斥量有优先级反转:
    在这里插入图片描述
    优先级继承内部代码流程如下:
    在这里插入图片描述
    在这里插入图片描述
    可以看到当它判断当前任务优先级高于持有者优先级时,它就会暂时将持有者任务移到当前任务优先级链表中

    事件组:

    在这里插入图片描述
    在之前的学习中,我们知道事件组是停止调度器,这与之前的队列,信号量,互斥量停止中断是不一样的,下面我们深入了解这是为什么

    在这里插入图片描述
    可以看到,在读队列中,不只是有任务会抢占读操作,在中断也会,所以要关闭中断防止中断进行读写操作

    不过我们也可以看到,写事件函数也有ISR,但它不是在中断里面进行写事件操作,而是唤醒定时器任务来进行写事件
    在这里插入图片描述
    在这里插入图片描述
    那为什么不在中断里面进行写事件呢,对比一下读队列和写事件内部操作:

    FreeRTOS 的红线是确定性(determinism),即"这段临界区的执行时间有没有上界"。
    队列 —— O(1),有界:

    /* xQueueGenericSendFromISR 核心 /
    if( listLIST_IS_EMPTY( &( pxQueue->xTasksWaitingToReceive ) ) == pdFALSE )
    {
    / 只摘链表头一个——优先级最高的那个等待者 */
    if( xTaskRemoveFromEventList( &( pxQueue->xTasksWaitingToReceive ) ) != pdFALSE )
    {
    *pxHigherPriorityTaskWoken = pdTRUE;
    }
    }

    一次入队最多解除一个任务的阻塞(一个数据只能给一个接收者),而且是直接摘链表头,没有循环。固定几十条指令,关中断时间可预测。

    事件组 —— O(N),无界:

    /* xEventGroupSetBits 核心 /
    vTaskSuspendAll();
    {
    pxEventBits->uxEventBits |= uxBitsToSet;
    while( pxListItem != pxListEnd ) / 遍历整个等待链表 /
    {
    / 每个等待者都要取出它的等待条件、拆控制位、
    判断 waitForAllBits / 任意位是否满足…… /
    if( xMatchFound != pdFALSE )
    {
    vTaskRemoveFromUnorderedEventList( … ); / 可能唤醒很多个 */
    }
    pxListItem = pxNext;
    }
    }
    ( void ) xTaskResumeAll();

    置一次位可能同时满足 N 个任务的条件(一个 bit 可以被任意多个任务共享),必须逐个遍历、逐个求值。N 有多大取决于应用怎么写,内核无法给出上界。

    还有一条硬约束

    注意上面那句 vTaskSuspendAll()。因为遍历太长,不能整段关中断,FreeRTOS 只能用"挂起调度器"来保护 —— 而 vTaskSuspendAll() 根本不允许在 ISR 中调用。这不是"怕慢",是实现上直接走不通。

    所以 xEventGroupSetBitsFromISR() 的实现是甩锅:

    xReturn = xTimerPendFunctionCallFromISR( vEventGroupSetBitsCallback, … );

    把请求扔进定时器队列,让守护任务(Timer/Daemon Task)在任务上下文里去慢慢遍历。

    注意:
    这里有一个必须澄清的误区:不在中断里写事件,并不是因为“被唤醒的任务本身可能会很耗时”,而是因为“遍历事件链表、逐个判断等待任务的事件条件是否满足”这个操作本身很耗时(O(N),没有执行时间上界),所以必须放到任务上下文里去做。

    要理解这一点,关键是要分清两个阶段:

  • 在 ISR 中,被唤醒的函数体一行都不会执行:调用 xEventGroupSetBitsFromISR() 时,它只是通过 xTimerPendFunctionCallFromISR() 把回调投递到定时器队列,然后 ISR 就返回了。此时,真正负责遍历事件链表的守护任务(Timer/Daemon Task)一行代码都还没跑。
  • 真正的遍历发生在任务上下文:守护任务要等 ISR 返回、portYIELD_FROM_ISR() 触发 PendSV 异常返回之后,才被调度器作为普通任务切换进来,此时才会开始遍历事件链表、处理事件位。
  • 所以结论很明确:“被唤醒的任务本身很耗时”对 ISR 的执行时长毫无影响——因为在 ISR 执行期间,它根本还没有开始运行。真正不能放在 ISR 里做的,是“遍历事件链表、寻找满足条件的任务”这段没有执行时间上界的循环。

    软件定时器:

    在这里插入图片描述

    从图中可以看到,软件定时器的回调执行链路可以拆成两步:

  • SysTick 中断提供系统心跳:FreeRTOS 的 tick 中断由 SysTick 定时器产生,它不断推进内核的时间基准。当设置的软件定时器超时时间到达时,tick 中断会识别出“有定时器到期”。
  • 唤醒定时器守护任务处理:tick 中断并不会直接执行软件定时器的回调,而是通过定时器命令队列唤醒定时器守护任务(Timer/Daemon Task)。守护任务在任务上下文里取出到期的定时器,再调用对应的回调函数。
  • 所以“启动 Timer”并不是让 SysTick 中断去执行回调,而是由 SysTick 推动时间、唤醒定时器守护任务,由定时器守护任务在任务上下文里统一执行回调。这样既能保证中断处理足够短,又能让定时器回调像普通任务一样运行。

    两套函数:

    在这里插入图片描述
    在之前的学习中,我们知道在调用ISP函数,当更高优先级的任务被唤醒时,它不会立马执行,而是给这个更高优先级的任务做个标记,当中断快结束时才切换到这个任务

    现在我们来了解它的内部实现。从图中可以看到,如果 ISR 在执行过程中不断唤醒更高优先级的任务,那么“每唤醒一次就立刻切换一次”,中间这些切换动作就会白白浪费 CPU 资源。为了避免这种情况,FreeRTOS 的做法是:

  • ISR 中只做标记,不立即切换:当 ISR 发现唤醒的任务优先级高于当前任务时,它并不会马上切换到那个新任务,而是把 xHigherPriorityTaskWoken 置为 pdTRUE,相当于记下一个标记——“已经有更高优先级的任务等待运行了”。
  • 中断结束时统一切换一次:ISR 执行完毕、准备返回时,如果检测到 xHigherPriorityTaskWoken 为 pdTRUE,就会触发 PendSV 异常。PendSV 的处理函数再从就绪链表中找出当前优先级最高的就绪任务,统一执行一次上下文切换,跳到那个最高优先级任务继续运行。
  • 这样设计有两个好处:

    • 避免浪费 CPU:无论 ISR 过程中唤醒了一次还是多次更高优先级任务,最终都只做一次切换,中间的切换全部省略。
    • 保证中断足够短:真正的上下文切换发生在中断结束之后,ISR 本身只负责“记录”,因此执行时间更短、确定性更强。

    两类中断:

    在这里插入图片描述

    我们知道队列、信号量、任务通知等机制在保护临界区时都会“关中断”。但这里的“关中断”并不是关闭全部中断,从上图中可以看到,它关掉的只是 B 类中断——也就是优先级数值较大、优先级相对较低的那部分中断。

    为什么要分成 A、B 两类呢?原因在于:FreeRTOS 允许应用使用更高优先级的中断去响应紧急事件,但它无法控制这些高优先级中断里会写什么代码。一旦某个高优先级中断在 RTOS 正在操作内核数据结构时抢占进来,又去调用 FreeRTOS API,就可能破坏内核数据的完整性。所以 FreeRTOS 划出一条边界:

    • A 类中断:优先级数值小于 configMAX_SYSCALL_INTERRUPT_PRIORITY,即更高优先级的中断。它们可以随时抢占内核,但不能调用任何 FreeRTOS API。
    • B 类中断:优先级数值大于等于 configMAX_SYSCALL_INTERRUPT_PRIORITY,即较低优先级的中断。FreeRTOS 通过设置 BASEPRI 把这类中断屏蔽掉,因此它们可以安全地调用带 FromISR 后缀的 API。

    那怎样保证那些更高优先级的 A 类中断不去调用这些 API 呢?做法就是在 API 内部检查中断优先级。

    每个可供中断调用的 API 入口处,FreeRTOS 都会读取当前中断的优先级,并判断它是否高于允许调用 API 的最高优先级门槛:

    • 如果当前中断优先级数值大于等于 configMAX_SYSCALL_INTERRUPT_PRIORITY,说明它是 B 类中断,优先级足够低,函数继续正常执行。
    • 如果当前中断优先级数值小于 configMAX_SYSCALL_INTERRUPT_PRIORITY,说明它是更高优先级的 A 类中断,按规定不应调用 API,FreeRTOS 就会进入 configASSERT 死循环,提示开发者这里使用错误。

    再次强调:ARM Cortex-M 的中断优先级是数值越小,优先级越高。所以“大于等于门槛”对应的是优先级较低、可以安全调用的中断;“小于门槛”对应的是优先级太高、会破坏内核的中断,必须禁止调用。

    中断机制优先级:

    在这里插入图片描述
    在B类中断中包括sysTick、PendSV、GPIO key等,它们的优先级怎么划分?我们知道最高优先级的任务也低于最低优先级的中断,所以对于sysTick、PendSV这两个都是为任务服务的中断,那么它们的优先级一般都是设置最低的

    那什么是PendSV呢?
    在这里插入图片描述

    我们知道,在 Tick 中断中会发起一次调度。此时,调度器会遍历就绪链表,判断接下来该轮到哪个任务执行。

    但要注意:调度器只是“做决策”——选出下一个任务,它并不会直接去保存寄存器、切换任务栈。为什么不能直接在 SysTick 中断里完成上下文切换呢?因为如果在 SysTick 中切任务切到一半时,突然来了一个更高优先级的外设中断,就会打断这个做了一半的上下文切换,让寄存器现场处于不一致的状态,系统就会崩溃。

    那真正的“切任务”动作由谁来执行呢?

    答案就是如上图所示的 PendSV 异常。PendSV 是 ARM Cortex-M 专门用于“可挂起系统调用”的异常,真正的任务上下文切换就放在 PendSV 里完成。由于 PendSV 的优先级通常被配置得比较低,它会等到所有高优先级的中断全部执行完毕后,才进入 PendSV 异常处理。这样就能保证:切换任务时不会被其他高优先级中断打断,整个上下文切换过程是完整、原子的。

    赞(0)
    未经允许不得转载:171主机测评 » FreeRTOS(内部机制1)
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址