FreeRTOS学习(9)——临界区

文章目录

引入

在前面的内容中,我们已经讲过:

当多个任务同时访问同一份共享资源时,如果不加控制,就可能出现数据错乱的问题,这就是线程安全问题。

进一步分析可以发现:

出现线程安全问题的根本原因在于,任务对共享资源的访问过程,并不是一个原子操作。

任务在执行操作的过程,随时都可能被切换打断(包括任务与中断),最终导致了线程安全问题。

所以:

若想解决线程安全问题,根本思路就是让原本对共享资源的非原子性操作,变为一个原子操作。

那么在FreeRTOS当中,如何让一个操作变为原子操作呢?

很简单:

只需要保证任务在执行共享资源访问操作时,不被打断就可以了。

在FreeRTOS当中,已经提供了这样的机制,这就是临界区(Critical Section)

  1. 通过某种机制,使一段代码在执行期间不被打断干扰的行为,我们称之为:进入临界区
  2. 而被保护起来、要求连续执行、不允许被打断的那段代码,就称为临界区代码
  3. 临界区代码执行完毕后,还需要还原系统行为,回归正常调度,这就是:退出临界区

在具体学习临界区的实现方式之前,在这里我们就要先搞清楚一点:

FreeRTOS的所谓临界区,其本质的原理都是:保证任务在临界区内不会被打断!

换句话说:

只要一段代码进入临界区,就必须完整执行完毕,中途不能被打断。

临界区正是基于这样的原理,来实现对共享资源的原子性操作。

暂停调度器

在 FreeRTOS 中,**(接近)**实现临界区的第一种方式是:暂停调度器。

相关的一对API如下:

c 复制代码
void vTaskSuspendAll( void );   // 暂停任务调度器
BaseType_t xTaskResumeAll( void );  // 恢复任务调度器

这一组 API 的核心思想非常简单:

既然任务切换是调度器触发的,那我直接把调度器停掉!

通过暂停调度器,阻止任务切换,从而保证当前任务能够连续执行。

这一对函数是FreeRTOS 内核自带的调度控制函数,不受任何宏配置开关控制,始终是存在的。

澄清一个误区

在继续深入之前,需要先澄清一个非常容易产生误解的点。

这两个函数的名字,很容易让人误以为它们类似于:

  1. 挂起所有任务
  2. 恢复挂起所有任务

难道说,执行这个函数就是把所有其他任务"挂起来"?通过这种方式来实现禁止切换?

当然不是,如果是这样,未免太麻烦了。

实际上:

这两个函数,它们操作的对象不是"任务",而是"调度器"。

也就是说:

  1. vTaskSuspendAll:暂停调度器,暂停系统中一切任务调度相关的行为。
  2. xTaskResumeAll:恢复调度器,恢复调度器的调度行为。

进一步来说:

这两个 API 并不会直接改变任何任务的状态。

任务原本是什么状态:

  1. 就绪态
  2. 运行态
  3. 阻塞态
  4. 挂起态

在调用这两个函数前后,都不会发生改变。

它们真正做的事情只有一个:让"任务切换机制"暂时失效/恢复。

可以用一句更本质的话来理解:

vTaskSuspend() 是冻结某个任务,暂停某个任务。

vTaskSuspendAll() 则是"冻结调度器","暂停调度"。

vTaskSuspendAll()源码

当然,如果只是讲上述内容,告诉你这样一句结论:vTaskSuspendAll() 是"冻结调度器","暂停调度"。

还是太抽象了,你也根本不理解到底发生了什么事情。

若想真正理解它的行为,最好、最有效的办法还是直接看源码。

这个函数做了什么事情呢?

其实非常简单:

它的核心作用只有一行代码:

说白了,这个函数只做了一件事情:

把uxSchedulerSuspended这个调度器暂停标志,加1,从原本的默认值0,改为了1。

从而表示:暂停调度器。

接下来需要重点关注的是:当 uxSchedulerSuspended 不为 0 时,内核的行为会发生哪些变化?

下面逐一进行分析。

调度器暂停对任务切换的直接影响

调度器暂停后,最直接的影响就是:

任务上下文切换不会再发生。

其具体表现为:

当前正在运行的任务,一旦获得 CPU,就会持续占用 CPU,不会被调度器切换出去。

即使在此期间,有更高优先级任务就绪,也不会立即切换任务。

当然时间片轮转机制就更不会生效了。


从内核实现角度来看:

FreeRTOS 中,真正完成"选择下一个运行任务"的核心函数是:

c 复制代码
vTaskSwitchContext()

该函数的职责是:

从就绪列表中选择优先级最高的任务,并更新当前运行任务指针。

而一旦任务调度器被暂停,那么任务上下文切换就被禁止了。此时该函数的处理源码如下所示:

一句话总结:

调度器暂停,本质上就是"禁止 vTaskSwitchContext() 的执行",从而彻底阻断任务上下文切换。

调度器暂停标志对全局Tick计数的影响

在前面的学习中,我们已经讲过全局系统Tick计数,也就是全局变量xTickCount

每当一个新Tick到来后,内核就会调用函数xTaskIncrementTick(),完成对全局计数的累加,并且处理一些事项。

在正常情况下,在任务调度器没有被暂停的情况下,它主要完成以下事项:

  1. 全局 Tick 计数加 1
  2. 检查延时阻塞列表
  3. 将已到期的任务移出阻塞态,并移入就绪列表
  4. 根据情况决定是否触发任务切换

此函数的部分源码如下图所示:

但这一切行为的前提是:

uxSchedulerSuspended == 0,即任务调度器处于正常工作状态。

一旦调度器被暂停(uxSchedulerSuspended > 0),xTaskIncrementTick() 的行为就会发生变化。

此时,该函数不再执行完整的 Tick 处理流程,而是只保留最基本的时间推进功能

源码如下:

具体来说,此时函数的行为是:

  1. 全局 Tick 计数仍然会加 1,系统时间继续向前推进
  2. 不再检查延时阻塞列表,即不会判断任务是否到期
  3. 不会将到期任务移入就绪列表,任务仍然停留在阻塞态
  4. 不会触发任务调度,即不会发生任务切换

也就是说,在调度器暂停期间,Tick 仍然在不断产生,时间还在继续推进,但由 Tick 引发的阻塞态任务状态切换不再进行了。

为了记录这段时间内累计的 Tick 数,内核使用了变量 xPendedTicks(暂停时期Tick计数)。

在调度器暂停期间,每当一个 Tick 到来时,不再执行完整的处理流程,而是简单地执行:

c 复制代码
xPendedTicks++

可以这样理解这一过程:

  1. 正常情况下,Tick 到来后会立即推动任务状态变化,并可能引发调度;
  2. 而在调度器暂停期间,Tick 只被"记录下来",并不会立即产生任何调度效果。

因此,会出现一种现象:

某些延时阻塞任务在时间上已经"到期",但状态上仍然停留在阻塞态。

这是因为:

时间在推进,但时间对任务状态产生的影响被延迟了,调度器被暂停了。

但是需要注意:

这些关于延时阻塞任务的处理不会丢失,而是会在后续调用 xTaskResumeAll() 时统一处理。

换句话说,调度器暂停期间,并不是"不处理",而是"暂不处理"。


通过这一条执行路径,我们还可以得到一个更本质的结论:

调度器暂停后,所有与任务调度相关的行为,并不会消失,而是被整体延后,统一在恢复调度器时再处理。

做一个类比就是:

公司周末不上班,并不是把需要做的工作都"消灭"了。

而是将事务暂时积压,等到下一个工作日再统一处理。


所以:

若一个处于延时阻塞状态的任务,一个处于延时阻塞列表中的任务

在任务调度暂停期间到达"苏醒时间",那么它实际不会发生任何变化,仍然会继续待在延时阻塞列表中,继续处在延时阻塞状态。

直到任务调度器恢复,它才会回到就绪态。

调度器暂停标志对事件阻塞的影响(了解)

在前面的分析中,我们已经从**"Tick驱动路径"**出发,说明了:

当调度器被暂停时,Tick 仍然正常推进,但由 Tick 引发的延时阻塞任务状态变化会被延迟处理。

但是在FreeRTOS当中,处于阻塞状态的任务,并不只有延时阻塞。

还有一种很常见的阻塞方式:

事件阻塞。

由于事件阻塞涉及到后面的知识点,所以这里只简单提一下,尽量不超出大家目前的知识范畴。

所谓事件阻塞,可以简单理解为:任务在等待某个"外部条件"成立或者"外部事件"发生。

例如,任务 A 需要等待其他任务发送通知。

如果这个事件始终没有发生,那么任务 A 会一直处于阻塞态;只有当事件真正发生时,任务 A 才会解除阻塞。

这里有一个关键区别:

延时阻塞依赖时间推进,而事件阻塞依赖事件本身是否发生。


处于事件阻塞的任务,会被放入对应的事件列表当中(具体是什么事件列表,后面详谈)。

那么问题来了:

如果在任务调度器被暂停的期间,事件发生了,会如何进行处理呢?

事件一旦发生,内核会调用函数 xTaskRemoveFromEventList() 来进行任务状态的处理。

常规的处理就是直接唤醒任务,将任务移入就绪列表,转变成就绪态:

但如果此时调度器处于暂停状态,情况就不同了:

如果调度器暂停期间,事件发生了,任务就不会回到就绪态了,而是被移入挂起就绪列表

也就是我们之前见过的一个列表:

挂起就绪列表,只是一个临时列表,它用于存储那些:

"在调度器暂停期间,事件发生,满足执行条件,进入就绪态的任务"。

为什么这么设计呢?

很简单:

在任务调度器暂停期间,如果让任务也能从阻塞态回到就绪态,即便不调度它上CPU,但这种操作还是破坏了调度系统结构本身。

这就好比:

公司的财务周末没上班,休息了,我偷偷往公司的账上记一笔。

显然是不合理。

从这个角度出发:

  1. 挂起就绪列表就是一个"临时账本",先记一笔
  2. 等到 xTaskResumeAll() 函数调用,再把"临时账本"上的数据抄回就绪列表这个"真正的账本"(这个事情由财务亲自干)。

也就是说:

  1. 挂起就绪列表,相当于一个"临时记录区"
  2. 等调度器恢复(调用 xTaskResumeAll())后
  3. 再把这些任务统一转移到真正的就绪列表中

总之,这条线还是告诉我们一个任务调度器暂停的本质作用:

调度器暂停后,所有与任务调度相关的行为,并不会消失,而是被整体延后,统一在恢复调度器时再处理。

只不过:

  1. 延时阻塞的任务如果到期,内核会先记录 临时挂起Tick计数,恢复调度器后再统一处理。
  2. 事件阻塞的任务如果满足条件,内核会先将它们放入挂起就绪列表,恢复调度器后再统一处理。

任务调度暂停对挂起任务的影响

如果一个任务处于挂起状态,那么任务调度器暂停对它有影响吗?

这个简单,直接查看挂起相关函数的源码即可:

挂起函数中,没有找到任何有关调度器暂停的判断。

那么恢复挂起呢?

恢复挂起函数中,也没有找到任何有关调度器暂停的判断。

所以,任务调度器暂停对挂起状态实际上是不起作用的:

  1. 即便任务调度器暂停,仍然可以将一个任务挂起,让这个任务进入挂起列表。
  2. 即便任务调度器暂停,仍然可以将一个挂起的任务,恢复挂起,让这个任务回到就绪列表。

但是注意,即便一个挂起任务恢复挂起,回到就绪态,但在任务调度器暂停期间,也不会上CPU执行。

vTaskSuspendAll()执行后的系统行为

调度器暂停后,所有与任务调度相关的行为,并不会消失,而是被整体延后,统一在恢复调度器时再处理。

也就是说,当执行 vTaskSuspendAll() 之后,系统进入一种**"半冻结"状态**:

  1. 当前正在运行的任务会继续执行
  2. 不会再发生任务切换(上下文切换被禁止)
  3. 即使有更高优先级任务就绪,也不会抢占当前任务。
  4. 中断仍然可以正常响应,只是暂停调度器,而没有禁用中断。
  5. 但由 Tick 驱动的任务状态变化(如延时到期)不会立即生效,延时阻塞状态的任务不会回到就绪态。
  6. 事件仍然可以正常发生,但被唤醒的任务不会立即被立刻加入就绪列表,而是进入挂起就绪列表。
  7. 挂起状态相关的事项仍然可以正常处理,但并没有实际意义,也不推荐在任务调度器暂停期间做挂起相关操作。

vTaskSuspendAll()函数总结

通过源码可以看出,vTaskSuspendAll() 的本质可以归纳为以下几点:

  1. vTaskSuspendAll() 并不是"停止系统运行",而只是"暂停任务调度"。
    1. 系统 Tick 仍然正常产生,全局时间持续推进。
    2. 但所有与任务调度相关的行为被暂时冻结。
  2. 调度器暂停后,所有"与调度相关的行为"都会被延迟处理,而不会丢失。
    1. 延时阻塞任务到期 → 不立即转入就绪态,而是通过 xPendedTicks 记录
    2. 事件触发 → 不直接进入就绪列表,而是进入挂起就绪列表
    3. 任务切换 → 不立即发生
    4. 本质上:不是"不处理",而是"暂不处理,后续统一处理"
  3. 内核通过"临时记录机制"保证系统状态的正确性。
    1. xPendedTicks:记录暂停期间累计的 Tick
    2. 挂起就绪列表:记录本应进入就绪态但被延迟处理的任务
    3. 即使调度器暂停,系统状态也不会丢失或紊乱。
  4. xTaskResumeAll() 是整个机制的"收口点" ,恢复调度器时会统一处理:
    1. 累计的 xPendedTicks
    2. 延时到期任务
    3. 挂起就绪列表中的任务
    4. 并在必要时触发一次任务调度
    5. 可以理解为:暂停期间积压的所有调度行为,在这里一次性补执行。

一句话总结:

vTaskSuspendAll() 的本质,不是停止系统,而是将任务调度行为整体延后,并在恢复时统一处理。

xTaskResumeAll()函数

前面我们讲了:

vTaskSuspendAll() 的作用,本质上就是把调度器暂停标志 uxSchedulerSuspended 加 1,使系统进入"暂停调度"的状态。

那么很自然就会有一个问题:

调度器暂停之后,靠谁来恢复?

答案就是:

xTaskResumeAll()

这个函数的作用可以概括为一句话:

恢复调度器,并把暂停期间积压的那些"本该处理但暂时没处理"的调度相关事务,统一补处理掉。

也就是说:

  1. vTaskSuspendAll() 负责"先暂停"
  2. xTaskResumeAll() 负责"再恢复,并收尾"

这两个函数通常是成对使用的。

函数的行为

xTaskResumeAll() 做了什么?

这个函数整体上主要完成 4 件事情:

  1. 将调度器暂停计数减 1
  2. 如果计数已经恢复为 0,说明调度器真正恢复
  3. 统一处理暂停期间积压的任务状态变化
  4. 根据情况决定是否触发一次任务切换

它的核心逻辑,其实就是围绕下面这句展开的:

也就是说:

vTaskSuspendAll() 是对调度暂停标志加 1,xTaskResumeAll() 就是对调度暂停标志减 1。

嵌套计数器

很容易就可以发现:

只有当调度暂停标志最终减回到 0 时,才表示:

调度器真正从"暂停状态"恢复到了"可调度状态"

所以,这里也能看出一个细节:

调度器暂停不是简单的布尔标志,而是一个可嵌套计数器。

例如:

c 复制代码
vTaskSuspendAll();
vTaskSuspendAll();

此时 uxSchedulerSuspended = 2

必须调用两次:

c 复制代码
xTaskResumeAll();
xTaskResumeAll();

才能真正恢复调度器。

暂停调度器这一对API,必须成对出现使用。而且暂停几次,就必须恢复几次。

函数核心功能

具体来说,恢复调度器,会完整以下操作。

第一,处理挂起就绪列表中的任务,统一将它们转移到就绪列表。

让它们真正进入就绪态。

源码部分如下所示:

除此之外,它还会补处理暂停期间积压的 Tick。

将 xPendedTicks 记录的Tick,进行逐个调用 xTaskIncrementTick()函数。

也就是将记录的Tick,逐个Tick的完成:

  1. 全局Tick计数加 1
  2. 检查延时就绪列表,将应当苏醒的任务加入就绪列表,回归就绪态。

源码部分如下所示:

xTaskResumeAll() 的核心作用就是:

把暂停期间积压的"时间变化"和"就绪变化",统一补处理。

函数返回值

xTaskResumeAll() 的返回值具有重要含义:

  1. 若恢复调度器后发生了任务切换 → 返回 pdTRUE;
  2. 若未发生任务切换 → 返回 pdFALSE。

这个返回值,本质上是在回答一个问题:

在调度器恢复的这一刻,是否有"更高优先级任务"需要立即运行。

在调度器暂停期间,系统中可能已经积累了一些变化,例如:

  1. 任务延时到期
  2. 事件唤醒任务(进入 xPendingReadyList)

当调用 xTaskResumeAll() 时,内核会统一处理这些变化,并重新构建就绪列表。

如果此时存在优先级高于当前任务的任务,那么系统需要立即进行任务切换,函数返回 pdTRUE;否则不发生切换,返回 pdFALSE。

因此可以总结为:

xTaskResumeAll() 的返回值,本质就是"是否需要立即进行一次任务切换"。

但是注意:

这个返回值只是告诉你,是否需要任务切换,真正想让任务发生切换这个行为,还需要手动调用函数。

一般来说,该函数的返回值可以接收并处理,标准形式如下:

c 复制代码
BaseType_t xYieldRequired;

vTaskSuspendAll();

/* 临界区代码 */

xYieldRequired = xTaskResumeAll();

if (xYieldRequired == pdTRUE) {
    taskYIELD();    // 主动触发调度,此时有高优先级任务就绪,尽快让它上CPU
}

通过这种方式,可以在恢复调度器后,立即触发一次任务调度,从而让高优先级任务尽快获得 CPU。

当然就算不接收函数的返回值,不主动触发调度,在下一个Tick到来时,系统还是会自动调度切换任务。

很明显,前者必然切换响应速度更快。

两者方式之间,最大会有接近 1个Tick 的响应时间差距。


Tips:简单介绍一下taskYIELD()函数。

调用 portYIELD() 后,系统会触发一次任务调度请求,使当前任务让出 CPU,由调度器重新选择任务运行。

具体表现为:

  1. 当前任务主动放弃 CPU 的使用权
  2. 系统进行一次调度
  3. 选择当前就绪状态中最高优先级的任务运行

根据系统情况,结果可能不同:

  1. 若存在更高优先级任务 → 切换到更高优先级任务执行
  2. 若存在同优先级任务 → 进行时间片轮转,切换到下一个任务
  3. 若没有其他就绪任务 → 当前任务继续执行(看起来像没有变化)

可以一句话理解为:

taskYIELD() 只是让调度器"现在就重新选一次任务",但不保证当前任务一定被切走。

代码示例

在前面,我们让多个任务,使用同一串口打印调试信息,就会出现线程安全问题。

所以我们可以这一对API来实现"禁止任务调度"的受保护区域,这样串口打印调试数据就不会再乱了。

还可以参考下面代码示例:

c 复制代码
#include "stm32f10x.h"      // STM32F10x 标准外设库头文件

#include "FreeRTOS.h"       // FreeRTOS 核心头文件
#include "task.h"           // FreeRTOS 任务相关 API

#include "DebugUSART1.h"    // 调试串口模块(printf1)

// -------------------- 任务1 --------------------
void task1(void *arg) {
    uint8_t testFlag = 0;

    while (1) {
        vTaskDelay(5000);
        printf1("task1 running\r\n");


        // 只测试一次
        if (testFlag == 0) {
            testFlag = 1;

            printf1("\r\n");
            printf1("task1 -> call vTaskSuspendAll()\r\n");

            // 暂停调度器
            vTaskSuspendAll();

            // for循环忙等待延时
            for (uint32_t i = 0; i < 8000000; i++);

            printf1("task1 -> still running 1\r\n");

            // for循环忙等待延时
            for (uint32_t i = 0; i < 8000000; i++);

            printf1("task1 -> still running 2\r\n");

            // for循环忙等待延时
            for (uint32_t i = 0; i < 8000000; i++);

            printf1("task1 -> still running 3\r\n");

            // for循环忙等待延时
            for (uint32_t i = 0; i < 8000000; i++);

            printf1("task1 -> call xTaskResumeAll()\r\n");
            printf1("\r\n");

            // 恢复调度器
            xTaskResumeAll();
        }
    }
}

// -------------------- 任务2 --------------------
void task2(void *arg) {
    while (1) {
        vTaskDelay(3000);
        printf1("task2 running\r\n");
    }
}

// -------------------- main函数 --------------------
int main(void) {
    // 1. 初始化调试串口
    DebugUSART1_Init();
    printf1("system start\r\n");

    // 2. 创建两个同优先级任务
    xTaskCreate(task1,
                "Task1",
                configMINIMAL_STACK_SIZE,
                NULL,
                tskIDLE_PRIORITY + 1,
                NULL);

    xTaskCreate(task2,
                "Task2",
                configMINIMAL_STACK_SIZE,
                NULL,
                tskIDLE_PRIORITY + 1,
                NULL);

    printf1("tasks created\r\n");

    // 3. 启动调度器
    vTaskStartScheduler();

    while (1) {
    }
}

还有一个很好的例子就是,假如你需要在任务当中创建新任务,又不希望被打断(尤其被创建的任务优先级还更高的情况)。

也可以临时暂停调度器。

代码示例如下:

c 复制代码
#include "stm32f10x.h"      // STM32F10x 标准外设库头文件

#include "FreeRTOS.h"       // FreeRTOS 核心头文件
#include "task.h"           // FreeRTOS 任务相关 API

#include "DebugUSART1.h"    // 调试串口模块(printf1)

// -------------------- 任务1 --------------------
void task1(void *arg) {
    while (1) {
        printf1("task1\r\n");
    }
}

// -------------------- 任务2 --------------------
void task2(void *arg) {
    while (1) {
        printf1("task2\r\n");
    }
}

// -------------------- 开始任务 --------------------
void beginTask(void *arg) {
    TaskHandle_t hand1 = NULL;
    TaskHandle_t hand2 = NULL;

    // 创建两个优先级更高的任务
    xTaskCreate(task1,
                "Task1",
                configMINIMAL_STACK_SIZE,
                NULL,
                3,
                &hand1);

    xTaskCreate(task2,
                "Task2",
                configMINIMAL_STACK_SIZE,
                NULL,
                3,
                &hand2);

    while (1) {
        printf1("beginTask\r\n");
    }
}

// -------------------- main函数 --------------------
int main(void) {
    // 1. 初始化调试串口
    DebugUSART1_Init();

    // 2. 创建开始任务
    xTaskCreate(beginTask,
                "beginTask",
                configMINIMAL_STACK_SIZE,
                NULL,
                2,
                NULL);

    // 3. 启动调度器
    vTaskStartScheduler();

    while (1) {
    }
}

由于被创建的任务优先级反而更高,所以:

beginTask 在创建完 task1 后,task1任务就绪,优先级更高,抢占 beginTask。

随后:

beginTask再也没有机会执行。

task2也不会被创建。

整个系统就只有task1在执行。

为了解决这个问题,可以在创建任务的过程中暂停调度器,还可以在创建任务结束后删除此任务。

参考代码如下:

c 复制代码
void beginTask(void *arg) {
    TaskHandle_t hand1 = NULL;
    TaskHandle_t hand2 = NULL;

    // 暂停调度器,防止创建高优先级任务后立即发生切换
    vTaskSuspendAll();

    // 创建任务1
    xTaskCreate(task1,
                "Task1",
                configMINIMAL_STACK_SIZE,
                NULL,
                3,
                &hand1);

    // 创建任务2
    xTaskCreate(task2,
                "Task2",
                configMINIMAL_STACK_SIZE,
                NULL,
                3,
                &hand2);

    // 恢复调度器
    xTaskResumeAll();

    // 启动任务完成后,删除自身
    vTaskDelete(NULL);
}

如此,beginTask就可以顺利完成任务的创建,随后被删除。

不能使用vTaskDelay等延时函数

FreeRTOS 提供的延时类函数,都不应该在调度器暂停期间使用。

因为延时函数的本质都是:

让当前任务进入阻塞态,主动放弃CPU,然后等待到期后再恢复就绪态,重新参与调度。

这类操作本身就依赖调度器的正常工作。

vTaskSuspendAll() 的作用恰恰是:

暂停调度器,禁止正常的任务调度流程。

所以两者在设计目标上就是冲突的。

对于 vTaskDelay() 来说,这一点从源码里其实已经能直接看出来:

所以,在任务调度器暂停期间,调用延时函数的后果就是:

当前任务会进入阻塞态,但不会立即发生任务切换。

比如下列代码:

c 复制代码
void task1(void *arg) {
    while (1) {
        printf1("task1 start\r\n");
        // 暂停任务调度器
        vTaskSuspendAll();

        printf1("Delay start\r\n");
        // 延时5s
        vTaskDelay(5000);
        printf1("Delay end\r\n");

        // 恢复任务调度器
        xTaskResumeAll();
        printf1("task1 end\r\n");
    }
}

执行完vTaskDelay(5000);函数调用后,并不会立刻让任务进入阻塞,反而会一直运行。

直接打印:

task1 start

Delay start

Delay end

待到调度器恢复,发现task1需要延时阻塞5s,此时才开始真正延时。

于是5s过后,任务1又会打印:

task1 end

Delay start

Delay end

看起来延时的行为还在,但被延后了,这显然也是不符合我们预期的。

因此:

凡是会让当前任务阻塞、等待时间到达的延时函数,都不应放在 vTaskSuspendAll()xTaskResumeAll() 之间使用。

包括:

  1. vTaskDelay()
  2. vTaskDelayUntil()

等FreeRTOS提供的延时函数,都不能在任务调度暂停期间使用。

那么我自己写一个for循环忙等待延时,可不可以用呢?

用是可以用,但不推荐!

在任务调度器暂停期间加延时,是一个可以实现,但是没有意义的操作!

暂停调度器的时间不应该过长

暂停调度器的时间不应该过长。

这是因为:

vTaskSuspendAll() 暂停的只是任务调度,不是整个系统时钟,也不是所有内核活动。

在这段期间内:

  1. 当前任务会一直占用 CPU 持续运行
  2. 其他任务即使已经满足运行条件,也无法及时获得 CPU
  3. 所有与调度相关的处理,都会被不断积压,等恢复调度器后再统一处理

因此:

如果只是短时间的暂停调度器,避免任务执行共享资源时被打断,这是合理的设计。

但如果长时间暂停调度器,势必导致整个系统的实时性,确定性都被破坏。

所以:

暂停调度器,只适合用于保护极短的关键代码片段,其中不应该执行延时、长循环,甚至阻塞等待等操作。

周末放假两天,积压两天的工作,周一还是可以处理的,但如果放假一年,公司就倒闭了。

注意:暂停调度器不算真正的临界区

严格来说,暂停调度器,并不算真正意义上的临界区。

它可以在系统明确只有任务切换,而没有中断抢占任务时,**"冒充"**一下临界区。

这是因为 vTaskSuspendAll() 的作用只是:禁止调度,禁止任务之间的切换。

但它并没有禁止中断。


这就意味着,在调度器被暂停期间:

  1. 虽然当前任务不会被其他任务打断,
  2. 但仍然可能被中断打断。

如果中断中也访问了同一份共享资源,依然可能发生线程安全问题。

因此,从严格意义上讲:

vTaskSuspendAll() 只能保证"任务级别"的原子操作,不能保证"系统级别"的原子性。

也就是说:

  1. 它只能防止"任务与任务之间"的竞争,导致数据出现问题。
  2. 但无法防止"任务与中断之间"的竞争。

所以可以得到一个非常重要的结论:

暂停调度器,只是弱化版的临界区,而不是真正的临界区。

当然:

能不能用暂停调度器,取决于你的资源是否会被中断访问;只要涉及中断,就不能把它当作临界区使用。


什么样才算真正的临界区呢?

很简单,临时禁用中断,就是真正意义上的实现"系统级别原子操作",是真正的临界区。

日常口语描述下,我们常说的"临界区"也是指"禁用中断"级别的临界区。

使用建议

什么时候可以使用"暂停调度器"这种级别的弱化版临界区呢?

它适用的场景其实是非常有限的,毕竟它是弱化版。

一般来说,只有在满足下面条件时,才适合使用:

第一,共享资源只会被"任务访问",不会被中断访问。

也就是说:

这份共享资源,只存在于任务之间的竞争,

中断中不会去读写它。

在这种情况下,只需要防止任务切换即可,即便中断"插一脚"也不会对共享资源产生影响。

此时使用暂停调度器就是安全的。

第二,被保护的代码执行时间非常短

暂停调度器期间:

所有任务调度都会被延后,如果这段时间过长,就会影响系统实时性。

因此:

只适合保护很短的一段代码。

因此不能在保护代码中执行延时、等待等操作。

临界区

在 FreeRTOS 当中,一个真正意义上能被称为临界区的机制,应当同时满足两个条件:

  1. 能防止任务切换
  2. 还能防止中断打断

FreeRTOS已经给我们提供了这样的一组 API,如下:

c 复制代码
taskENTER_CRITICAL();   // 进入临界区,不会被中断和任务打断
taskEXIT_CRITICAL();    // 退出临界区

这一组 API 的核心思想也非常简单:

禁用中断,关闭中断,来保证当前代码执行期间不被打断。

也就是说,只要中断被关闭,就能够同时实现两大功能:

  1. 不会被中断打断
  2. 不会发生任务切换

于是你马上就会提出一个疑问:

关闭中断后,不会被中断打断,这很正常,很好理解。但为什么同时也不会发生任务切换呢?

为了解释这个问题:

我们还需要先了解一下,FreeRTOS任务切换的过程,究竟是怎么进行的。

FreeRTOS任务切换的流程

在前面的课程中,我们已经对 FreeRTOS 的任务切换有了初步了解:

  1. 任务被切换时,会将当前上下文信息保存到自身的任务栈中
  2. 当任务再次运行时,从任务栈中恢复上下文信息,继续执行

但是,这里还存在两个关键问题:

  1. 任务是在什么时候发生切换的?
  2. 任务切换的具体执行过程是怎样的?比如会调用哪些函数?

这些内容在前面并没有展开。

这一小节,我们就来把这一部分补完整。

首先要明确一个非常关键的结论:

FreeRTOS 的任务切换,并不是"随时发生的",也不是在哪个函数中完成的,而是必然在ISR中断完成的。

这也是临界区禁止中断后,就可以禁止任务切换的根本原因!

FreeRTOS 任务切换流程,可以直接分为两大步:

  1. 触发任务切换请求,也就是触发任务调度器进行调度。
  2. 在 PendSV 中断服务函数(PendSV_Handler)中完成任务切换。

我们先不讲调度如何触发,因为有多种方式,不妨先来看一下 PendSV_Handler 是如何实现任务切换的。

PendSV_Handler中断处理函数

在 FreeRTOS 的移植过程中,我们通过宏定义:

复制代码
#define xPortPendSVHandler PendSV_Handler

将 PendSV_Handler 中断服务函数交由 FreeRTOS 内核接管。

因此可以明确一点:

FreeRTOS 中的任务切换,并不是在任务调用某个函数中直接完成的,而是统一在 PendSV_Handler 中断中完成。

也就是说:

任务本身只能"触发调度","请求一次调度"

但真正的任务切换,也就是真正的"上下文切换动作",一定发生在 PendSV_Handler 中。

这也是 FreeRTOS 的一项强制设计:

所有任务切换,都必须在中断环境中完成,而这个中断就是 PendSV。

这样设计的目的很明确:

将任务切换这一复杂且关键的操作,统一放在一个固定入口中执行,从而保证切换过程的可控性与一致性。

总结一句话:

PendSV_Handler 就是 FreeRTOS 任务上下文切换的唯一执行现场。

上下文切换流程

内核接管的 PendSV_Handler 函数处理,其源码大多都是汇编代码。

但从整体逻辑上看,它的执行可以概括为三步:

  1. 先保存当前任务的上下文信息(入栈)。
    1. 当前正在运行的任务进入 PendSV 中断后
    2. 会将当前任务(也就是pxCurrentTCB指针指向的任务)需要保存的CPU内核寄存器压入当前任务栈中
    3. 包括当前任务的任务栈栈顶等信息也都会保存起来。
    4. 这就是任务上下文切换过程中的上下文保存。
  2. 调用函数vTaskSwitchContext(),判断是否需要切换任务。
    1. 该函数的作用是:
    2. 根据当前系统状态(优先级、就绪列表等),判断是否需要切换任务,并选择下一个要运行的任务。
    3. 也就是说:PendSV 只是提供"切换的执行环境",只做"上下文的保存与恢复",是否真的发生任务切换,是由调度器决定的。
  3. 恢复上下文信息(出栈)。
    1. 根据 pxCurrentTCB 指向的任务,将它的上下文信息,从任务栈中恢复,恢复 CPU 的执行现场。
    2. 完成后,中断执行完毕,退出中断。
    3. 注意:pxCurrentTCB 指向的任务可能是新任务,也完全可以还是旧任务,具体是哪一个任务要看优先级和就绪列表的状态。
    4. 所以:如果调度器选择了新的任务,那么就完成了任务切换,如果仍然是原任务,那么实际上只是"进了一次 PendSV,又回来"。

总结一句话:

PendSV 负责"执行切换",vTaskSwitchContext 决定"要不要切换、切换到谁"。

扩展/补充了解:为什么选择PendSV这个中断来切换任务

这牵扯到FreeRTOS的内核底层设计问题,而且这是一个非常巧妙的设计,非常推进大家阅读学习一下。

我们一步步得来回答这个问题:

第一,我们首先解释一下为什么要用中断来完成任务切换。

原因很简单:

  1. 如果在普通任务环境下进行任务切换,这是非常不合理的。因为:
    1. 任务处于执行状态下,当前代码本身也在用CPU寄存器
    2. 那怎么完成任务上下文的保存呢?
    3. 一边动态运行,一边记录静态的状态,显然不可能。
    4. 所以,任务切换必然不可能在任务环境下完成。
  2. 使用中断切换任务还有一个天然的好处:
    1. 进入中断时,任务已经处于冻结状态,硬件自动保存任务执行的上下文。
    2. 在这种状态下,进行任务的切换是非常合理且安全的。

总之,只有在中断上下文中,任务切换才是"安全且可控的"。

第二个问题,为什么非要选择PendSV这个中断来切换任务呢?

首先,我们要明确的一些前提是:

FreeRTOS明确要求,STM32集成FreeRTOS,需要设置使用优先级分组4。

FreeRTOS-CM3使用注意

如下图所示:

也就是说,整个STM32中断系统:

没有子优先级,全部4位优先级位寄存器都用于设置抢占优先级,抢占优先级的取值范围是:0 ~ 15

当然,中断的优先级数值越大,优先级越低。(这和FreeRTOS的任务优先级是恰好相反的)

并且:

FreeRTOS启动后,任务成为了"主程序执行",所有中断都可以打断任务的执行。

现在来回答问题:

为什么非要选择PendSV这个中断来切换任务呢?

原因很简单,PendSV中断的优先级最低,默认是15,源码设置如下所示:

这里有两个中断:PendSV和Systick中断。

在内核优先级寄存器中,它们的存储位置如下:

| 31~24 | 23 ~ 16 | 15 ~ 8 | 7 ~ 0 | ↑ ↑ SysTick PendSV

并且是高4位生效。

所以在FreeRTOS当中,PendSV和Systick中断的优先级都是抢占优先级15,属于优先级最低的任务。

这样设计的好处是:

它们执行的时候,一定是在"所有更高优先级中断都执行完"之后,它们一定不会打断其他任务的执行!

这样做就使得中断过程中执行的任务切换,可以更加安全的完成。

举个例子:

系统中 USART 中断正在执行:

c 复制代码
uint8_t data = USART_ReceiveData();
xQueueSendFromISR(...);   // 触发调度

如果 PendSV 优先级不低,它会立刻抢占当前 USART 中断,在中断还没执行完的时候就开始任务切换。

这样会导致中断逻辑被打断,数据可能没处理完,系统状态也可能被破坏,这是不安全的。

而在 FreeRTOS 中,PendSV 被设置为最低优先级。

当在中断中触发调度时,PendSV 不会立刻执行,而是等当前 USART 中断执行完并退出之后,再执行任务切换。

关键点在于:任务切换发生在中断执行完成之后,而不是中断执行过程中。

总结一句话:PendSV 设为最低优先级,是为了保证任务切换不会打断正在执行的中断。

扩展/补充了解:任务的第一次执行

既然任务切换是通过中断完成的,那么任务第一次上CPU是怎么完成的呢?

首先,任务第一次上CPU也是通过中断来完成的。

既然任务第一次上CPU,也需要依赖中断来完成,于是就自然存在一个问题:

任务都还没有开始执行,任务栈中怎么会保存任务的上下文信息呢?(中断结束后,需要恢复上下文执行任务)

原因很简单:

第一次运行的任务,其"上下文"并不是上一次运行保存出来的,而是FreeRTOS"伪造出来的"。

FreeRTOS在创建任务时,会在该任务的任务栈中,提前构造好了一个"假的中断现场、一个假的任务执行上下文"。

因此,这个"伪造的上下文",本质上是在模拟一种场景:

就好像这个任务曾经运行过一次,被中断打断,现在需要被恢复执行。

换句话说:

FreeRTOS在创建任务时,已经提前帮你把"任务第一次运行时所需要的CPU寄存器状态、以及其他信息",全部压入了任务栈中。

然后中断结束,上下文恢复后,CPU就会从任务的入口函数开始执行。

如此,就完成了任务的第一次执行。


但具体到细节:

  1. FreeRTOS内核调度器启动后的第一个任务执行,真正意义上的第一个任务执行。
  2. 后续其他任务的第一次执行。

它们还是有区别的。

其中:

  1. SVC 中断是任务调度的入口,它负责来启动系统的第一个任务,将整个系统从MSP模式转换成PSP模式。
  2. PendSV 是任务调度的"切换器",由它来负责系统第二个任务开始的,所有任务的切换与执行。

举一个例子:

ABC三个优先级一致的任务,首先执行任务C。

于是由 SVC 中断来启动任务C,这是整个系统的第一个任务执行,由SVC中断完成。

随后AB任务的第一次执行,包括随后的ABC三者时间片轮转,都由PendSV中断完成任务的切换。

小Tips:

  1. 任务栈的栈基地址是任务栈的最低地址处,且整个任务生命周期内保持不变。
  2. 但任务栈的栈顶地址,在任务创建后就不再是任务栈的最高地址了,而是已经向低地址偏移,指向"伪造上下文"所在的位置。

好了,我们讲完了任务上下文切换的细节,那么我们再回过头来看一下,任务调度是如何触发的。

任务主动触发调度:taskYIELD()

在前面的学习中,我们已经接触过一种主动触发任务调度的方式:

c 复制代码
taskYIELD();

它由某个任务调用,主动让出CPU,主动触发一次任务调度。

最终的结果就是触发 PendSV 中断,在 PendSV_Handler 中根据任务调度机制来决定谁上CPU。

随后完成必要的上下文切换流程。

最常见的触发调度方式:Tick中断(重点)

在 FreeRTOS 中,任务调度的触发方式有多种,但最常见、也是最核心的一种方式,就是Tick 中断

什么是Tick中断

在 FreeRTOS 中,Tick 中断可以这样理解:

Tick 中断,是一个按照固定时间间隔、周期性触发的系统定时中断。

很显然,这种周期性的、时间强相关的中断,必然需要定时器外设的参与。

在FreeRTOS中,Tick 中断实际上就是由最简单的定时器------**系统嘀嗒定时器(SysTick)**触发的。

它会按照设定好的周期,不断重复触发中断。

Tick 中断的产生频率,由如下宏决定:

c 复制代码
#define configTICK_RATE_HZ ( ( TickType_t ) 1000 )

所以对于这个宏的理解,我们进一步加深了:

这个宏配置了系统每秒会产生多少次 Tick,每秒划分为多少个时间片,也是每秒会产生多少次Tick中断。

换一个更本质的理解方式:

Tick 是 FreeRTOS 的最小时间单位。

系统时间被划分为一个个离散的 Tick,每到一个 Tick,系统时间就向前推进一步。

每一个新的 Tick 到来时,都会触发一次 Tick 中断。


Tips:

SysTick系统嘀嗒定时器产生的Tick中断,其中断处理也被FreeRTOS接管了。

这就是我们在移植FreeRTOS时,使用的下面配置宏:

c 复制代码
#define xPortSysTickHandler SysTick_Handler

关于SysTick系统嘀嗒定时器中断,我们后续定时器课程还会再详谈它,这里你了解一下即可。

Tick中断做了什么事情呢?

Tick中断做的事情,我们太熟悉了。

实际上它就做了这三件事情:

  1. 系统全局Tick计数 + 1
  2. 遍历延时阻塞列表,查看是否有任务需要唤醒
  3. 若需要切换任务,则触发一次系统调度。即触发PendSV中断。

这也解释了一个问题:

引入FreeRTOS后,Systick系统嘀嗒定时器,被用于产生FreeRTOS系统节拍,产生Tick中断。

既然Systick系统嘀嗒定时器已经被占用了,那我们就不要再用了。

之前我们实现的,基于Systick系统嘀嗒定时器的延时函数,就不能再使用了。

其他触发任务调度的场景

看到这里,我们已经知道:

所谓触发任务调度,本质上就是触发PendSV中断,在中断中完成任务调度。

除了上面讲的两个场景外:

  1. 任务进入阻塞态,也会触发中断,进行任务调度。
  2. 某个任务被唤醒(时间到期、事件发生),也会触发中断,进行任务调度。
  3. ...

taskENTER_CRITICAL()临界区

FreeRTOS 中另一种构建临界区的方式 ------ taskENTER_CRITICAL()

除了通过挂起调度器(vTaskSuspendAll / xTaskResumeAll)构建"调度级临界区"之外,

FreeRTOS 还提供了更常见的一种临界区机制:

taskENTER_CRITICAL() 与 taskEXIT_CRITICAL()。

这是一对专门用于临界资源保护的函数。

c 复制代码
taskENTER_CRITICAL();
...
taskEXIT_CRITICAL();

调用 taskENTER_CRITICAL() 表示进入临界区,

调用 taskEXIT_CRITICAL() 表示退出临界区。

这两个函数必须成对使用,用于包裹需要被保护的代码段。

当进入临界区后:

当前任务不会被任务调度器切换出去。

示例代码如下:

c 复制代码
#include "stm32f10x.h"      // STM32F10x 标准外设库头文件

#include "FreeRTOS.h"       // FreeRTOS 核心头文件
#include "task.h"           // FreeRTOS 任务相关 API

#include "DebugUSART1.h"    // 调试串口模块(printf1)

// -------------------- 任务1 --------------------
void task1(void * arg) {
    while (1) {
        printf1("task1\r\n");
    }
}

// -------------------- 任务2 --------------------
void task2(void * arg) {
    while (1) {
        printf1("task2\r\n");
    }
}

// -------------------- 开始任务 --------------------
void beginTask(void * arg) {
    TaskHandle_t hand1 = NULL;
    TaskHandle_t hand2 = NULL;

    taskENTER_CRITICAL();

    xTaskCreate(task1,
                "Task1",
                configMINIMAL_STACK_SIZE,
                NULL,
                3,
                &hand1);

    xTaskCreate(task2,
                "Task2",
                configMINIMAL_STACK_SIZE,
                NULL,
                3,
                &hand2);

    taskEXIT_CRITICAL();

    while (1) {
        printf1("xTaskResumeAll\r\n");
    }
}

// -------------------- main函数 --------------------
int main(void) {
    DebugUSART1_Init();

    xTaskCreate(beginTask,
                "beginTask",
                configMINIMAL_STACK_SIZE,
                NULL,
                2,
                NULL);

    vTaskStartScheduler();

    while (1) {
    }
}

在这段代码中,

任务创建过程被临界区包裹,

在退出临界区之前,不会发生任务调度。


挂起调度器 与 taskENTER_CRITICAL 的区别

虽然这两种方式都可以"保护代码",

但它们的本质机制不同。

vTaskSuspendAll / xTaskResumeAll 的特点

它们的作用是暂停任务调度器。

调用 vTaskSuspendAll() 后:

  1. 不发生任务切换;
  2. 但中断仍然可以发生;
  3. 中断服务函数仍然会执行。

如果在中断中:有更高优先级任务被唤醒进入就绪态,

那么在恢复调度器时,可能立即发生任务切换。

因此可以总结为:

挂起调度器 = 只禁止任务调度,不禁止中断。

taskENTER_CRITICAL 的特点

taskENTER_CRITICAL() 不仅:

禁止任务调度;

还会:屏蔽部分中断。

具体屏蔽哪些中断,取决于:

复制代码
#define configMAX_SYSCALL_INTERRUPT_PRIORITY 191

在 Cortex-M3 架构下:

NVIC 使用高 4 位作为中断的有效优先级。

191 对应 0xB0 → 1011 1111,只取高四位,就是1011,即十进制优先级 11。

由于 Cortex-M 中:

数值越小,优先级越高。

因此:

进入临界区后,

会屏蔽优先级数值为 11~15 的中断,

不会屏蔽优先级 0~10 的中断。

可以理解为:

只屏蔽"低优先级中断",

高优先级中断仍然可以响应。

当然,屏蔽了中断,自然就屏蔽了任务调度。


如何选择使用哪种方式

可以这样理解:

如果希望:

保护"短时间关键代码",并且不希望被中断打断,

可以使用:taskENTER_CRITICAL() / taskEXIT_CRITICAL()

如果希望:允许中断响应,但不允许任务切换。

可以使用:

vTaskSuspendAll() / xTaskResumeAll()


为什么不建议长时间进入临界区

无论使用哪种方式,都不建议在临界区中执行长时间代码。

原因如下:

  1. 第一,实时性会下降。
    1. 如果长时间关闭调度或屏蔽中断,
    2. 高优先级任务和中断将无法及时响应。
  2. 第二,系统抖动增加。
    1. 延迟的中断会在退出临界区后集中执行,
    2. 可能导致系统瞬时负载增大。
  3. 第三,可能引发看门狗复位。
    1. 若长时间屏蔽中断,某些周期性中断无法执行,
    2. 可能触发系统异常。

因此临界区应尽量短小,只保护必要的共享数据操作,避免在临界区中:

  1. 执行 printf
  2. 调用 vTaskDelay
  3. 进行复杂逻辑计算
  4. 执行阻塞操作

总结一下,taskENTER_CRITICAL临界区:

  1. 禁止调度 + 屏蔽部分中断,以此来实现一个更加安全的临界区
  2. 适合极短关键代码保护

总结一下,vTaskSuspendAll临界区:

  1. 只禁止调度,允许中断响应
  2. 适合需要保证连续执行但又不想影响中断的场景

合理选择机制,是实时系统设计的重要能力之一。

中断和任务间的屏蔽问题

中断一定能打断任务吗?临界区是如何真正屏蔽中断的?

我们之前讲过一句话:

在 FreeRTOS 中,ISR 的优先级天然高于任务优先级,因此中断可以随时打断任何任务的执行。

这句话是正确的。

但随后我们又看到:

taskENTER_CRITICAL() 可以屏蔽部分中断;

这看起来似乎有些矛盾,其实并不矛盾。关键在于:

中断是否能打断任务,取决于"CPU当前是否允许这个等级的中断执行"。

而 FreeRTOS 的临界区机制,本质上是操作了 Cortex-M 的中断屏蔽寄存器。

回顾:STM32 中的中断屏蔽寄存器

在 Cortex-M 内核中,常见的中断屏蔽寄存器有三个:

  1. BASEPRI
  2. PRIMASK
  3. FAULTMASK

它们的作用分别不同:

一、BASEPRI

BASEPRI 用于屏蔽"低优先级中断"。

当设置 BASEPRI = X 时:

优先级数值 ≥ X 的中断将被屏蔽。

注意:

Cortex-M 中优先级数值越小,优先级越高。

因此:

设置一个 BASEPRI 值,本质是设定一个"屏蔽阈值"。

高于这个阈值(数值更小)的中断仍然可以触发。

这正是 FreeRTOS 使用的核心机制。

二、PRIMASK

PRIMASK 只有一位:

  1. PRIMASK = 1 → 屏蔽所有普通 IRQ
  2. PRIMASK = 0 → 允许普通 IRQ

即:

它会屏蔽所有可屏蔽中断,

但不会屏蔽:

  1. NMI
  2. HardFault

这两个系统级别中断

三、FAULTMASK

FAULTMASK = 1 时:

  1. 屏蔽所有可屏蔽中断
  2. 甚至屏蔽 HardFault

仍然不会屏蔽 NMI。

这个寄存器极少在普通应用中使用。

关于 HardFault 与 NMI

HardFault:

当系统发生严重异常时触发,例如:

  1. 访问非法地址
  2. 执行非法指令
  3. 总线错误
  4. 使用错误

默认处理函数通常是死循环:

复制代码
while(1);

NMI(Non-Maskable Interrupt):

最高优先级中断,

不能被任何寄存器屏蔽。

常见触发原因:

  1. 看门狗异常
  2. 时钟失锁
  3. 外部硬件异常

FreeRTOS 是如何实现临界区的?

理解了这些寄存器之后,

我们就可以回答:

taskENTER_CRITICAL() 是如何屏蔽中断的,以及taskEXIT_CRITICAL() 如果退出临界区,解除屏蔽的。


taskENTER_CRITICAL 屏蔽中断的实现原理:

设置 BASEPRI = configMAX_SYSCALL_INTERRUPT_PRIORITY。

也就是说:

屏蔽优先级数值 ≥ configMAX_SYSCALL_INTERRUPT_PRIORITY 的中断。

由于 Cortex-M 采用"数值越小优先级越高"的规则,因此:

  1. 数值较大的(低优先级中断) → 被屏蔽
  2. 数值较小的(高优先级中断) → 仍然可以响应

换句话说:

FreeRTOS 并没有关闭所有中断,而是只屏蔽"可调用系统 API 的那一类中断"。

这样设计的目的,是在保证临界区安全的同时:

仍然允许极高优先级的中断(如紧急中断)继续执行,从而保证系统实时性。

另外,taskENTER_CRITICAL() 还做了一件重要的事情:

维护临界区嵌套计数(uxCriticalNesting)

也就是说:

  1. 每调用一次 taskENTER_CRITICAL → 计数 +1
  2. 支持"临界区嵌套"使用

taskEXIT_CRITICAL 解除中断屏蔽的过程:

taskEXIT_CRITICAL() 并不会每次调用都立即恢复中断,而是:

  1. 先将嵌套计数减 1
  2. 只有当计数减到 0 时,才真正恢复中断

最终的恢复动作是:

c 复制代码
BASEPRI = 0

这表示:

取消中断屏蔽阈值,恢复所有可屏蔽中断的响应能力。


核心总结

taskENTER_CRITICAL 的本质:

都是通过修改 Cortex-M 的 BASEPRI 寄存器,来控制中断响应范围。

它们并不是在"操作任务",而是在操作"CPU 的中断屏蔽级别"。

因此:

中断是否能打断任务,最终取决于当前的中断屏蔽状态,而不是任务优先级本身。

临界区的退出

xTaskResumeAll() 不同,taskEXIT_CRITICAL() 几乎不做"内核层面的处理"。

它只做一件事: 先嵌套计数-1,然后在嵌套计数归零时,把 BASEPRI 清零,恢复中断响应。

之后发生的事情,其实不是它"主动做的",而是硬件自动触发的:

  1. 之前被屏蔽的中断(如 Tick)开始执行
  2. 如果这些中断触发了调度(如 PendSV)
  3. 才会进一步发生任务切换

所以可以这样对比总结:

  1. xTaskResumeAll():主动处理一堆内核事务(补调度、处理延迟列表等)
  2. taskEXIT_CRITICAL():只恢复中断,后续一切交给中断机制自然推进

一句话总结:

调度器恢复是"内核主动收尾",而临界区退出只是"打开中断开关"。

临界区嵌套

taskENTER_CRITICAL() 的嵌套机制

在 FreeRTOS 中,taskENTER_CRITICAL() 支持嵌套调用。

也就是说:

可以在一个临界区内部再次进入临界区,而不会破坏中断屏蔽状态。

其本质实现方式是:

通过内部计数器(临界区嵌套计数)来维护嵌套深度。

具体机制如下:

  1. 每调用一次 taskENTER_CRITICAL() → 计数器递增;
  2. 每调用一次 taskEXIT_CRITICAL() → 计数器递减;
  3. 只有当计数器递减至 0 时,才真正恢复中断。

这意味着:

只有"最后一次退出"临界区时,才会重新开启中断。

嵌套示例

c 复制代码
taskENTER_CRITICAL();  // 第一次进入,计数器 = 1,屏蔽中断

// ... 临界区代码1 ...

taskENTER_CRITICAL();  // 第二次进入,计数器 = 2,中断仍然屏蔽

// ... 临界区代码2 ...

taskEXIT_CRITICAL();   // 计数器 = 1,中断仍然保持屏蔽

taskEXIT_CRITICAL();   // 计数器 = 0,恢复中断

可以看到:

中断只在"最外层临界区退出"时恢复。

为什么需要嵌套支持

在实际工程中,

某些函数内部可能已经使用了临界区,

而上层调用函数也可能使用了临界区。

如果不支持嵌套,

则会导致:

  1. 中断提前被恢复;
  2. 临界区保护失效;
  3. 出现数据竞争风险。

因此:

嵌套计数机制是保证临界区安全性的关键。


底层实现说明(Cortex-M)

在 Cortex-M 端口中:

FreeRTOS 使用 BASEPRI 寄存器来屏蔽部分中断。

taskENTER_CRITICAL():

  1. 提高 BASEPRI 屏蔽级别;
  2. 同时增加嵌套计数。

taskEXIT_CRITICAL():

  1. 减少嵌套计数;
  2. 当计数归零时恢复 BASEPRI。

注意事项

必须保证:

每一个 taskENTER_CRITICAL()都有一个对应的 taskEXIT_CRITICAL()

否则:

计数器无法归零,中断将永久处于屏蔽状态,系统将失去实时响应能力。


小结

taskENTER_CRITICAL 的嵌套机制依赖:

内部计数器维护临界区层级。

其原则是:

进入几次,就必须退出几次。只有最外层退出时,中断才会真正恢复。

重要的提醒

在我们创建使用多任务FreeRTOS系统时,(默认配置下)如果希望禁用中断的临界区功能生效,应该遵循以下原则:

  1. 用户自定义中断的优先级不要高于阈值(11),即中断优先级数值应满足:大于或等于11。
    1. 推荐设置为 11 或 12。
    2. 这样这些中断才能被 BASEPRI 正常屏蔽,从而保证临界区有效。
    3. 如果优先级设置得更高(数值更小,例如 0~10),则不会被屏蔽,临界区将失去保护作用。
  2. 自定义中断优先级设置为 11 或 12 已经足够使用,这是因为:
    1. 它们仍然属于"较高优先级中断",响应速度已经很快(毕竟这个优先级已经高于任务调度的中断优先级了)。
    2. 同时又处于 FreeRTOS 可管理范围内(可被 BASEPRI 屏蔽),可以安全调用 FromISR API。

注意:

只有优先级数值 ≥ configMAX_SYSCALL_INTERRUPT_PRIORITY 的中断,才允许调用 FreeRTOS API(FromISR 版本)。

原因也很简单:

FreeRTOS 内核在操作关键数据结构(如就绪列表、延时列表等)时,大量依赖临界区进行保护。

而临界区的本质,是通过 BASEPRI 屏蔽"一部分中断"。

如果你定义的中断优先级过高(数值更小),不受 BASEPRI 控制,那么就会出现这种情况:

内核正在修改链表 → 你这个高优先级中断突然打断 → 又调用了 FreeRTOS API → 再次操作这些链表

结果就是:

内核数据结构被破坏,系统直接异常甚至崩溃。

所以,必须牢记一个结论:

用户自定义中断的优先级,不要高于 configMAX_SYSCALL_INTERRUPT_PRIORITY 对应的阈值。

也就是说:

中断优先级数值必须 ≥ 该阈值(例如 ≥ 11)。

相关推荐
小智老师PMP2 小时前
零基础能不能考PMP?零基础专属学习路径+全套扶持体系
学习·算法·职场和发展·软件工程·求职招聘·敏捷流程
XGeFei4 小时前
【Fastapi学习笔记(4)】—— JsonScheme与数据验证、错误响应格式、正则表达式
学习·fastapi
爱喝水的鱼丶4 小时前
SAP-ABAP:SAP 简单报表输出开发系列(共6篇) 第四篇:SAP 报表异常处理机制:数据校验与消息提示规范落地
开发语言·数据库·学习·算法·sap·abap
東雪木5 小时前
泛型、反射、注解(Spring 框架核心底层)专属复习笔记
java·windows·笔记·学习·spring
小陈phd6 小时前
多模态大模型学习笔记(四十七)——跨模态融合策略:早融合、中融合与晚融合核心解析
笔记·学习
进击的小头6 小时前
第7篇:MOS 管最全入门:原理、关键参数、选型、驱动与典型应用
经验分享·科技·嵌入式硬件·学习
叶子野格6 小时前
《C语言学习:文件操作》16
c语言·开发语言·c++·学习·visual studio
ZC跨境爬虫6 小时前
SQL学习日志 Day_3 :(SELECT查询语句入门)
数据库·sql·学习·oracle
小郑加油7 小时前
一周读懂博弈论:从理性决策到信息博弈_Day2博弈论基础与战略思维
学习·管理学·经济学