FreeRTOS学习(6)——任务创建

文章目录

准备工作:引入串口打印工具

在正式讲解 FreeRTOS 任务的两种创建方式 之前,我们先引入一套串口打印工具。

原因其实很简单:

FreeRTOS 是一个多任务系统,一旦任务数量多了,代码就不再是"从上往下顺序执行"的形式了。

此时如果还只靠 LED 闪烁 来判断程序是否正常运行,不但信息量太少,而且调试效率极低。

因此,在后续的学习中,我们需要一种方式,能够:

  1. 实时输出任务的执行信息
  2. 观察不同任务是否被成功创建
  3. 判断任务是否在运行、是否发生切换
  4. 直观看到不同创建方式下的执行差异

最合适、也最常用的手段,就是------串口打印调试信息。

为此,这里我们提前封装了一套基于 USART1 的简易打印工具,提供类似 printf 的输出能力。

在后续的任务创建实验中,我们将大量使用该工具,在关键位置打印日志信息,用"看得见的输出",来验证 FreeRTOS 行为是否符合预期。

从这一刻开始:

串口输出 = 我们观察 FreeRTOS 内部行为的"眼睛"

参考实现代码

找到我们之前创建的FreeRTOS开发标准模板,在工程根目录下创建一个"Utils"文件夹,并添加到Keil5工程中。

然后在"Utils"目录下新增一个"DebugUSART1"模块。

具体需要创建两个文件,它们的内容如下:

DebugUSART1.h头文件:

c 复制代码
#ifndef __DEBUG_USART1_H__
#define __DEBUG_USART1_H__

#include "stm32f10x.h"
// 以下两个头文件中的某些函数声明,在实现printf1函数时需要用到
#include <stdio.h>  
#include <stdarg.h>

/**
 * @brief  初始化调试串口 USART1
 * @note   用于调试输出,不作为通信接口使用
 */
void DebugUSART1_Init(void);

/**
 * @brief  调试打印函数(串口版 printf)
 */
void printf1(const char *format, ...);

#endif

DebugUSART1.c实现源文件:

c 复制代码
/* DebugUSART1.c
 *
 * 模块定位:
 * ------------------------------------------------------------
 * 本文件实现一个基于 USART1 的调试输出工具,
 * 用于在 FreeRTOS 实验中观察任务的创建、运行与调度行为。
 *
 * 设计原则:
 *  1) 对外仅暴露 printf1 接口
 *  2) 底层串口发送细节完全封装
 *  3) 采用阻塞式发送,逻辑直观,便于教学理解
 */

#include "DebugUSART1.h"

/* ================= 内部函数(不对外暴露) ================= */

/**
 * @brief  USART1 发送单字节(阻塞式)
 * @note   仅供本文件内部使用
 */
static void DebugUSART1_SendByte(uint8_t Byte) {
    USART_SendData(USART1, Byte);

    /* 等待发送数据寄存器为空 */
    while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);
}

/* ================= 对外接口实现 ================= */

/**
 * @brief  初始化 USART1,用于调试输出
 */
void DebugUSART1_Init(void) {
    /* 1. 开启 USART1 和 GPIOA 外设时钟 */
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE);
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);

    /* 2. 初始化 GPIO 引脚 */
    GPIO_InitTypeDef GPIO_InitStructure;

    /* PA9 -> USART1_TX:复用推挽输出 */
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOA, &GPIO_InitStructure);

    /* PA10 -> USART1_RX:上拉输入 */
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOA, &GPIO_InitStructure);

    /* 3. USART 参数配置:115200, 8N1 */
    USART_InitTypeDef USART_InitStructure;
    USART_InitStructure.USART_BaudRate = 115200;
    USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;
    USART_InitStructure.USART_Mode = USART_Mode_Tx | USART_Mode_Rx;
    USART_InitStructure.USART_Parity = USART_Parity_No;
    USART_InitStructure.USART_StopBits = USART_StopBits_1;
    USART_InitStructure.USART_WordLength = USART_WordLength_8b;
    USART_Init(USART1, &USART_InitStructure);

    /* 4. 使能 USART1 */
    USART_Cmd(USART1, ENABLE);
}

/**
 * @brief  调试打印函数(串口版 printf)
 * @param  format: 格式化字符串,和printf函数的第一个参数完全一致
 * @note
 *         1. 用法与 printf 类似
 *         2. 实际输出通过 USART1 完成
 *         3. 用于 FreeRTOS 任务调试与运行观测
 */
void printf1(const char *format, ...) {
    // 初始情况下,单次发送字符串最大长度为99
    // 后续可以根据实际情况,增加buffer的容量
    char buffer[100];

    /*
        参照C语言库函数中printf函数的实现
        把 printf1("xxx", 参数...) 里的可变参数
        安全地取出来,并按格式拼成一段字符串。

        具体的实现,大家可以不了解。
    */
    va_list list;
    va_start(list, format);
    vsprintf(buffer, format, list);
    va_end(list);

    /* 逐字节输出数据到串口,并简单处理Windows端的\n换行 */
    for (uint16_t i = 0; buffer[i] != '\0'; i++) {
        if (buffer[i] == '\n') {
            DebugUSART1_SendByte('\r');
        }
        DebugUSART1_SendByte((uint8_t)buffer[i]);
    }
}

给出一个测试用的"main.c"文件的代码:

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

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

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

// -------------------- 任务1 --------------------
void vTask1(void *arg) {
    while (1) {
        printf1("task1 \n");
        vTaskDelay(2000);      // 延时 2000 ms
    }
}

// -------------------- 任务2 --------------------
void vTask2(void *arg) {
    while (1) {
        printf1("task2 \n");
        vTaskDelay(2000);      // 延时 2000 ms
    }
}

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

    // 2. 创建任务1
    xTaskCreate(vTask1,
                "Task1",
                configMINIMAL_STACK_SIZE,
                NULL,
                tskIDLE_PRIORITY + 1,
                NULL);

    // 3. 创建任务2(与任务1优先级相同)
    xTaskCreate(vTask2,
                "Task2",
                configMINIMAL_STACK_SIZE,
                NULL,
                tskIDLE_PRIORITY + 1,
                NULL);

    printf1("tasks created \n");

    // 4. 启动 FreeRTOS 调度器
    vTaskStartScheduler();

    // 理论上不会运行到这里
    while (1) {
    }
}

使用 printf1 与串口调试的注意事项:

  1. 在 printf1 的实现中,我们对字符串中的 "\n" 进行了额外处理,在实际发送到串口时,会自动转换为 "\r\n"。
    1. 这是因为,之前C语言标准库的printf函数也会自动根据平台处理换行符。
    2. 当前我们在Windows环境下使用此调试工具,所以换行符是"\r\n"。
    3. 如果不进行这样的处理,每次发送换行都需要明确使用"\r\n"。
  2. 使用串口助手给STM32发数据时,如果发送换行符:
    1. 建议使用串口工具,本身自带的发送换行符的功能。
    2. 不建议在发送框中手动输入 "\r\n" 或类似字符序列。
    3. 因为你不知道这个串口工具内部究竟如何处理换行符,如果它不按照你的预期发送字符,就会出问题。
  3. 如果多测试几次,尤其是将发送数据加长,很容易看到数据发送非常混乱。关于这一点,后续会进行解释。

带串口调试功能的FreeRTOS标准工程

删除main.c中的一些内容,得到以下内容:

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

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

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

// -------------------- 任务1 --------------------
void vTask1(void *arg) {
    while (1) {
        vTaskDelay(1);
    }
}

// -------------------- 任务2 --------------------
void vTask2(void *arg) {
    while (1) {
        vTaskDelay(1);
    }
}

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

    // 2. 创建任务1
    xTaskCreate(vTask1,
                "Task1",
                configMINIMAL_STACK_SIZE,
                NULL,
                tskIDLE_PRIORITY + 1,
                NULL);

    // 3. 创建任务2(与任务1优先级相同)
    xTaskCreate(vTask2,
                "Task2",
                configMINIMAL_STACK_SIZE,
                NULL,
                tskIDLE_PRIORITY + 1,
                NULL);

    printf1("tasks created \n");

    // 4. 启动 FreeRTOS 调度器
    vTaskStartScheduler();

    // 理论上不会运行到这里
    while (1) {
    }
}

如此,我们就得到了一个带有串口调试模块的FreeRTOS模板工程。

在"王道嵌入式软件环境"下也提供了这样的模板工程。

任务创建的两种方式

FreeRTOS的官网文档中,对于创建任务 - FreeRTOS™,一共有三种方式:

其中第三种方式,xTaskCreateRestrictedStatic函数:

在第二种方式,静态创建任务的基础上,还允许为任务额外限制可访问的内存区域。

但这种方式,只有在支持内存保护单元(MPU)的MCU上才可以使用。

STM32F1系列、Cortex-M3架构的单片机都不支持MPU功能,所以这种创建方式我们无法使用。

该种创建任务的方式,主要面向安全隔离、工业安全、功能安全认证等场景。

在绝大多数一般的嵌入式场景中,都不需要这么严密的安全保障,所以很少会用到。


下面我们主要讲解两种创建任务的方式:动态创建和静态创建任务。

  1. 动态创建任务,xTaskCreate,最常用的任务创建方式,由 FreeRTOS 动态分配任务控制块和任务栈内存。
    1. 动态创建任务,使用简单,不需要操作任务内存分配的问题。
    2. 任务的空间分配在FreeRTOS内核堆上,依赖FreeRTOS内核堆
    3. 绝大多数场景都使用该函数创建任务,我们后续课程内容也主要使用动态创建任务。
  2. 静态创建任务,xTaskCreateStatic,由用户提供任务控制块和任务栈内存,FreeRTOS 只负责调度,任务的内存空间由用户提供。
    1. 静态创建任务,使用更加繁琐,但内存空间更可控(毕竟任务的内存来源与管理都依赖程序员)。
    2. 适合对内存确定性要求非常高的系统
    3. 比较少用,偶尔用到。

这两种创建任务的方式,其核心的差异就在于:

任务的内存区域由谁提供。

具体来说:

  1. 动态创建任务,任务的内存区域交由FreeRTOS在FreeRTOS内核堆上动态创建,程序员不需要操心任务内存区域的创建问题。
  2. 静态创建任务,任务的内存区域交由程序员手动进行创建/分配/回收,也就是由程序员手动来管理每一个任务的内存区域。

搞明白了这两种方式的差异,那么它们的使用场景也就明朗了:

  1. 大多数人都是懒鬼,况且任务的内存区域只要不出现溢出,放在哪里通常都没什么关系。所以动态创建任务是最常见的选择!
  2. 如果你特别在意整个系统的内存使用确定性:
    1. 必须明确,每一个任务在哪里分配内存,具体用多少,什么时候回收等问题
    2. 此时,就必须手动来管理每一个任务的内存区域
    3. 此时,就必须使用静态创建任务的方式。

总得来说,如果满足以下条件,更推荐使用静态创建任务:

  1. 项目周期比较长,稳定性特别重要,优先考虑静态创建任务。如果是一个小项目,本身不过分在意稳定性,不太需要静态创建任务。
  2. 系统中的任务都已经提前设计好了,不会临时改变任务。在这样确定的系统中,可以使用静态创建任务。
  3. 系统中的任务不太复杂,只有少数几个任务,在方便手动管理的情况下,考虑使用静态创建任务。

当然,最后还有一个思考题:

静态创建任务需要程序员手动创建任务的内存区域,那这个任务内存区域在哪里创建呢?

FreeRTOS内核堆是动态创建方式使用的内存区域,静态创建不能使用。

C语言库堆是malloc等函数使用的区域,但我们不建议在STM32开发中使用这些函数,所以C库堆也不行。

主栈区域实际上也是不可用的,这一点我们下面会专门讲解。

那用哪里的内存区域充当任务内存区域呢?

很简单,自己创建一个静态内存区域.bss,然后把任务内存分配到自己创建的静态内存区域.bss即可。

关于静态创建任务,下面我们再详谈。

重要的规律:FreeRTOS官方函数名命名规则

xTaskCreate和xTaskCreateStatic,是我们正式讲解FreeRTOS函数的前两个函数。

大家第一眼看到这两个函数名,想到的是什么?

我的第一印象中,在还没有想这两个函数有啥用之前,就想到了一个问题:

"TaskCreate"以及"Static"都是"见名知意",描述函数的功能作用。

那么"x"这个前缀是什么意思呢?

如果这个前缀是完全无意义的,那么开发者这么干就有点太carzy了。

事实上,几乎所有的FreeRTOS函数,乃至于变量名,都有这样的小写字母前缀。而不同的前缀,用以代表不同的返回值类型。

下面列举一些常见的FreeRTOS函数名前缀,及其代表的函数。

最简单的前缀形式:v

在 FreeRTOS 的命名规范体系中:

  1. 以 v 作为前缀的函数,通常表示该函数 没有返回值;
  2. 而只要 不是以 v 为前缀 的函数,就通常表示 该函数是有返回值的。

下面我们围绕这个规则,解释两个核心问题。

第一,什么样的函数会被设计成没有返回值呢?

这个问题,其实并不只是 FreeRTOS 的问题,而是一个非常典型、非常通用的工程设计问题:

在实际工程中,什么时候该设计一个 void 返回值的函数?

答案其实并不复杂,一句话就可以讲清楚:

如果一个函数的结果已经通过系统状态或副作用体现出来,

而且调用者不需要去处理结果,处理也没有意义,那么这个函数就可以被设计成void返回值类型。

换个角度理解,只要你看到一个函数的返回值类型是 void,就是告诉你:

  1. 要么这个函数压根不可能失败,只可能成功;
  2. 要么函数即使失败了,你也很容易从现象上看出来,不需要再通过返回值判断;
  3. 要么函数就算失败了,你也没什么补救手段,给你返回值也没有实际意义。

所以作为程序员,看到void函数,其实应该高兴,这意味着这个函数不需要处理返回值。

第二,举一个典型的返回值是void,以"v"作为前缀开头的函数。

其实这样的函数我们已经见过了,也用过了,那就是延时函数:

c 复制代码
void vTaskDelay( const TickType_t xTicksToDelay );

这是 FreeRTOS 中最典型的一个 v 前缀函数。

关于此函数的原理,我们后面再谈,这里我们只聊它的void返回值类型。

此函数之所以设计成没有返回值,是因为:

  1. 这个函数几乎不存在失败的可能性,在正常系统运行条件下基本只会成功;
  2. 就算没有真正延时成功,你也会立刻感知到------任务没有阻塞、代码继续往下跑,现象非常明显;
  3. 即便给它设计一个返回值,调用者也很难据此做出有意义的补救行为,给返回值也没意义。

最常见前缀名形式:x

在所有"有返回值"的 API 前缀里,出现频率最高的是 x。

在 FreeRTOS 的命名体系中,如果某个 API 的函数名前缀是 x,可以把它理解为类似数学中的"未知量 x"。

它代表的含义是:

  1. 函数一定具有返回值!
  2. 函数的返回值代表函数执行完成后的结果,也就是说:该函数通过返回值告诉程序员,函数执行完成后的结果。
  3. 但不同函数的执行结果不同,类型不同,代表的含义也大不相同:
    1. 有些函数,其返回值代表执行成功或失败。
    2. 有些函数,其返回值会返回操作完成后的对象。
    3. 有些函数,其返回值是当前的系统时间。
    4. ...
  4. 于是,就用"x"这种数学意义上的未知量,来代表这一大类函数的返回值。
  5. 当然:既然函数的返回值是结果,且是有含义、有价值的结果,那我们就最好接收并处理它。

需要注意的是:

这里的 x 并不等于某一种固定的数据类型。

它强调的是"有结果",而不是"返回某种特定类型"。

总之,看到 x 前缀的 FreeRTOS 内核函数,就要形成一种条件反射:

这个函数执行后会给出一个结果。这个结果具有语义意义,通常应当接收,并根据其含义进行处理。


下面举一个例子。

x 前缀表示:函数会返回一个结果作为返回值,并且这个结果用于指示函数执行成功与否。

在这种情况下,函数的返回值类型通常为 BaseType_t 类型。

这个BaseType_t类型的返回值,通常用于表示函数执行是否成功。

这和C语言标准库函数中的malloc,用返回值来表示分配内存是否成功,其设计思路是一样的。

BaseType_t类型以"_t"结尾,很明显,这是一个类型别名。

在FreeRTOS的设计中,BaseType_t 是与CPU架构匹配的基础整数类型。

在Cortex-M3架构CPU移植使用的FreeRTOS当中,BaseType_t其实就是有符号4字节的long类型。

相关代码,如下图所示:

portmacro.h 是 FreeRTOS 的"架构适配层核心文件",也是我们进行移植时根据架构所选择的核心文件之一。

该文件所做的重要工作之一,就是:定义与 CPU 架构强相关的基础数据类型。

通过这种"先定义类型别名,使用类型别名,再由具体移植层决定实际类型"的方式,FreeRTOS 在不同架构之间实现了统一接口。

同时也提高了代码的可读性和可移植性。

这正是 类型别名的一个典型工程用途。


以 x 为前缀的函数,例如动态创建任务的函数------xTaskCreate,它的返回值类型就是 BaseType_t。

那么问题来了:

既然返回值是一个 4 字节的有符号数long类型,且返回值用于指示函数是否执行成功。

那具体返回什么表示成功,返回什么表示失败呢?

若想了解这一点:

我们可以查阅FreeRTOS官方API手册:xTaskCreate - FreeRTOS™

如下图所示:

可以看到,返回值并不是直接给出一个"魔法数",而是通过两个宏来表示创建任务是否成功。

这又是两个宏,它们的具体值是:

这些宏定义位于 projdefs.h 文件中。

projdefs.h文件,这是一个天才命名,个人猜测应该是:project definitions

翻译过来就是说:该文件下存放的是,适用于整个FreeRTOS工程的宏定义。

事实上,这个文件下,确实定义了大量在整个FreeRTOS中,都广泛使用的宏定义。

比如创建任务函数的返回值,创建成功返回成功的宏,创建失败就返回创建失败的宏。

虽然这两个宏本质上就是1和0,但禁止直接使用魔法数,而应当使用 FreeRTOS 官方定义的语义宏。

常见前缀名形式1:ux

ux 前缀表示:该函数具有返回值,并且返回值类型为 UBaseType_t。

UBaseType_t也是一个类型别名,它具体是什么类型呢?

这太简单了,使用它就是在BaseType_t的基础上加了一个U,所以就表示无符号版本。

在 Cortex-M3 架构上移植使用FreeRTOS:

  1. BaseType_tlong
  2. UBaseType_tunsigned long

如下图所示:

也就是说:

ux 前缀函数,返回的是一个 4 字节无符号整数。

那么这个返回值有什么作用呢?

首先我们要知道:既然函数要强调自己的返回值类型是一个无符号数,这就意味着返回值不会是负数,只能是一个非负数。

那么在 FreeRTOS 中,有哪些数据天然就应该是非负数呢?

很明显,数量、优先级、剩余空间等数据一定是非负数。

举两个具体函数的例子。

例子1:

对于函数:

c 复制代码
UBaseType_t uxTaskPriorityGet( const TaskHandle_t xTask );

该函数用于获取某个任务的优先级,并将其作为返回值。

优先级一定是一个非负数。

因此,该函数采用 ux 作为命名前缀,是非常合理的。

例子2:

对于函数:

c 复制代码
UBaseType_t uxTaskGetNumberOfTasks( void );

该函数用于获取整个 FreeRTOS 系统中当前存在的任务数量。

数量也是一个典型非负数,因此,该函数的命名前缀同样采用 ux

常见前缀名形式2:pv

在 FreeRTOS 的命名规范体系中:

  1. p 表示 pointer(指针)
  2. v 表示 void

合在一起:pv 表示返回一个 void * 类型的指针。

那函数什么情况下会返回一个通用指针类型呢?

这我们是不陌生的,因为C语言标准库函数中的malloc、calloc等都返回void*类型。

也就是说:

当一个函数用于申请一块内存

并将这块内存的起始地址作为结果返回时(将申请的内存块交给外界)

就非常适合设计成返回 void * 类型。

所以,以 pv 为前缀的函数,表示其返回值是void*类型,具体来说就是,返回一块内存区域的指针。


FreeRTOS 中,最典型、也是最常见的 pv 前缀函数就是:

c 复制代码
void *pvPortMalloc( size_t xSize );

该函数用于从 FreeRTOS 内核堆中申请一块内存,并返回这块内存的起始地址(指针)。

函数的返回值不仅仅是返回内存块,还兼具指示成功失败的作用:

  1. 成功时 , 返回值是一个有效的内存地址(void *),不是NULL
  2. 失败时 ,返回值为 NULL

返回值本身,就是函数的执行结果

可以看到"pv"前缀的函数,它们的返回值设计哲学和malloc、calloc,其实是完全一致的。

不多见的两个前缀:pc和e

在 FreeRTOS 的命名规范体系中,除了 vxuxpv 这些高频前缀 之外,还存在一些出现频率较低的前缀 ,例如:pce

由于它们在实际使用中并不常见,这里我们做一个简要说明。


pc 前缀可以拆开理解为:

  1. p → pointer(指针)
  2. c → char

也就是说:pc 前缀表示:函数返回一个 char * 类型的指针。

pv 返回 void * 不同,pc 返回的是明确类型的字符指针,通常用于:

  1. 返回字符串
  2. 返回可读文本信息

看到 pc 前缀,你只需要记住一句话就够了:它返回的是一个字符指针(大概率就是一个字符串)。

最常见的pc前缀的函数就是:

c 复制代码
char *pcTaskGetName( TaskHandle_t xTaskToQuery );

该函数的作用是获取任务的任务名字符串,并且作为返回值返回。


e 前缀表示:该函数具有返回值,并且返回值类型是一个 enum(枚举类型)。

也就是说,这类函数的返回值:

  1. 不是简单的成功 / 失败
  2. 也不是数量或指针
  3. 而是多个离散状态之一

看到 e 前缀,也就是说此函数的返回值是一个枚举值。

这类函数最常见的是:

c 复制代码
eTaskState eTaskGetState( TaskHandle_t xTask)

该函数用于获取任务的状态,而任务的状态有多个,使用一个枚举类型eTaskState进行定义。

表示作用域的前缀名形式:prv

在 FreeRTOS 的命名体系里,除了我们常见的 v / x / ux / pv / pc / e 这些"返回值语义前缀"之外。

还有一类前缀,它并不是在描述返回值类型,而是在描述函数的作用域

这类前缀里最典型的就是:prv

prv 前缀即:prv = private(私有)

也就是说:

看到 prv 前缀的函数,你应该立刻想到: 这是 FreeRTOS 内核内部使用的私有函数,不是给用户调用的 API。

或者换句话说,上面讲解的所有前缀名,都表示函数是一个公共的,给用户调用的API。

prv 函数的典型特征是:

在源码中,prv前缀 往往和 static 修饰符 共同出现。

比如:

c 复制代码
static BaseType_t prvCreateIdleTasks( void );

当然这些内部使用的私有函数,是不会在头文件中存在定义的。

用户也不可能直接调用这些函数。

表示中断环境专用的函数后缀名:FromISR

在前面我们提到过:

FreeRTOS提供的API当中,绝大多数都只能在任务环境下使用,只有少数API可以在主程序和中断中使用。

在 FreeRTOS 中,有一类函数专门用于在中断服务程序(ISR)中调用,而不能在其余位置被调用。

这类函数的命名有一个非常明显的特征:

函数名后缀带有 FromISR

例如:

复制代码
xQueueSendFromISR()
xQueueReceiveFromISR()
xSemaphoreGiveFromISR()
vTaskNotifyGiveFromISR()

这里的 ISR 是:

Interrupt Service Routine(中断服务程序)

当然这些函数的具体作用,后续课程再慢慢讲解,这里你知道有这么回事就可以了。

补充:FreeRTOS官方变量名命名风格

实际上,FreeRTOS不仅在 函数命名 中大量使用前缀,在 变量命名 中同样大量使用前缀。

这种命名方式,本质上来源于一种经典的工程命名方式:匈牙利命名法(Hungarian Notation)

其核心思想是:

通过 变量名前缀,快速表达变量的类型或用途,使程序员在阅读代码时,不需要查看变量定义,就可以大致判断变量的类型。

而且,FreeRTOS的变量名前缀规则,有些还和函数名前缀类似。

以下用表格的形式,总结一下这些变量名前缀的规则:

前缀 含义 典型类型 示例
x BaseType_t 或其它表示执行结果的变量,比如某种句柄(结构体类型指针) BaseType_t xResult
ux 变量类型是无符号基础类型,Cortex-M3下通常是4字节无符号类型 UBaseType_t uxPriority
px 变量类型是一个指针类型,比如指向BaseType_t的指针或某个结构体类型的指针 struct * pxCurrentTCB
pc 变量类型是字符指针,可能是一个字符串 char * pcTaskName
pv 变量类型是通用指针类型 void * pvParameters
uc 变量类型是无符号字符 unsigned char ucQueueType
us 变量类型是无符号短整型 unsigned short usStackDepth
ul 变量类型是无符号长整型 unsigned long ulRunTimeCounter
c 变量类型是字符类型 char cRxedChar
prv 内核私有函数或变量 private prvAddTaskToReadyList

这部分内容了解即可,不是特别重要。

了解这些规律,可以让你在阅读FreeRTOS源码时,可以更快速的获悉变量的数据类型。

于是在阅读源码时,就可以节约一些时间,很多时候就不用再去翻源码查看类型了。

xTaskCreate动态创建任务

task.h 头文件的第 382 行左右,我们可以找到 xTaskCreate 函数的原型(声明):

如下所示:

c 复制代码
BaseType_t xTaskCreate( TaskFunction_t pxTaskCode,
                       const char * const pcName,
                       const configSTACK_DEPTH_TYPE uxStackDepth,
                       void *pvParameters,
                       UBaseType_t uxPriority,
                       TaskHandle_t *pxCreatedTask );

这是 FreeRTOS 中最常用、也最核心的 API 之一,用于动态创建任务。

这个函数相对比较复杂,类型千奇百怪,形参也足足有6个之多。

所以下面我们逐一进行拆解,慢慢来理解这个函数。

裁剪相关

FreeRTOS 中,几乎所有对外提供的公共 API,理论上都属于"可剪裁 API"。

某个 API 是否真正可用,最终都取决于 FreeRTOSConfig.h 中的配置。

但需要强调的是:

"可剪裁"并不等于"默认关闭"。

在 FreeRTOS 中,确实存在一部分非常常用、非常基础的 API,官方在设计时就选择了默认开启,以降低初学和工程上手的门槛。

xTaskCreate函数,就是典型的,会默认开启使用的API。

如下图所示:

当然即便这个函数是默认开启的,我们也建议大家在配置文件中显式定义宏,显式开启此函数。

你可以在FreeRTOSConfig.h头文件配置文件中,明确加入一条宏定义:

c 复制代码
// 显式配置,明确使用动态创建任务函数
#define configSUPPORT_DYNAMIC_ALLOCATION 1

所以在学习或使用 FreeRTOS 的任何一个 API 之前,首先需要关注的就是该API裁剪相关的事项。

返回值

xTaskCreate 这种以 x 字母为前缀 的 FreeRTOS 公用 API,其返回值类型统一为 BaseType_t

BaseType_t 是 FreeRTOS 中一个非常常见的基础类型。

在 Cortex-M 架构的移植实现中,它本质上就是一个有符号的 4 字节 long 类型

对于 xTaskCreate 来说,这个返回值的作用只有一个:用于指示任务的创建是否成功。

在FreeRTOS中,用两个宏分别指代创建任务的成功与失败:

  1. 任务创建成功,返回宏pdPASS,这个宏本质上就是整数值1
  2. 任务创建失败,返回宏errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY,这个宏本质上就是整数值-1

这两个宏的定义,可以在**头文件"projdefs.h"**中找到。如下图所示:

那么什么时候xTaskCreate创建任务会失败呢?

其实也很简单:

任务创建,会在FreeRTOS内核堆中,开辟专属于此任务的内存区域用于存储TCB与任务栈。

因此:

如果在创建任务时,FreeRTOS内核堆的空间不足,任务创建就会失败。

你可以手动将FreeRTOS内核堆的空间调小或者给一个任务分配超大的任务栈空间,这样就可以演示创建任务失败的场景。

对于此函数的返回值,建议的接收处理格式如下:

c 复制代码
// 函数调用的实参,可以继续阅读文档,下面会讲解
// 测试时,你可以把第三个参数改成10000,这样会导致任务内存区域会分配的非常大,远超FreeRTOS内核堆的大小
// 这样就能够测试任务创建失败的情况
BaseType_t Ret = xTaskCreate(vTask1, "Task1",
                             configMINIMAL_STACK_SIZE,  // 此参数决定任务栈的空间大小
                             NULL,
                             tskIDLE_PRIORITY + 1,
                             NULL);

if (Ret != pdPASS){
    // 创建任务失败,必须手动处理
    printf1("Task1 create failed. \n");
    // 并且在处理完成后,应该直接写一个死循环
    // 既然任务已经创建失败了,干脆直接让整个程序停留在原地,而不是继续运行。
    while (1);
}

xTaskCreate函数的返回值,可以考虑接收并处理它,避免"任务创建失败却不自知,继续运行系统"的情况出现。

但只要系统空间设计与使用合理,在明确任务肯定能创建成功的情况下,也可以选择不接收处理返回值。

第一个参数:pxTaskCode

xTaskCreate 函数的第一个参数 pxTaskCode,用于指定该任务的入口函数,也就是说该任务上CPU时,就会执行这个函数。

该参数的类型是 TaskFunction_t,本质上是一个函数指针的类型别名。

如下所示:

c 复制代码
typedef void (* TaskFunction_t)( void * arg );

也就是说:

如果你想要定义一个任务的入口函数,那就需要遵循上述函数指针描述的格式。

从声明的格式上来说,任务入口函数需要满足:

  1. 返回值类型必须是void,任务入口函数必须没有返回值
  2. 函数名随意,建议遵循"见名知意"的原则。
  3. 函数的形参列表,必须有1个形参,并且此形参的数据类型是:void*

任务入口函数的实现,也有相应的要求:

任务入口函数不允许执行完毕,所以任务函数内部应包含一个死循环。

FreeRTOS的任务函数是一个永不返回的函数。

在调用 xTaskCreate 时,创建任务成功,并不意味着会立即执行该任务函数。

而只是将该函数的地址保存到任务控制块(TCB)中。

当调度器启动,并且该任务上CPU时,处理器才会从 pxTaskCode 所指向的函数入口开始执行代码。

什么叫调度器启动呢?

其实就是一句函数调用:

c 复制代码
// 启动 FreeRTOS 调度器
vTaskStartScheduler();

这个函数的具体细节,我们等到后面再详谈。

第二个参数:pcName

pcName 是 xTaskCreate 函数的第二个参数:

  1. 它的类型是 const char * const,本质上是一个只读字符串指针。
  2. 用于给任务指定一个任务名(Task Name)。

在 FreeRTOS 中,任务名并不参与调度,不会影响任务的优先级,也不会决定任务什么时候运行、运行多久。

从内核角度看,任务名只是一个附加信息。

换句话说,pcName 的存在目的只有一个:给"人"看的,不是给调度器看的。

在语法层面上来说:

任务的名字几乎只有一个限制,那就是字符串长度的限制。

在FreeRTOS配置文件中,存在以下宏:

c 复制代码
// 没必要加长任务名的长度,实际上你把任务名起到超过10个字符长度就已经足够逆天了!
#define configMAX_TASK_NAME_LEN     ( 16 )

也就是说,任务名的字符串,最大长度是15,超过这个长度就会自动截断多余部分。

但从工程实践的角度来说,对于任务名的使用提几点建议要求:

  1. 任务名显然必须在任务整个存活期间都有效。建议直接传参字面值字符串常量作为任务名,这是最佳的选择!
  2. 两个任务允许同名,FreeRTOS并没有限制这一点,但这么做,显然是自找麻烦。任务名不要重名!
  3. 任务名建议"见名知意"的表明任务的作用,这样在调试排查时,会更加清晰明了。

除此之外,还有个小细节:

这个参数名以"pc"作为前缀,这也隐含表示了它的类型是字符指针类型。

第三个参数:uxStackDepth

uxStackDepth 是 xTaskCreate 函数的第三个参数,它的类型是 configSTACK_DEPTH_TYPE。

uxStackDepth直译过来,就是栈的深度,当然这里的栈指的是当前创建任务的任务栈,而不是主栈!

这个参数的作用非常简单,用于指定该任务栈的大小。

为了解释这个参数,我们提出以下问题:

  1. configSTACK_DEPTH_TYPE具体是什么类型?
  2. 这个参数,如何来确定任务栈的大小?

下面我们来逐一分析讲解这两个问题。

首先,我们可以找到configSTACK_DEPTH_TYPE的实际类型是:StackType_t

而那StackType_t实际上是一个无符号32位整型。

如下图所示:

总之我们的结论是:

参数uxStackDepth的类型是一个无符号32位整型,必须传参一个无符号非负数。

那这个参数的取值,就是任务栈的字节大小吗?

实际上并不是!

参见下面的源代码:

所以我们的结论是:

任务栈的字节大小 = 参数uxStackDepth * 4

也就是说,任务栈的字节大小一定是4的整数倍。

如果说的更准确具体一点:

任务栈的大小是uxStackDepth字, uxStackDepth参数决定了任务栈的大小是多少个字。

为什么有这样的设定呢?

这是因为在 STM32F103C8T6 单片机中,CPU一次性寻址操作的最小空间是一个字,也就是4字节。

所以任务栈空间大小需要确保为4字节的整数倍,这样便于CPU寻址与计算。

除此之外,FreeRTOS 的配置文件中,还提供了一个建议的任务栈最小深度,如图所示:

该宏用于给出一个"最小可运行任务"的栈深度参考值。

在创建任务、传入任务栈深度参数时,建议任务栈的深度不小于该宏定义的数值,以保证任务能够正常运行。

在实际开发中,如果一时无法准确评估任务所需的栈空间大小,也可以直接使用 configMINIMAL_STACK_SIZE 作为任务栈深度的初始值。

如果使用这个推荐最小值,那么任务栈的深度就是128,任务栈的实际大小就是512个字节。

当然,如果你的任务相对复杂,或任务内部开辟使用了较大的空间,可以酌情在此基础上增加栈的深度,以避免出现栈溢出的错误。

扩展:

当然你肯定会有疑问,如何确定某个任务具体需要多大的任务栈空间呢?

不要着急,这个我们后面会给大家讲解。

第四个参数:pvParameters

vParameters 是 xTaskCreate() 的第四个参数。

它的类型是:void *,通用指针类型。恰好这个参数的前缀名是 pv,与前面讲过的函数名前缀命名规则保持一致。

这个参数的作用也很明确:用于给任务的入口函数传参。

任务入口函数的声明格式是固定的,如下所示:

c 复制代码
void TaskFunction(void * pvParameters);

pvParameters这个形参,会原封不动地传给任务函数的参数。

通俗的说:pvParameters 就是你创建任务时,顺手塞给这个任务的一份"启动参数"。

但是客观的讲:

在主流FreeRTOS工程中,这个参数非常鸡肋,基本没啥用,几乎没有实用价值。

该参数直接传 NULL,不要用它给任务入口函数传参。

这是为什么呢?

因为主流FreeRTOS工程中,普遍在main函数创建任务。

比如下列代码:

c 复制代码
// 任务入口函数
void vTask1(void *Arg) {
    uint8_t *PNum = (uint8_t *)Arg;
    // 后续可以操作main函数内传参的Num变量
    while (1) {
        vTaskDelay(1);
    }
}

// 当前处于main函数内部
uint8_t Num = 100;
xTaskCreate(vTask1,
            "Task1",
            configMINIMAL_STACK_SIZE,
            &Num,
            tskIDLE_PRIORITY + 1,
            NULL);

这段代码看起来非常合理:

  1. main() 中定义局部变量 Num
  2. Num 的地址通过 pvParameters 传给任务
  3. 在任务入口函数中使用该参数

但实际上,这段代码是无法依照预期执行的。

问题的根源在于:主栈的使用方式发生了变化。

在 FreeRTOS 调度器启动之前:

  1. main() 使用主栈(MSP)
  2. main() 的局部变量是安全的

但在 调度器启动之后:

  1. 主栈会被 FreeRTOS 用作 中断函数调用栈
  2. 所有中断相关函数的调用,都在主栈中完成
  3. 而FreeRTOS的运行过程中,中断必然会频繁触发,主栈空间会被频繁使用和覆盖。(为什么中断会频频繁触发后面讲)

而主栈的空间本身就很有限。这意味着:

  1. main 函数的栈帧并不会受到任何保护,很容易被中断相关函数栈覆盖。
  2. 一旦main函数栈帧被侵占覆盖,那么其中的数据就完全丢失了。

因此可以得出一个非常关键的结论:

在 FreeRTOS 调度器启动后,指向 main 函数局部变量的指针,其指向的内存内容大概率已经被破坏。

所以在任务中,几乎无法可靠地访问 main 函数中局部变量的正确值。

那如果你"非要传参",非要用这第四个参数,怎么办呢?

主栈空间被侵占,但数据段并不会。

所以只要使用静态或全局变量传参即可。

比如下列代码:

c 复制代码
// 任务入口函数
void vTask1(void *Arg) {
    uint8_t *PNum = (uint8_t *)Arg;
    // 后续可以操作main函数内传参的Num变量
    while (1) {
        vTaskDelay(1);
    }
}

// 函数外部
uint8_t Num = 100;  // 使用全局变量

// 当前处于main函数内部
static uint8_t Num = 100; // 使用静态局部变量
xTaskCreate(vTask1,
            "Task1",
            configMINIMAL_STACK_SIZE,
            &Num,
            tskIDLE_PRIORITY + 1,
            NULL);

但这样做,问题又来了:这种写法显然没有任何意义。

因为:

  1. 任务入口函数
  2. 以及这些全局 / 静态变量

通常都定义在同一个源文件中(就算不在同一个文件中,全局变量也可以跨文件共享访问)。

既然如此,直接访问即可,何必传参多此一举呢?

总结如下:

  1. 在大多数 FreeRTOS 工程中,任务集中在 main() 中创建。此时在创建任务时给任务传参,没有什么实际价值。
  2. pvParameters 在实际工程中基本用不上,直接传 NULL 是最稳妥、最清晰的做法。

当然,这种传参也不是完全一无是处,一点用没有。

如果想要实现对任务的复用,对任务入口函数的复用,可以使用这种方式,给任务的入口函数传参。

但这种场景不太多见,知道即可。

第五个参数:uxPriority

uxPriority 是 xTaskCreate() 的第五个参数,它的类型是:UBaseType_t

在在 Cortex-M3 架构上移植使用FreeRTOS:

UBaseType_t的实际类型就是unsigned long。如下图所示:

这个参数的作用是什么呢?

非常简单:uxPriority 用于指定任务的优先级。

在 FreeRTOS 中,任务优先级是一个非负整数值。数值越大,优先级越高。

优先级的取值范围

在 FreeRTOS 中,任务优先级从 0 开始

其中:

  1. 0 为最低优先级
  2. 数值越大,优先级越高

那么,最高优先级是多少呢?

在 FreeRTOS 的配置文件 FreeRTOSConfig.h 中,有如下宏定义:

复制代码
#define configMAX_PRIORITIES        ( 5 )

这个宏表示:系统中可用的优先级总数量。注意,它表示的是"数量",而不是"最大数值"。

所以此时的优先级取值范围是:0 ~ 4

也就是说:

  1. 最低优先级:0
  2. 最高优先级:configMAX_PRIORITIES - 1

是否需要修改这个值?

理论上可以手动修改 configMAX_PRIORITIES

但在实际工程中:

  1. 过多的优先级会增加系统复杂度
  2. 调度逻辑会变得难以维护
  3. 通常 4~7 个优先级已经足够使用

因此,一般没有特殊需求时,不建议随意增大该数值。

优先级的作用

那么,任务优先级到底有什么作用?

核心只有一句话:

优先级决定哪个任务可以先获得 CPU 的执行权。

关于任务的优先级调度机制,我们之前已经讲解过了。

具体来说,总结如下:

  1. FreeRTOS采用优先级调度机制,当多个任务进入就绪状态时,调度器总是选择优先级最高的任务运行。
  2. 只要高优先级的任务不主动让出CPU执行权,那么高优先级的任务将会一直执行,"饿死"低优先级的任务。
  3. 如果开启了抢占式调度(默认开启),高优先级的任务进入就绪状态后,还可以抢占低优先级任务的执行。

为了更好的帮助大家理解FreeRTOS的优先级,我们举几个反例、优先级错误的理解:

第一个误解:高优先级的任务更重要。

FreeRTOS任务的优先级越高,只代表它会优先执行,甚至抢占执行。

也就是说:任务优先级越高,它就越容易优先执行,越容易立刻执行。

所以,任务的优先级高优先级,和任务的重要性、价值都没有关系,只代表该任务执行的"紧迫性",需要尽快执行罢了。

而任务的优先级越低,代表任务越不"紧迫",接受一定的延时执行。

第二个误解:高优先级任务一定先执行。

FreeRTOS任务的优先级调度,是在多个任务都进入就绪状态,同时参与CPU竞争时,高优先级任务先执行。

但如果存在下面这种情况:

  1. 任务A优先级很高,但长期处于阻塞状态,没有参与CPU竞争。
  2. 任务B优先级很低,但系统中没有任务与它同时进入就绪状态,没有任务与它竞争CPU,那么它仍然会最先执行。

第三个误解:核心的、关键的任务设置高优先级。

上面已经讲过了,任务的高优先级最主要和任务的紧迫性相关。

而系统的核心任务,通常执行时间较长,长期占用CPU。

如果将这种任务设置高优先级,会导致低优先级任务很难有机会被执行,反而会导致整体系统的不稳定。

所以,越高优先级的任务,应当越快执行完成,尽早让出CPU。

系统的核心关键任务,通常都不应当设置高优先级。

第四个误解:优先级设置的档位越多越好。

优先级的档位越多,系统越复杂,越难以直观的预判系统行为,系统的稳定性就会降低。

所以,我们应该尽量少的设计优先级档位。

那么关于任务系统优先级的设计,具体该怎么做呢?

实践中如何设计任务优先级

FreeRTOS这个实时操作系统,本身就设计用于资源受限的嵌入式系统。

在这种场景下,绝大多数 FreeRTOS 工程中,任务数量通常都不多,一般在 5 个以内,超过 10 个的情况并不常见。

任务规模本身就不大,因此优先级设计更应该追求简洁、清晰、可预测,而不是"分得很细"。

关于优先级设计,可以遵循以下几条工程化原则:

  1. 优先级档位越少越好。
    1. 优先级不是越多越精细,而是越少越稳定。
    2. 档位过多会增加系统行为的不确定性,使得任务调度的关系难以分析明白。
  2. 只有极少数"必须立即响应"的任务,才提升一个优先级等级。
    1. 被提升优先级的任务,应当是那些必须马上、立刻做出响应的任务。
    2. 高优先级的任务应当尽量少。
    3. 高优先级的任务应该尽量快速执行,执行完成后快速让出CPU。
  3. 能使用相同优先级的任务,尽量使用相同优先级。
    1. 同级任务在时间片轮转机制下公平运行,逻辑更清晰,系统行为更容易预测。
    2. 在FreeRTOS系统中,同优先级的任务,应当占据绝大部分。

这样的优先级设计方式有三个明显优势:

  1. 降低系统复杂度
  2. 提高系统稳定性
  3. 增强调度行为的可预测性

优先级设计的目标不是"区分地位"或者"区分重要性",而是"控制响应时效"。只有紧急必须立刻响应的任务,才需要设置为高优先级。

而越少的层级,越清晰的结构,往往意味着越成熟、越稳健的系统。

就像我们的生活:

绝大多数时候都是按部就班的做事情,偶尔来一个紧急的事情需要立刻处理。

绝大多数事情都是早一点晚一点无所谓,偶尔来一个事情必须立刻处理。

空闲任务和空闲任务优先级

在大多数时候,我们在创建任务时,都会使用一个宏来定义任务的优先级。

调用方式通常如下:

c 复制代码
BaseType_t ret = xTaskCreate(vTask1,
                             "Task1",
                             configMINIMAL_STACK_SIZE,
                             NULL,
                             tskIDLE_PRIORITY + 1,
                             NULL);

其中:

c 复制代码
tskIDLE_PRIORITY + 1

就是此任务的优先级。

那问题来了:

tskIDLE_PRIORITY 这个宏到底是什么意思?

为什么几乎所有创建任务的示例代码里,任务优先级,都会在它的基础上 + 1呢?

要回答这个问题,就必须先引入一个概念:空闲任务(Idle Task)。

什么是空闲任务呢?

在FreeRTOS调度器启动时,FreeRTOS内核会自动创建一个系统任务,这个系统任务就是空闲任务。

该任务不需要用户手动创建,也不允许用户手动创建。

除此之外,也不允许用户手动删除它。

只要系统进入多任务调度阶段,空闲任务就一定始终存在。

需要注意:

  1. 空闲任务不是可选的,而是必须存在的,哪怕系统中一个用户任务都不创建,也必须存在空闲任务。
  2. 空闲任务和用户任务没有本质区别,空闲任务仍然有任务栈、TCB的结构。只不过这个任务由内核自动创建,不是你手动调用API创建。
  3. 空闲任务也可以由程序员控制和操作,但相对而言,允许执行的操作更少。比如肯定不能删除,也最好不要挂起。

空闲任务的什么时候上CPU呢?

简单来说:

当系统中没有任何用户任务可以运行时,

CPU 就会去执行空闲任务。

换句话说,只要系统中还有就绪状态的用户任务可以执行,空闲任务就不会、也不应当被执行。

正如它的名字空闲一样,只要当整个系统没有其他用户任务可以上CPU时,空闲任务才**"勉为其难"**的来上CPU。

空闲任务当中执行什么逻辑呢?

空闲任务就是一个普通任务,但是它的入口函数并不是我们自定义的。

那么空闲任务执行时,究竟执行什么逻辑,做什么事情呢?

关于这一点,我们先按下不表,留到后续课程内容补充。

空闲任务的优先级是多少呢?

了解了空闲任务的作用,可以看到它实际上是系统中的"兜底任务"。

所以它的优先级必然需要设定为最低!

通常来说,空闲任务的优先级用以下宏来表示:

第六个参数:pxCreatedTask

pxCreatedTask 是 xTaskCreate() 的第六个参数。

这可能是此函数最复杂,最需要理解的一个参数了。

它的类型是:

再深入一层查看类型:

再深入一层:

所以这个pxCreatedTask参数,到底是什么类型呢?

结论如下:

  1. struct tskTaskControlBlock,本身是一个结构体类型,该结构体就是任务TCB的描述结构。任务的TCB内存区域其实就是这样的一个结构体。
  2. TaskHandle_t,本身是任务TCB结构体指针类型,的类型别名。
  3. pxCreatedTask形参,它是"任务TCB结构体指针类型别名"的指针类型。

一个直接明了的图示如下:

所以,pxCreatedTask形参的实际类型是:

c 复制代码
struct tskTaskControlBlock ** pxCreatedTask;

再说通俗点:

形参pxCreatedTask的具体类型是,指向"任务控制块(TCB)结构体对象指针"的二级指针。

参数的作用

搞清楚了 pxCreatedTask 这个参数的类型本质之后,下一个问题自然就是:

这个传参到底是用来干什么的?

为了更好地理解这个参数的作用,我们不妨先看一个非常熟悉、也非常经典的函数调用:

c 复制代码
int num;
scanf("%d", &num);

scanf 中的"指针传参"在干什么?

在调用 scanf 时:

  1. 表面上看,我们是给 scanf 传入了一个指针,也就是一个地址。
  2. 但从本质上来说,scanf 获得的是:这个指针背后所对应的一片内存区域。
  3. 最终,scanf 会把从键盘读取到的数据,直接写入这块内存区域中。
  4. scanf函数并不需要接收返回值,就可以通过指针传参获取键盘录入数据。

xTaskCreate() 中的 pxCreatedTask 参数,和 scanf 中的指针传参,在作用机制上是完全一致的。

同样是:

函数内部产生结果,通过指针,把结果写回给调用者。

我们一步一步来看。

该参数配合函数调用,推荐的格式如下:

c 复制代码
TaskHandle_t Task1Handle;   // Task1Handle建议设置为一个全局变量
BaseType_t ret = xTaskCreate(vTask1,
                             "Task1",
                             configMINIMAL_STACK_SIZE,
                             NULL,
                             tskIDLE_PRIORITY + 1,
                             &Task1Handle);

这段代码做了什么呢?

首先,TaskHandle_t类型是TCB结构体指针的类型别名。

所以变量 Task1Handle 的具体类型是:

c 复制代码
struct tskTaskControlBlock *Task1Handle;

也就是说:

Task1Handle 这个变量,本质上是一个指针变量。

是一个指向任务TCB结构体的指针。

接下来,我们再看函数调用:

c 复制代码
xTaskCreate(..., &Task1Handle);

注意这里传入的是:

c 复制代码
&Task1Handle

传参的是指针的指针,也就是传参了二级指针。

具体传参的类型是:

c 复制代码
struct tskTaskControlBlock **;

因此:

  1. Task1Handle 是 TCB 指针
  2. &Task1Handle 是 TCB 指针的指针(二级指针)

xTaskCreate() 获取到这个二级指针后,就拥有了一个能力:

可以在函数内部,修改调用者中,Task1Handle 这个指针变量的指向。

也就是说:

  1. xTaskCreate 创建了一个新的任务,在FreeRTOS内核堆上分配该任务的TCB结构体。
  2. 此时 xTaskCreate 函数内部就获取了该TCB块的指针。
  3. 然后通过二级指针,把这个地址写回给 Task1Handle

具体的源码如下所示:

所以:

在函数执行完成后,Task1Handle 就是该任务TCB结构体的指针。

参考注释当中的描述,Task1Handle就是此任务的handle。

把handle翻译成中文术语:

在函数执行完成后,Task1Handle 就是该任务的任务句柄。

任务句柄

经过上面的描述,相信你已经对"任务句柄"的概念有了大体的认知。

说白了:

任务句柄就是此任务TCB结构体的指针。

在 FreeRTOS 中,每创建一个任务,内核都会为这个任务分配一块 TCB 内存区域,并用一个指针来唯一标识它。

这个指针,就是我们平时所说的 任务句柄(TaskHandle_t)。

那任务句柄有什么作用呢?拿到了任务句柄能实现什么功能呢?

要回答这个问题,我们不妨先反过来问一句:

为什么这个东西叫"句柄"呢?

句柄是单词"handle"的术语翻译,而handle本身具有把柄、抓手的含义。

从这个词的原始含义出发,"句柄"这个命名,本身就已经在暗示它的用途。

在 FreeRTOS 中,"任务句柄"可以这样理解:

  1. FreeRTOS 在创建任务时,会把这个任务的"抓手、把柄"交给你。
  2. 你只要拿着这个"抓手、把柄",就可以对这个任务进行操作和控制。

给你任务的"句柄",就是给你一种途径和权限,让你可以操作、控制这个任务。

基于上面的理解,可以得出两个非常重要的结论:

  1. 任务句柄就是你控制、操作任务的权限凭证。
  2. 任务句柄就是FreeRTOS内核中,某个任务的唯一性标识。

那为什么不把任务句柄直接叫"TCB指针"呢?这样不是更直击本质吗?

这个问题非常关键,而且正是 FreeRTOS 命名设计的精妙之处。

如果它直接叫:"TCB指针",是很容易让人产生误解的:

  1. 既然它是一个指针,那我就可以直接解引用它。
  2. 既然能解引用,那我直接修改 TCB 里的成员,看起来也是"合理"的。
  3. 但实际上,任务 TCB 属于 FreeRTOS 内核管理的核心数据结构,不可能允许程序员随意修改。

所以直接称呼为指针是不合理的。

但程序员需要操作任务,但又不能直接操作 TCB,那怎么办呢?

FreeRTOS 给出的答案是:句柄 + API(实际上所有的操作系统都是这么干的)

FreeRTOS 的解决方式非常明确:

  1. 把 TCB 的指针交给你,但不鼓励、也不允许你直接操作它。
  2. 把这个指针包装成一个"句柄",强调它的抽象意义
  3. 程序员只能拿着这个"句柄",通过 FreeRTOS 提供的 API,间接操作任务。

在这个过程中:

  1. 任务句柄只是一个"权限凭证"
  2. 真正对 TCB 的读写和修改全部由内核自主完成。

那为什么不干脆翻译成"把柄""抓手"这种更直观的词呢?

原因也很简单:

  1. 如果使用过于生活化的词汇,反而容易让人产生误解。
  2. 技术术语本身就应该是抽象的、克制的,用来和日常语言做区分。
  3. 这种抽象命名,反而会"逼迫"学习者去理解它背后的真实含义。

从这个角度看,"句柄"这个翻译是刻意而为之的

最后,API配合任务句柄,是如何定位具体任务的呢?

FreeRTOS 提供了大量用于控制和操作任务的 API。

这些 API 在内部,都需要回答同一个问题:

我要操作的是哪一个任务?

很显然:任务句柄是某个任务的TCB指针,这个指针/地址显然是具有唯一性的。

所以,任务句柄天然就是某个任务的唯一性标识。

所有控制、操作任务的 API,都必须通过任务句柄来定位目标任务,所以这些任务都需要传参一个任务句柄。

当然,任务句柄这个唯一性标识是被FreeRTOS内核认可的,任务唯一性标识。

对于程序员而言,任务名则更加直观,程序员可以依赖任务名来区分任务。

但对于FreeRTOS内核来说,任务名就只是一个普通的字符串,没什么特殊含义。

扩展:FreeRTOS内核的句柄

在后续 FreeRTOS 的学习中,包括 Linux 这种通用操作系统的系统编程学习中,"句柄"都是一个非常常见、非常核心的概念。

广义来说:

操作系统内核提供给用户,通过 API 间接操作内核对象的一种"标识/凭证",都可以称为句柄。

句柄从实际类型来看:

  1. 可以是一个指针(最常见)
  2. 也可以是一个整型数据(比如Linux系统的文件描述符)
  3. 也可以是一个结构体对象
  4. ...

本质上,句柄就是:

用户访问内核对象的一种"受控入口"。

在FreeRTOS内核中,句柄大多都是一个原始类型的指针。

虽然你可以拿到这个原始类型的指针,但你却并不能直接访问这个原始类型。

比如,就以任务句柄为例:

表面上看:任务句柄就是一个指向 TCB 的指针。

那么问题来了:

既然是指针,那用户是不是可以直接访问 TCB 结构体成员?

答案是:不能。

原因在于:

  1. 用户代码只能看到 TCB 结构体的"声明"
  2. 但看不到 TCB 结构体的"完整定义"

这是因为:

  1. 用户代码仅能包含头文件,而头文件中仅有TCB结构体的类型声明。
  2. 真正的结构体定义在内核源码(tasks.c)内部,这是用户代码所不能直接查看的。

因此,对于用户而言,虽然拿到了一个"指向 TCB 的指针"。

但由于不知道结构体内部布局,自然也就无法直接访问和修改内部成员。

在 C 语言中,这是一种经典的封装手段,称为:

不透明指针(Opaque Pointer)设计模式。

我们都知道,C 语言本身没有:

  1. private
  2. protected
  3. class
  4. 访问控制关键字

所以如果想实现"封装",就必须通过一些设计技巧。"不透明指针"正是其中最常见的一种。

其核心思想是:

  1. 在头文件中只暴露结构体声明
  2. 在源文件中隐藏结构体完整定义
  3. 对外只提供操作该结构体的 API

这样一来:

  1. 用户只能通过 API 操作内核对象
  2. 无法直接篡改内部结构(因为具体类型定义不可见)
  3. 提高了模块的安全性和可维护性

FreeRTOS 的句柄设计,几乎全面采用这种模式,了解这一点对我们后续学习会非常有帮助。

任务句柄必须保存吗?

任务句柄的作用,是为了在 任务外部 对该任务进行控制和各种操作。

例如:

  1. 删除一个任务。
  2. 临时挂起(暂停)一个任务,以及恢复一个挂起的任务
  3. 给任务发通知(发数据)
  4. 修改任务优先级
  5. 以及各式各样的查询任务的操作。

所以:

任务句柄不是任务运行的必要条件,而是跨任务操控任务的通行证。

如果系统结构简单、任务彼此独立,完全可以不保存句柄。

如果确有需求保存任务句柄:

应当使用全局变量或静态全局变量,来保存任务句柄。

任务句柄总结

由于任务句柄非常重要,但确实比较抽象。

所以这里做一个细致全面的总结:

  1. 任务句柄本质上是任务 TCB 结构体的指针。
  2. FreeRTOS 通过任务句柄,为每个任务提供一个唯一的、被内核认可的标识。
  3. 程序员只能通过"任务句柄 + 内核 API"的方式,间接操作和控制任务,而不能直接修改任务的 TCB 内存区域。
  4. 如果系统结构较为简单明了,各任务之间相互独立,那么任务句柄并不需要被保存。
  5. 如果需要保存任务句柄以供使用,建议使用全局变量或静态全局变量来保存,用局部变量来保存句柄也没什么意义。

vTaskStartScheduler调度器开启函数

在前面讲"动态创建任务"时,我们已经多次见过 vTaskStartScheduler() 这个函数。

但当时的介绍是零散的、点到为止的

现在我们单独用一节内容,系统地把它讲清楚。

此函数的声明原型非常简单:

复制代码
void vTaskStartScheduler( void );

函数既不需要传参,也没有返回值。

那么这个函数有什么作用呢?

一句话总结:

vTaskStartScheduler函数调用后,使得嵌入式系统从"单一执行路径、裸机运行模式",进入"多任务调度"模式。

或者说,就是启动了FreeRTOS任务调度器。

在调用该函数之前:

  1. CPU 只是在顺序执行 main() 函数中的代码,整个系统只有单一执行路径。
  2. 所有通过 xTaskCreate() 创建的任务,都只是被"创建并登记",但并没有真正运行。

只有当 vTaskStartScheduler() 被调用之后:

  1. 调度器才会接管 CPU
  2. 任务才会按照调度规则,开始被调度执行。

由于课程进度的限制,我们暂时不需要将这个函数的作用完全了解。

目前我们只需要知道一个事情即可:

该函数会在FreeRTOS内核堆上创建一个优先级最低的空闲任务,作为系统的兜底任务,保证系统任意时刻总有任务可以执行。

关于此函数的调用,我们还需要注意以下细节:

第一,该函数正常情况下一定不会返回。

一旦调度器成功启动,CPU 将一直在各个任务之间切换执行,main() 后续的代码理论上,没有再执行的机会。

c 复制代码
// main函数内,上面的代码会创建任务
// 启动 FreeRTOS 调度器
vTaskStartScheduler();

// 理论上不会运行到这里

当然,"理论上不会"并不等于"绝对不会"。

如果 vTaskStartScheduler() 返回了,通常只有两种原因:

  1. FreeRTOS内核堆空间内存不足。比如空闲任务创建失败了,就会导致该函数返回,或者某个任务栈运行时爆栈。
  2. FreeRTOS 配置、移植方面存在问题,调度器启动失败。

在确保移植正确的前提下,如果该函数仍然返回,通常都是FreeRTOS内核堆空间不足导致的。

比如下列代码:

c 复制代码
int main(void) {
    // 1. 初始化调试串口
    DebugUSART1_Init();
    printf1("System Init \n");

    // 2. 启动 FreeRTOS 调度器
    vTaskStartScheduler();

    // 正常来说,不会运行到这里
    printf1("error: scheduler return. \n");
    while (1) {
    }
}

// FreeRTOSConfig.h中配合修改一个宏:
// 注意:故意将FreeRTOS内核堆的空间调成128字节,这点空间甚至一个空闲任务都创建不了
#define configTOTAL_HEAP_SIZE       ( ( size_t ) ( 128 ) )

第二,建议在系统中所有任务都创建完成后,再开启调度器。

创建任务,本质上就是在系统中"注册登记"一个任务。

因此,推荐的做法是:

  1. main() 函数中
  2. vTaskStartScheduler() 调用之前
  3. 统一创建系统中的所有任务

不建议进行一些"花里胡哨"的操作,包括但不限于:

  1. 在调度器启动函数之后创建任务(实际无效)
  2. 在任务中再创建任务
  3. 在各种中断处理中随意创建任务
  4. ......

任务应该在调度器启动之前统一创建,这样任务系统结构清晰、行为可预期。

如果在系统运行过程中,在各种位置临时、随意地创建任务,只会增加系统复杂性和理解难度,纯粹自找麻烦。

第三,调度器启动后,不要依赖主栈中的数据,尤其是main函数的局部变量。

调度器启动后,主栈(MSP)会变成系统的公共资源。

需要明确几点事实:

  1. 所有中断处理函数,强制使用 MSP,不会使用任务栈
  2. 主栈空间本身就很小,通常默认只有 1KB,使用FreeRTOS后,也不会刻意调大(也没空间给主栈了)
  3. 调度器启动后,main() 函数的使命已经完成,其栈帧不再具备存在意义

因此,在调度器启动后:

  1. main() 的栈帧会很迅速的被中断和其他的一些系统行为覆盖
  2. main() 中定义的局部变量也会随之失效

此时:

如果在任务中访问main函数中定义的局部变量(基于指针传参),几乎只能访问到随机值。

任务的线程安全问题

我们在前面的课程中讲过,FreeRTOS 中的任务兼具通用操作系统中进程线程的特点:

  1. 从内存资源分配的角度看,任务是 FreeRTOS 内核分配内存资源的基本单元
  2. 从 CPU 调度管理的角度看,任务是 FreeRTOS 内核调度 CPU 资源的基本单元

因此,在使用方式上,FreeRTOS 中的任务更接近于通用操作系统中的线程

既然任务本质上具备"并发执行"的特性,那么在多任务并发运行的过程中,也就不可避免地会出现线程安全问题。

那么,什么是任务的线程安全问题呢?

很简单:

当多个任务并发访问同一份共享资源时,程序依然能够保证执行逻辑正确、数据不被破坏。

这就叫做"保障了线程安全"。

反之,如果不能保证,就称之为"存在线程安全问题"。

需要说明的是,在上述语境中,"任务"和"线程"可以视为等价概念。

但在表述时,仍然建议统一使用"线程安全"这一术语,而不是"任务安全"。

原因很简单:"线程安全"是操作系统领域早已经约定俗成的标准说法。

光这么干讲概念,可能不够直观,下面我们通过一段实际代码来看线程安全问题是如何产生的。

线程安全问题的典型案例

参考下列代码:

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

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

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

static uint32_t Count;

// -------------------- 任务1 --------------------
void vTask1(void *arg) {
    for (uint32_t i = 0; i < 5000000; i++) {
        Count++;
    }
    printf1("Count1 = %d \n", Count);
    while (1) {
    }
}

// -------------------- 任务2 --------------------
void vTask2(void *arg) {
    for (uint32_t i = 0; i < 5000000; i++) {
        Count++;
    }
    printf1("Count2 = %d \n", Count);
    while (1) {
    }
}

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

    // 2. 创建任务1
    xTaskCreate(vTask1,
                "Task1",
                configMINIMAL_STACK_SIZE,
                NULL,
                tskIDLE_PRIORITY + 1,
                NULL);



    // 3. 创建任务2(与任务1优先级相同)
    xTaskCreate(vTask2,
                "Task2",
                configMINIMAL_STACK_SIZE,
                NULL,
                tskIDLE_PRIORITY + 1,
                NULL);

    // 4. 启动 FreeRTOS 调度器
    vTaskStartScheduler();

    // 理论上不会运行到这里
    while (1) {
    }
}

这段代码中:

  1. 创建了两个任务
  2. 两个任务优先级相同
  3. 采用时间片轮转机制并发执行
  4. 并且在任务中并发访问了同一份共享数据 ------ 全局变量 Count

每个任务都会对 Count 执行 5,000,000 次自增操作。

那么问题来了:

最终 Count 的结果,会是 10,000,000 吗?

答案是:一定不是,而且一定会小于 10,000,000。

这是为什么呢?

核心原因其实只有一句话:

Count++ 并不是一个原子操作。

啥意思?

在 C 语言层面,Count++ 看起来只是一条语句。

但从 CPU 的实际执行角度看,它至少会被拆成"读-改-写"三个步骤:

  1. 从内存中读取 Count 的当前值,加载到通用寄存器(读)
  2. CPU 在寄存器中将该值加 1(改)
  3. 将加 1 后的结果写回内存(写)

也就是说:

一次 Count++,并不是"一步完成"的,而是分为多步。
而且这些步骤也不存在**"要么都全部完成,要么一步都不做"**的约束限制。
这就是所谓的**"不是一个原子操作"**。

那么这会带来什么样的影响呢?

两个任务并发执行Count自增时,在任何一个 Count++ 的执行过程中,只要发生一次任务切换。
就可能出现如下情况:

  1. 任务1 读取了 Count 的值(例如 100)
  2. 任务1 执行了Count 加1
  3. 任务1 还没来得及将加后的结果写回内存,发生任务切换,任务2 开始执行
  4. 任务2 也读取到 Count = 100
  5. 任务2 也执行了Count 加1,将101写回内存后,任务切换
  6. 任务1 重新获取执行权后,继续将没有写回的101写入内存

最终的结果是:

  1. 两个任务都执行了一次 Count++
  2. Count 实际只增加了 1

这类"加法丢失"的情况,在多任务并发执行过程中会反复发生,是一种常态化的现象。

因此最终的 Count 结果:

  1. 一定小于 10,000,000
  2. 而且通常会小得多

这正是线程安全问题的一个典型表现。

如何保障线程安全

既然了解了线程安全的问题所在,那么如何解决这个问题呢?

线程安全问题产生的根本原因是:Count++ 不是一个原子操作。

那么一个很自然的想法就是:

如果能把 Count++ 变成一个原子操作,线程安全问题是不是就解决了?

事实上确实是这样。

所谓"原子操作",核心只有一句话:

原子操作在执行过程中,不允许被打断。

也就是说:

  1. 要么整个操作完整执行完
  2. 要么一步都不执行
  3. 中间不存在"被插一脚"的可能

如果 Count++ 能满足这一点,那么:

  1. 不论有多少个任务
  2. 不论如何调度
  3. 不论什么时候切换

都不可能再出现"加法丢失"的问题。

那具体怎么实现所谓"原子操作"呢?

这是我们后面的要讲解的内容,这里暂时先不深入讲解,下面简单介绍一下这种思想,即临界区。

临界区的概念(暂时了解)

在通用操作系统和实时操作系统中,为了解决并发访问共享资源的线程安全问题,都提供了一种叫做"临界区"的解决方案。

什么叫做临界区呢?

简单来说是这样的:

FreeRTOS中可以通过加锁、限制访问、禁止调度等方式让某一段访问共享资源的代码,在逻辑上表现为一个整体的原子操作。

也就是说:这段代码在执行过程中,不允许被其他任务打断。

从而形成一种约束关系:在同一时刻,只允许一个任务进入临界区,执行这段代码。

因此,当任务1正在执行临界区代码时:

  1. 其他任务无法同时进入该临界区
  2. 也就无法并发访问同一份共享资源
  3. 更无法对共享数据产生交叉修改

从而保证了对共享资源的并发访问是线程安全的。

我们来看一段伪代码。

c 复制代码
// -------------------- 任务1 --------------------
void vTask1(void *arg) {
    for (uint32_t i = 0; i < 5000000; i++) {
        // 进入临界区,其余任务直到临界区退出,无法执行Count++
        Count++;
        // 退出临界区,其余任务可以执行Count++
    }

    printf1("Count = %d \n", Count);
    while (1) {
    }
}

// -------------------- 任务2 --------------------
void vTask2(void *arg) {
    for (uint32_t i = 0; i < 5000000; i++) {
        // 进入临界区,其余任务直到临界区退出,无法执行Count++
        Count++;
        // 退出临界区,其余任务可以执行Count++
    }
    printf1("Count = %d \n", Count);

    while (1) {
    }
}

如此,在临界区的约束下:

  1. 无论是任务1,还是任务2
  2. 在执行 Count++
  3. 都必须完整地完成"读 → 改 → 写"的整个过程
  4. 才允许其他任务进入临界区
  5. 也就是说,无论是任务1还是任务2,都需要完成Count++这个原子操作。

也就是说:

在同一时刻,只会有一个任务在执行这段代码,这样就解决了线程安全问题。

当然,没有任何机制是没有代价的。

临界区的存在,确实可以解决并发访问共享资源所带来的线程安全问题。

但与此同时,它也会引入额外的系统开销。

如果临界区代码执行时间过长,就会降低系统的并发性,甚至影响系统的实时性。

因此,在嵌入式系统中,应尽量缩短临界区的执行时间,并谨慎使用临界区机制。

扩展:

如果你实在好奇临界区如何实现,可以临时参考下列的代码:

c 复制代码
// -------------------- 任务1 --------------------
void vTask1(void *arg) {
    for (uint32_t i = 0; i < 5000000; i++) {
        // 进入临界区
        taskENTER_CRITICAL();
        Count++;
        // 退出临界区
        taskEXIT_CRITICAL();
    }

    printf1("Count = %d \n", Count);
    while (1) {
    }
}

// -------------------- 任务2 --------------------
void vTask2(void *arg) {
    for (uint32_t i = 0; i < 5000000; i++) {
        // 进入临界区
        taskENTER_CRITICAL();
        Count++;
        // 退出临界区
        taskEXIT_CRITICAL();
    }
    printf1("Count = %d \n", Count);

    while (1) {
    }
}

关于函数taskENTER_CRITICAL的具体含义作用,我们后面课程再讲。

另一个线程安全的案例

之所以这么早给大家提出"线程安全"这个概念(毕竟解决方案现在没法讲),主要是为了解释下面一段代码:

c 复制代码
// -------------------- 任务1 --------------------
void vTask1(void *arg) {
    while (1) {
     printf1("Task1 \n");
    }
}

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

在两个任务仍然优先级一致,并发执行的前提条件下,串口能够做到轮流打印吗?

显然是不行的。

为什么呢?

因为单片机的串口外设本身就是一个共享资源。

无论是任务1,还是任务2,最终都要访问同一个 USART 外设的数据寄存器。

printf1() 这个函数,也并不是一个原子操作。

它在底层至少包含:

  1. 格式化字符串,使用了同一个共享的字节数据
  2. 逐字节发送数据
  3. 等待标志位置位
  4. ...

假设当前任务1正在发送 "Task1 \n",刚发到一半时发生了任务切换。

此时任务2获得 CPU,也开始调用 printf1("Task2 \n")

那么串口线上发送的数据,就有可能变成:

复制代码
Task1sk2

或者

复制代码
Task1 
T 
Task2 
ask1 
Task1Task2 
Tas 
Task1 
Tk2 
Task2 
ask1 
Task
Task2 
Tas1 
(顺序非常混乱)

也就是说:

两个任务对串口的访问发生了交叉。

这种现象,本质上仍然:

多个任务并发访问共享资源,且没有使用任何限制手段保护机制,所以就出现了典型的"线程安全"问题。

我们再换一种更严谨的说法。

在 FreeRTOS 中:

  1. 两个优先级相同的任务会被时间片轮转调度
  2. 每个时间片到达时,都可能发生任务切换
  3. 任务切换的时机,是不可预测的

因此,只要 printf1() 的执行时间超过一个时间片(实际上一定超过一个时间片),它就可能在执行过程中被打断。

一旦被打断,而另一个任务也调用了 printf1(),那么串口发送的比特流就会交叉乱序,从而导致输出结果乱序。

这说明:

并发执行 + 共享资源 + 无保护机制,就会产生线程安全问题。

术语解释(补充了解)

出于以下目的:

  1. 真正搞懂线程安全的概念
  2. 便于在学习和工作中进行技术交流

我们在讨论线程安全时,最少需要了解下面几个术语的含义。下面逐一做一个简要说明。

第一,并发。

并发的概念我们不陌生:

在 FreeRTOS 中,当多个任务优先级相同,并开启时间片轮转调度时,任务会轮流获得 CPU。

虽然在任意时刻,CPU 只能执行一个任务,但由于任务切换时间非常短暂,人眼会感觉多个任务"同时在执行"。

这就是并发。

并发是出现线程安全问题的前提条件。

如果系统中只有一个任务,那么就不会存在并发,也就不存在竞争。

第二,共享资源。

共享资源,是指被多个任务并发访问的数据或硬件资源。

常见的共享资源包括:

  1. 全局变量
  2. 通信接口外设(如串口、I2C、SPI 等)
  3. 文件
  4. 内存缓冲区
  5. ...

并发 + 访问共享资源,都是线程安全问题产生的必要条件。

第三,原子操作。

所谓原子操作,是指:一个操作在执行过程中不可被打断。

虽然它在底层可能由多个步骤组成,但从实际执行过程看,它要么完整执行完毕,要么完全没有执行。

如果多个任务并发执行同一个原子操作,那么就不会出现线程安全问题。

第四,竞态条件。

竞态条件是一个很不好理解的概念,至少从字面意义上看,并不直观。

所谓竞态条件,是指:

多个执行实体(任务 / 线程 / 中断)在访问共享资源时,如果最终执行结果与它们的执行顺序有关,这就叫做"产生了竞态条件"。

竞态条件(Race Condition),直译过来是"赛跑条件"。

也就是说:

多个执行实体像在赛跑一样,"谁先谁后跑完",会直接影响最终的执行结果。

当执行顺序不同,结果也不同,这就叫产生了竞态条件。

比如,多个任务同时执行这样一行代码:

c 复制代码
Count++;

这里就产生了竞态条件。

产生了竞态条件,就意味着程序的执行结果变得不可预测,因为任务的先后顺序难以预测。

这就属于线程安全问题。

或者换句话说:

产生竞态条件是原因,出现线程安全问题是结果。

只要存在竞态条件,程序的结果就依赖于执行时机,而执行时机在并发系统中是不可控的。

而如果加上临界区,将上面一行代码的操作,变为一个原子操作:

c 复制代码
// 进入临界区
taskENTER_CRITICAL();
Count++;
// 退出临界区
taskEXIT_CRITICAL();

这样就消除了竞态条件,因为无论是任务1还是任务2,谁先谁后执行,都不会影响Count最终计算的结果。

第五,临界区。

为了解决竞态条件问题,我们需要对共享资源的访问进行保护。临界区(Critical Section),就是这样一种机制。

临界区指的是:访问共享资源的一段必须保证不可被并发打断的代码区域。

在任意时刻,只允许一个任务进入临界区执行。

当某个任务进入临界区后:

  1. 其他任务无法同时访问同一份共享资源
  2. 从而消除竞态条件
  3. 确保了最终执行结果,与并发执行的先后顺序无关。

在FreeRTOS环境下,临界区是解决竞态条件,实现任务访问共享资源线程安全的最常用手段。

当然,临界区的实现方式多种多样,后续课程再详谈它。

静态创建任务xTaskCreateStatic(了解)

在上面,我们已经讲过:

静态创建任务,和动态创建任务的核心区别在于,静态创建任务需要程序员手动来分配管理任务栈和TCB的内存区域。

在大多数场景下,静态创建任务不是FreeRTOS任务创建的首选,这种创建方式大家了解一下即可。

所以静态创建任务的方式,大家了解一下就可以了。

该函数的声明/原型如下:

c 复制代码
// 函数声明位于: task.h (508行)
TaskHandle_t xTaskCreateStatic( TaskFunction_t pxTaskCode,
                               const char * const pcName,
                               const configSTACK_DEPTH_TYPE uxStackDepth,
                               void * const pvParameters,
                               UBaseType_t uxPriority,
                               StackType_t * const puxStackBuffer,
                               StaticTask_t * const pxTaskBuffer );

下面逐一介绍该函数的使用。

裁剪相关

要想完整的理解静态创建任务的方式,首先要理解FreeRTOS关于动态静态创建任务的,相关裁剪问题。

动态创建任务API是默认开启的,依赖于宏:

c 复制代码
#define configSUPPORT_DYNAMIC_ALLOCATION 1

只有这个宏的值是1,才可以使用相关动态创建任务的API函数。

静态创建任务API则是默认禁用的:

c 复制代码
// FreeRTOS.h头文件的第2806行

#ifndef configSUPPORT_STATIC_ALLOCATION
/* Defaults to 0 for backward compatibility. */
#define configSUPPORT_STATIC_ALLOCATION    0
#endif

如果想要使用此函数,需要明确在配置文件中,加上下述宏定义:

c 复制代码
#define configSUPPORT_STATIC_ALLOCATION    1

只有当该宏被定义为 1 时,静态创建任务相关 API 才会被编译进系统。

能否同时使用两种创建任务的方式呢?

能不能在FreeRTOS中既使用动态创建任务,也使用静态创建任务呢?

答案是:可以。

前提是,在 FreeRTOSConfig.h 中同时开启这两个宏:

c 复制代码
#define configSUPPORT_STATIC_ALLOCATION     1
#define configSUPPORT_DYNAMIC_ALLOCATION    1

当这两个宏都为 1 时:

  1. xTaskCreate() 可以使用(动态创建)
  2. xTaskCreateStatic() 可以使用(静态创建)

两种方式彼此独立,互不影响。

那么什么时候考虑混用两种任务的创建方式呢?

动态创建任务:

  1. 由FreeRTOS自动分配和管理任务内存
  2. 优点是使用简单,代码整洁
  3. 但缺点是内存行为不完全可预测,内存使用相对不可控。

静态创建任务:

  1. 由程序员分配管理任务内存,FreeRTOS只负责任务调度
  2. 内存使用更可控,内存行为完全可预测
  3. 但缺点是使用起来更复杂,需要手动管理内存

实际工程中,如果混用两种创建任务的方式,通常:

  1. 核心任务(必须稳定运行的任务)使用静态创建
  2. 临时任务或功能性任务使用动态创建

这样可以在保证关键模块稳定性的同时,提高开发效率。

静态创建任务宏的第二层含义(重点)

关于宏:

c 复制代码
#define configSUPPORT_STATIC_ALLOCATION     1

它的第一层含义就是:开启静态创建任务相关API。

但是,这只是表层含义。

它的第二层含义是,只要开启了这个宏:

FreeRTOS系统中的空闲任务,以及静态方式手动创建的任务,都必须由程序员手动分配和管理任务内存区域。

也就是说:

开启 configSUPPORT_STATIC_ALLOCATION = 1,不仅仅是允许你静态任务的方式。

而是整个FreeRTOS内核运行,都完全可以不依赖FreeRTOS内核堆,完全可以在"无堆模式"下运行。

允许 FreeRTOS 内核在完全静态内存模式下运行。

尤其需要注意的是:

只要开启静态创建任务宏,空闲任务的任务空间都必须由程序员手动分配管理,即便允许动态创建任务。

FreeRTOS任务的几种创建方式组合

上面我们已经知道,FreeRTOS 允许同时开启动态和静态创建任务方式:

c 复制代码
#define configSUPPORT_STATIC_ALLOCATION     1
#define configSUPPORT_DYNAMIC_ALLOCATION    1

这意味着:

  1. 可以使用 xTaskCreate() 动态创建任务
  2. 可以使用 xTaskCreateStatic() 静态创建任务
  3. 静态创建任务的任务栈和 TCB 空间必须由程序员手动提供。
  4. 空闲任务的任务栈和 TCB 空间也必须由程序员手动提供。

对上面两个宏进行排列组合,FreeRTOS还支持下面几种任务创建方式组合。

第一,仅使用动态创建任务。

这种方式是FreeRTOS创建任务的默认行为。

c 复制代码
#define configSUPPORT_STATIC_ALLOCATION     0
#define configSUPPORT_DYNAMIC_ALLOCATION    1

上述宏配置完全可以不写进配置文件,默认就使用上述宏配置。

所有任务的内存空间都由FreeRTOS内核自动分配和管理,程序员不需要操心。

第二,仅使用静态创建任务。

即使用下面的配置:

c 复制代码
#define configSUPPORT_STATIC_ALLOCATION     1
#define configSUPPORT_DYNAMIC_ALLOCATION    0

这意味着:

  1. 手动创建任务,只能使用 xTaskCreateStatic() 静态创建任务
  2. 所有任务(包含空闲任务)的内存区域都由程序员手动分配和管理,FreeRTOS只负责任务调度。

这种方式使用比较少见,仅见于需要完全控制任务内存空间的场景,比如对内存可预测性要求极高的系统。

第三,全部关闭。

即使用下面的配置:

c 复制代码
#define configSUPPORT_STATIC_ALLOCATION     0
#define configSUPPORT_DYNAMIC_ALLOCATION    0

这种配置从语法上来说就是不可行,因为FreeRTOS系统中至少也有一个空闲任务需要创建。

如果两种任务创建方式都被禁止,那么空闲任务就无法创建了。

况且,不创建任务,要FreeRTOS系统来做什么呢?

所以这种配置方式是非法的。

前面几个参数

此函数的声明如下:

c 复制代码
// 函数声明位于: task.h (508行)
TaskHandle_t xTaskCreateStatic( TaskFunction_t pxTaskCode,
                               const char * const pcName,
                               const configSTACK_DEPTH_TYPE uxStackDepth,
                               void * const pvParameters,
                               UBaseType_t uxPriority,
                               StackType_t * const puxStackBuffer,
                               StaticTask_t * const pxTaskBuffer );

此函数的前面几个参数:

  1. TaskFunction_t pxTaskCode,任务的入口函数。
  2. const char * const pcName,任务的任务名(用于调试/可视化观察)。
  3. const configSTACK_DEPTH_TYPE uxStackDepth,任务栈的深度(重点)。
    1. 确定最终任务栈的大小为:任务栈深度 * 4 字节
    2. 任务栈的最低深度是:configMINIMAL_STACK_SIZE,这个宏的大小可以配置,通常是128
    3. 也就是说,FreeRTOS推荐的任务栈最小深度是128,最小大小是512字节。
  4. void * const pvParameters,用于传参给入口函数。几乎无意义,不会这种方式给任务入口函数传参,通常直接写NULL。
  5. UBaseType_t uxPriority,任务的优先级。
    1. FreeRTOS采用任务抢占式调度,优先级高的任务只要不主动放弃CPU,会一直执行下去。
    2. 空闲任务的优先级是tskIDLE_PRIORITY,这个宏的大小可以配置,通常就是0
    3. 设置任务优先级时,建议基于 tskIDLE_PRIORITY + n 的方式写,表达更清晰。

因此,xTaskCreateStatic() 的前 5 个参数,和 xTaskCreate()(动态创建任务)在语义上是一样的。

真正的区别在于后面两个参数:

  1. puxStackBuffer:任务栈内存由用户提供
  2. pxTaskBuffer:TCB 内存由用户提供

后面我们重点讲这两项。

第六个参数:puxStackBuffer

函数原型中第六个参数为:

c 复制代码
StackType_t * const puxStackBuffer

这个参数的含义是:

任务栈的内存空间,由程序员手动提供。

在动态创建任务时:

c 复制代码
xTaskCreate()

任务栈由 FreeRTOS 从 heap 中自动分配。

而在静态创建任务时:

c 复制代码
xTaskCreateStatic()

FreeRTOS 不再动态分配任务栈。

而是要求:

由程序员提前准备好一块连续内存区域,作为任务栈。

这块内存空间的首地址,就是参数 puxStackBuffer

c 复制代码
StackType_t`的实际类型在STM32F1系列上是:`uint32_t

所以我们只需要创建一个此类型的数组,然后再把数组名传参即可。

这里有两个小问题:

  1. 这个数组应该是一个什么变量?
  2. 这个数组的长度可以随便写吗?

下面来剖析这两个问题。

任务栈数组应该是什么变量

既然任务栈是由程序员提供,那么这块内存的生命周期就必须满足一个前提条件:

任务存在多久,这块内存就必须有效多久,甚至更长。

任务栈内存空间的生命周期 >= 任务本身的生命周期

任务是运行在整个系统生命周期中的。

而创建任务的函数,往往只是执行一次。

因此,如果我们写成下面这样:

c 复制代码
// main函数
StackType_t xTaskStack[128];  // 局部变量
xTaskCreateStatic(..., xTaskStack, ...);

这是错误的。

原因很简单:

  1. xTaskStack 是main函数中的局部变量
  2. 调度器启动后,main函数所处的主栈空间会被侵占,其中存储的数据就无法保证正确了。
  3. 主栈空间的大小十分有限,通常只有1024个字节,用来存储任务栈空间实在过于勉强。

既然主栈空间不可用,FreeRTOS内核堆也不可用,那么:

任务栈数组必须定义为"静态存储区变量"。

可以使用:

  1. 全局变量或静态全局变量,优先推荐使用静态全局变量。
  2. 静态局部变量,可以使用,但它的作用域仅限于函数内部,不利于后续调试与维护,不推荐使用。

任务栈数组的长度

任务栈数组的长度显然是不能随便写的。

需要满足两个限制条件:

第一,必须等于 uxStackDepth,也就是任务栈数组的长度与任务栈的深度参数必须保持一致。

任务栈的深度,类型是 StackType_t,任务栈数组的类型也是它,所以只需要让它们保持一致即可。

比如都使用宏configMINIMAL_STACK_SIZE,即默认的任务栈最小深度128。

第二,必须满足任务的实际需求。

任务栈的大小肯定不是越小越好,也不是越大越好,而是要满足实际任务的需求情况。

实际设置任务栈的大小时,建议先设置一个基础值,然后再根据使用情况,进行扩展。

思考一个小细节:

任务栈的实际大小,大于uxStackDepth参数决定的任务栈大小,可以吗?

比如:uxStackDepth传参128,那么任务栈的大小就是512字节。

实际创建一个1024字节大小的数组,然后传参,可以吗?

当然可以。

但FreeRTOS 只会使用你传入的 uxStackDepth 那么多的空间作为任务栈。

多余的空间无意义,FreeRTOS不会使用。

所以不要这么做,任务栈数组的实际大小,就应该等于uxStackDepth确定的任务栈大小。

推荐的调用方式

总之,建议采用下面的方式来调用函数,进行传参:

c 复制代码
static StackType_t xTaskStack[configMINIMAL_STACK_SIZE];    // 静态全局变量
// main函数内部创建任务
xTaskCreateStatic(..., xTaskStack, ...);

下面再看一下最后一个参数。

第七个参数:pxTaskBuffer

函数原型中第七个参数为:

c 复制代码
StaticTask_t * const pxTaskBuffer

这个参数的含义是:

任务控制块(TCB)的内存空间,由程序员手动提供。

关于TCB的概念,我们已经了解过了。

简单来说,TCB 是 FreeRTOS 内核用来"管理任务"的核心数据结构,本质上就是一个结构体对象。

这个结构体当中会存储任务的关键信息,例如:

  1. 任务的任务名
  2. 任务的优先级
  3. 任务的栈基指针
  4. 任务的栈顶指针
  5. ...

在动态创建任务时:

c 复制代码
xTaskCreate()

FreeRTOS 会:

  1. 从 FreeRTOS内核堆上 分配一块内存作为 TCB
  2. 从 FreeRTOS内核堆上 分配一块内存作为任务栈

程序员无需关心这些内存的地址与生命周期。

而在静态创建任务时:

c 复制代码
xTaskCreateStatic()

FreeRTOS 不再自动分配 TCB和任务栈。

而是要求:

由程序员提前准备一块 StaticTask_t 类型的内存,用于存放任务的 TCB。

这块内存对象的地址,就是 pxTaskBuffer

实际上就是:

让你创建一个 StaticTask_t 类型的全局结构体对象,然后把它的指针传进去即可。

所以此参数的推荐调用方式如下:

c 复制代码
static StaticTask_t xTaskTCB;   // 静态全局变量
// main函数内部创建任务
xTaskCreateStatic(..., &xTaskTCB);

有了第六个参数(任务栈)的铺垫,想到这种写法其实并不难。

不过这里仍然会有两个小问题:

  1. 在动态创建任务时,TCB 的结构体类型似乎和 StaticTask_t 不一样,这是怎么回事?
  2. pxTaskBuffer 这个参数到底是什么?它是不是任务句柄?

下面来逐一进行讲解。

两种创建任务方式TCB结构体类型对比

FreeRTOS 内部真正使用的TCB类型,其实是:tskTCBTCB_t等。

这个类型是定义在tasks.c源码内部的一个私有的结构体类型。

TCB中的数据直接关系到FreeRTOS内核任务调度的稳定性,所以不可能直接暴露给外界查看和使用。

同样的:

在静态创建任务时,此函数也不能真正的拿到TCB的真实类型信息。

但是静态创建任务,又要求程序员提供TCB的内存区域,需要创建TCB结构体对象,那怎么办呢?

FreeRTOS提供的解决办法是:

  1. 设计一个StaticTask_t类型,静态 TCB 容器类型。
  2. 该类型的内存使用大小,内存使用布局,都与真实的TCB结构体类型,完全保持一致!

所以:

  1. FreeRTOS内核内部:用 TCB_t 来创建TCB结构体对象,来进行任务管理。
  2. 用户侧静态创建任务时:用 StaticTask_t 提供TCB内存块的模具、壳子、容器。

举一个更通俗的例子是:

你想在网上买鞋,不可能把脚寄过去,于是你使用"40、41..."这样的尺码来买鞋子。

这里的"尺码标准",就类似于 StaticTask_t 类型。

而真正穿在脚上的鞋子,就相当于内核内部真正使用的 TCB 数据结构。

你不需要知道鞋子的内部结构:

  1. 鞋底几层
  2. 缝线怎么做
  3. 鞋垫材质是什么
  4. ...

你只需要按照"标准尺码"购买即可。

这个参数是任务句柄吗?

关于这个问题的答案,我个人觉得答案应该是:是也不是。

先来说是:

pxTaskBuffer参数是一个指针,用于告诉FreeRTOS内核,在此指针指向的内存空间上创建管理任务的TCB结构体。

而任务句柄,在当前FreeRTOS系统中,本质上就是TCB结构体对象的指针。

所以从存储的地址上来说,pxTaskBuffer参数和此任务的任务句柄,是完全一致的。

那为什么又说不是呢?

这是因为二者的语义、类型以及设计思路都完全不同。

首先从语义上来说:

  1. pxTaskBuffer 是你提供给内核的一块"原始内存空间",它的作用是:作为 TCB 的存储位置。
  2. TaskHandle_t 是内核返回给你的"任务标识符",它的作用是:用于后续对任务进行操作。

二者的语义作用是完全不同的。

其次是类型不同:

  1. pxTaskBuffer 是一个指向 StaticTask_t 结构体类型的指针。
  2. 任务句柄的类型是TaskHandle_t

二者虽然大小和内存布局是一致的,但类型确实不一致。

最后再谈设计思路的不同:

任务句柄是某个任务唯一性标识的抽象类型,它并不一定就是TCB类型。

FreeRTOS 并不保证:TaskHandle_t 永远等于 TCB 的地址/指针。

只是当前实现"恰好如此"。

内核完全可以在未来改成:

  1. 句柄内部包含额外字段
  2. 句柄不再直接等于 TCB 指针
  3. 甚至通过句柄查表定位 TCB

而你提供的 pxTaskBuffer 仍然只是那块内存空间。

所以,从根本上来说,pxTaskBuffer参数不应该被视为任务句柄。

它们一个是"任务唯一性标识"的抽象概念,一个是具体TCB块的实现内存空间。

返回值

此函数的返回值类型是:

c 复制代码
TaskHandle_t

这就非常明显了:

  1. 此函数创建任务成功时,返回任务句柄
  2. 失败时返回 NULL

任务句柄是任务的唯一性标识,配合FreeRTOS提供的任务操作API,可以实现对任务的各种操作与控制。

那么什么时候会导致静态创建任务失败呢?

静态创建任务几乎不会因为"内存不足"而失败,因为:

  1. TCB 是你自己提供的
  2. 栈空间是你自己提供的

如果你故意提供错误的参数,例如:

  1. pxTaskCode = NULL
  2. puxStackBuffer = NULL
  3. pxTaskBuffer = NULL
  4. uxStackDepth = 0
  5. ...

则静态创建任务可能会失败。

静态创建任务的返回值可以酌情决定是否接收处理。

静态创建任务的推荐方式

对于静态创建任务,推荐采用下面的标准调用方式:

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

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

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

// -------------------- 任务1 --------------------
void vTask1(void *arg) {
    while (1) {
        printf1("Task1 \n");
        vTaskDelay(1000);
    }
}

// -------------------- 任务2 --------------------
void vTask2(void *arg) {
    while (1) {
        printf1("Task2 \n");
        vTaskDelay(1000);
    }
}

// 任务1的任务栈、TCB以及任务句柄
static StackType_t xTask1_Stack[configMINIMAL_STACK_SIZE];
static StaticTask_t xTask1_TCB;
static TaskHandle_t xTask1_Handle;

// 任务2的任务栈、TCB以及任务句柄
static StackType_t xTask2_Stack[configMINIMAL_STACK_SIZE];
static StaticTask_t xTask2_TCB;
static TaskHandle_t xTask2_Handle;

int main(void) {
    // 初始化调试串口
    DebugUSART1_Init();
    //printf1("Usart1 init completed. \n");
    // 静态创建任务1
    xTask1_Handle = xTaskCreateStatic(
        vTask1,
        "Task1",
        configMINIMAL_STACK_SIZE,
        NULL,
        tskIDLE_PRIORITY + 1,
        xTask1_Stack,
        &xTask1_TCB);

    if (xTask1_Handle == NULL) {
        printf1("Task1 create failed. \n");

        while (1);
    }

    // 静态创建任务2
    xTask2_Handle = xTaskCreateStatic(
        vTask2,
        "Task2",
        configMINIMAL_STACK_SIZE,
        NULL,
        tskIDLE_PRIORITY + 1,
        xTask2_Stack,
        &xTask2_TCB);

    if (xTask2_Handle == NULL) {
        printf1("Task2 create failed. \n");

        while (1);
    }


    // 启动 FreeRTOS 调度器
    vTaskStartScheduler();

    // 理论上不会运行到这里
    while (1) {
    }
}

这段代码写完后,我们直接编译代码,能够成功吗?

当然是不行的。

只要开启了静态创建任务,配置了下列宏:

c 复制代码
#define configSUPPORT_STATIC_ALLOCATION     1

那么空闲任务的任务栈和TCB内存空间也需要程序员来手动提供。

而上述代码,显然没有做任何与此相关的操作。

直接编译这行代码会出现一个链接错误,怎么解决呢?

为空闲任务提供内存空间

这个链接错误的错误信息是:

linking... .\Objects\xTaskCreate_Test.axf: Error: L6218E: Undefined symbol vApplicationGetIdleTaskMemory (referred from tasks.o). Not enough information to list image symbols. Not enough information to list load addresses in the image map. Finished: 2 information, 0 warning and 1 error messages. ".\Objects\xTaskCreate_Test.axf" - 1 Error(s), 0 Warning(s).

重点看到这一行:

Undefined symbol vApplicationGetIdleTaskMemory (referred from tasks.o).

基本就可以直接下结论:

FreeRTOS 内核要求你提供 Idle Task(空闲任务)的静态内存,但你没有提供。

FreeRTOS内核规定:

允许静态分配任务时,用户必须实现 vApplicationGetIdleTaskMemory()函数,把空闲任务的 TCB 和栈空间提供给内核。

实际上这就是通过vTaskStartScheduler的一段源码实现的。

如下所示:

这个函数的声明原型,就写在对应的头文件中:

在使用静态创建任务时,链接器会在链接阶段自动查找该函数的实现。链接器如果找不到这个函数的实现,自然就会抛出一个链接错误。

控制反转思想(了解)

这个函数的设计,体现的是一种**"控制反转(Inversion of Control, IoC)"**的思想。

所谓控制反转,就是:

你只负责提供具体逻辑的实现,

至于这个逻辑何时执行,由系统框架来决定。

这种思想我们已经见过很多次了,下面举一些例子。

在qsort函数中:

你只需要提供一个比较函数,用来定义排序规则。

至于:

  1. 比较函数的调用时机
  2. 比较函数的调用次数

都由 qsort 内部算法决定。

这里的控制反转是通过"函数指针参数传递"实现的:

  1. 用户在调用 qsort 时,将比较函数地址作为参数传入;
  2. qsort 在排序过程中,通过该函数指针调用比较函数;
  3. 从而实现"算法框架固定、比较规则可变"的灵活排序功能。

qsort函数的设计,是一种典型的函数回调的应用,教科书般的函数回调。

函数回调是实现控制反转思想最简单直接的一种方式。。

在任务创建中:

你提供任务入口函数。

但:

  1. 任务何时获得 CPU
  2. 任务何时开始执行
  3. 任务何时被切换

都由 FreeRTOS 调度器决定。

这里的控制反转同样基于函数地址:

  1. 任务函数地址在创建任务时保存到 TCB;
  2. 调度器在首次调度任务时,根据 TCB 中保存的函数地址跳转执行。

不同之处在于:

函数地址不是通过参数反复传递,而是存入任务TCB中。

任务入口函数的调用,某种程度上来说,也可以视为一种函数回调的机制,只不过没那么纯粹。

毕竟任务入口函数一旦调用,除非切换任务,否则会一直执行(任务入口函数不会结束)。

在中断机制中:

从底层机制来看,中断服务程序的执行,本质上仍然是一种"函数地址跳转"。

  1. 中断服务函数地址在编译/链接阶段写入中断向量表;
  2. 中断向量表烧录后存储在 Flash闪存 中;
  3. 当硬件中断触发时,CPU 根据中断号索引向量表;
  4. 然后跳转到对应函数执行。

这里的函数地址:不是运行时传递,不是存入SRAM内存结构中。

而是:

在系统启动前就已经写死在 Flash 中。

中断函数的调用,如果谈本质的话,也可以视为一种函数回调,只不过存在硬件(NVIC)参与控制,也不是传统意义上的回调。

qsort、任务调度、中断机制,三者虽然表现形式不同,但本质一致:

都是通过函数地址跳转执行用户代码。

区别仅在于,函数地址来源不同

  1. 参数传入
  2. 数据结构保存
  3. 向量表索引

相比之下,vApplicationGetIdleTaskMemory 的控制反转实现更加特殊。

它并没有通过函数指针参数来实现控制反转。

而是:

  1. FreeRTOS内核在固定位置声明该函数;
  2. 用户必须提供该函数实现;
  3. 链接阶段由链接器完成对应函数实现的链接,也就是绑定符号;
  4. FreeRTOS内核运行时直接调用该函数。

这种机制称为:钩子或钩子函数(Hook)

它的特点是:

  1. 函数名以及函数声明固定
  2. 实现由用户提供
  3. 链接阶段进行符号绑定
  4. 具体的调用时机由系统框架来决定。

为什么要学习控制反转的思想(了解)

通过上述几个例子我们可以看到:

无论是 qsort、任务调度、中断机制,还是 Hook,本质上都体现了一种共同的设计思想:

框架掌控流程,用户提供逻辑。

在传统顺序程序中:

  1. 程序员掌控执行流程;
  2. 每一个函数何时执行,由 main 函数明确控制。

而在控制反转模型中:

  1. 执行流程由系统或框架掌控;
  2. 程序员只需要提供某些"被调用的逻辑单元"。

至于:

  1. 何时执行
  2. 执行多少次
  3. 是否被打断
  4. 是否再次执行

都由框架或硬件决定。

如果从更抽象的层面来看:

控制反转的核心,并不在于"函数指针"这个技术细节,而在于:

控制权的转移。

具体的实现手段可以有很多种:

  1. 函数指针参数传递(qsort)
  2. 数据结构保存函数地址(TCB 中的任务入口函数)
  3. 向量表索引函数地址(中断机制)
  4. 固定函数声明 + 链接期绑定(钩子,Hook)

它们在技术实现上不同,但在设计思想上是完全统一的。

因此,我们可以得出一个更高层的结论:

控制反转是一种架构思想

回调、Hook、任务入口函数、中断服务函数,都是这种思想在不同层次上的具体体现。

从应用层到内核层,从软件框架到硬件机制,控制反转思想贯穿始终。

如果再往上升一个层次去看:

操作系统本身,就是一个巨大的控制反转框架。

  1. 用户程序并不控制 CPU;
  2. 用户程序并不决定自己何时执行;
  3. 中断并不由用户程序主动调用;
  4. 系统资源的分配也不由用户程序直接掌控。

一切都在"系统框架"的调度之下运行。

而我们写的代码,只是嵌入在这个框架中的一部分逻辑。

总之:

当系统规模变大,单纯依赖顺序控制无法构建复杂系统时,就必须引入控制反转,让框架接管流程,而程序员只专注于实现具体逻辑。

举几个大家现在能理解的例子,这段内容大家看个乐子即可:

案例一:裸机 while(1) 与 FreeRTOS 的对比

我们先看最原始的裸机程序:

c 复制代码
int main(void){
    init_uart();
    init_led();

    while (1){
        if (uart_rx_flag){
            handle_uart();
        }

        if (led_timeout){
            toggle_led();
        }
    }
}

在这个模型里:

  1. 你控制主循环
  2. 你决定检查顺序
  3. 你决定执行频率
  4. 你决定任务优先级(靠代码顺序)

这是典型的"顺序控制模型"。

现在换成 FreeRTOS:

c 复制代码
void vUartTask(void *arg){
    while (1){
        handle_uart();
        vTaskDelay(10);
    }
}

void vLedTask(void *arg){
    while (1){
        toggle_led();
        vTaskDelay(10);
    }
}

此时发生了什么?

  1. 你不再写主循环调度逻辑
  2. 你不再控制任务顺序
  3. 你不再控制切换时机
  4. 你不再控制优先级抢占过程

调度器接管了执行流程。

你只写"任务逻辑"。

这就是控制反转在嵌入式中的直接体现。

第二个例子,利用中断来接收串口数据。

如果你直接用轮询的方式:

c 复制代码
while (USART_GetFlagStatus(USART1, USART_FLAG_RXNE) == RESET);
data = USART_ReceiveData(USART1);

你在控制CPU,让它等待阻塞式的接收串口数据。

控制权在你。

但如果你用中断:

c 复制代码
void USART1_IRQHandler(void){
    // 省略多余逻辑
    if (USART_GetITStatus(USART1, USART_IT_RXNE) == SET){
        data = USART_ReceiveData(USART1);
    }
}

现在:

  1. 你不再等待阻塞接收数据
  2. 你不再决定什么时候接收数据,你没有这个控制权。
  3. 由硬件触发中断机制,来决定何时进行数据接收。

你只是提供"数据到来时要做什么"。

这是硬件层面的控制反转。

如果你能坚持看到这里,而且也能看懂的话,控制反转的思想将对你后续的学习进阶之路非常有帮助。

我希望大家能够记住几件事情:

第一,控制反转不是一定要使用的。顺序执行也不是万恶之源。

如果你只是简单点个灯,也没有必要将控制权交给其他框架,直接顺序执行就可以了。

只有复杂的系统,存在并发或异步执行的需求时,才需要用到控制反转。

第二,面对大型框架,复杂代码时:

如果需要手写逻辑让系统框架调用,不妨多思考"它在哪个生命周期阶段调用我",而不是总是思考"它为什么要调用我"。

这是一个认知上的提升,比如我们后面学习Qt框架,一定需要掌握这种认知,才能够真正学明白。

第三,所谓控制反转,是从"程序控制机器"走向"系统调度程序"的过程。

借助内核函数提供空闲任务的空间(了解)

说了一大堆理论,这一小节来具体实践一下如何提供空闲任务的内存空间。

首先我们先讲一种,几乎很少见,但确实存在的方式------借助FreeRTOS内核函数为空闲任务提供任务空间。

如下图所示

为了开启这个内核函数,我们需要在配置文件中添加以下宏定义:

c 复制代码
// 以下三个配置宏开启后,空闲任务空间的创建由FreeRTOS内核函数提供
#define configKERNEL_PROVIDED_STATIC_MEMORY 1
#define portUSING_MPU_WRAPPERS 0
#define configTIMER_TASK_STACK_DEPTH 128

如此配置后,我们的main.c文件中的代码不需要任何改变,就可以直接完成静态创建任务,并启动FreeRTOS。

此时空闲任务采用以下配置:

  1. 默认的最小任务栈大小,即512字节。
  2. 任务栈和TCB的内存区域都分配为静态局部变量。

这种方式并不推荐使用,因为:

  1. 不可以自主控制空闲任务的任务栈的大小(通常尽量不改FreeRTOS内核源码)
  2. 静态局部变量在函数外部无法获取访问,不利于调试维护代码。

下面,我们讲一下更推荐的方式,即手动为空闲任务分配任务栈和TCB内存空间。

手动为空闲任务分配任务空间

将配置文件中的上述三行宏定义注释掉,我们采用手动为空闲任务分配任务空间的方式。

首先,我们从头文件中把函数的声明抄过来:

如下所示:

c 复制代码
void vApplicationGetIdleTaskMemory( StaticTask_t ** ppxIdleTaskTCBBuffer,
                                   StackType_t ** ppxIdleTaskStackBuffer,
                                   configSTACK_DEPTH_TYPE * puxIdleTaskStackSize );

可以看到此函数有三个参数,分别是:

  1. TCB内存空间的二级指针。
  2. 任务栈内存空间的二级指针。
  3. 任务栈深度的一级指针。

我们已经学习使用C语言超过一个月了,这个函数的实现方式,对你来说应当非常简单了:

  1. 传参TCB内存空间的二级指针:
    1. 目的是在函数内部修改对应一级指针的指向,也就是TCB指针的指向
    2. 指向谁呢?
    3. 当然是指向我们自定义创建的TCB内存块。
  2. 传参任务栈内存空间的二级指针:
    1. 目的是在函数内部修改对应一级指针的指向,也就是任务栈指针的指向
    2. 指向谁呢?
    3. 当然是指向我们自定义创建的任务栈内存块。
  3. 传参任务栈深度的一级指针:
    1. 目的是在函数内部修改任务栈的深度
    2. 改为哪个数值呢?
    3. 当然是我们自定义创建的任务栈数组的长度。

一个标准的规范的调用方式如下:

c 复制代码
/* 空闲任务的 TCB 和任务栈 使用静态全局变量 */
static StaticTask_t IdleTaskTCB;
static StackType_t  IdleTaskStack[configMINIMAL_STACK_SIZE];

void vApplicationGetIdleTaskMemory(StaticTask_t **ppxIdleTaskTCBBuffer,
                                   StackType_t **ppxIdleTaskStackBuffer,
                                   uint32_t *pulIdleTaskStackSize) {
    /* 设置空闲任务 TCB 的存储位置 */
    *ppxIdleTaskTCBBuffer = &IdleTaskTCB;

    /* 设置空闲任务 任务栈 的存储位置 */
    *ppxIdleTaskStackBuffer = IdleTaskStack;

    /* 设置任务栈的深度 */
    *pulIdleTaskStackSize = configMINIMAL_STACK_SIZE;
}

相比"内核自己偷偷分配",这种方式具有明显优势:

  1. 内存在哪你看得见;
  2. 栈多大你自己决定;
  3. 更利于内存分析与调试;
  4. 不需要开启特殊的配置宏;
  5. 行为完全可预期。

一句话总结:

这是最清晰、最可控、最符合嵌入式工程思维的一种写法。

静态创建任务的内存空间回收(了解)

在 FreeRTOS 中,如果我们静态创建任务:

c 复制代码
xTaskCreateStatic( ... );

那么任务的:

  1. TCB
  2. 任务栈

都是由我们自己提供的静态内存。

问题来了:

当我们调用 vTaskDelete() 删除这个任务时,这块内存会不会被自动"释放"?

当然不会。

静态创建任务时,任务的内存空间是由程序员显式提供的,程序的静态数据区。

而静态内存的生命周期,从系统上电开始就已经存在,直到系统断电才消失(SRAM)。

因此,这种内存本身就不存在"分配"和"释放"的过程,自然也不存在所谓的"自动释放"。

那怎么销毁这段空间呢?

答案是:

不能销毁,不能释放,不能free。毕竟这段空间是静态区域,没有所谓的"释放"。

这段空间只能被复用,被其他静态任务所重新使用。

但在工程实践中,这种"删除后复用"的场景极少出现。

因为使用静态创建任务的核心设计理念通常是:系统任务数量固定,结构稳定,不进行动态扩展或删除。

也就是说:

静态任务通常在系统初始化阶段创建完成,之后长期存在,基本不会删除。

所以静态任务的空间回收,工程实践中,基本上不会去做这个事情。

总结

至此,关于任务创建的两种方式,以及其中涉及的一些基础概念,我们已经完整梳理了一遍。

总体来说,在实际工程中使用更多的仍然是动态创建任务

建议大家仔细阅读官方文档,把相关 API 与细节都好好自己梳理一番,结合任务内存布局,深入理解任务的创建。

当然,任务创建只是第一步。

创建任务,编写任务入口函数,解决的是:

系统中"需要有哪些任务",这些任务都在做什么事情。

而真正决定系统行为的,是:

当前"谁在运行"。

这就引出了接下来更核心的问题------

FreeRTOS 的任务调度机制。

在前面的章节中,我们已经从结果层面知道:

  1. 高优先级任务会抢占低优先级任务
  2. 高优先级任务会一直执行,直到主动放弃CPU
  3. 相同优先级任务会进行时间片轮转
  4. 阻塞态任务不会参与调度
  5. 进程的三种状态
  6. ...

这些是调度的"表现"。

但问题是:

FreeRTOS 内部究竟是如何实现这些行为的?

调度器是依靠什么结构来组织和管理任务的?

如果说"任务创建"是给系统添加成员,那么"任务调度"就是决定这些成员如何运转。

要真正理解调度机制,我们必须深入内核最核心的一部分------

任务列表(Task List)。

相关推荐
nashane2 小时前
HarmonyOS 6学习:保存图片预览空白?沙箱路径转URI的“视觉修复”术
学习·华为·harmonyos
IronMurphy2 小时前
AI Agent 学习day5 MCP 协议入门与实践
网络·人工智能·学习
li星野2 小时前
LLMLingua:用小型模型“剪枝”大语言模型提示词,让长文本不再昂贵
人工智能·python·学习·语言模型·剪枝
峥嵘life2 小时前
Android getprop 属性限制详解:User 版本属性获取问题分析
android·开发语言·python·学习
星夜夏空992 小时前
FreeRTOS学习(5)——内存映射
开发语言·学习
wuxinyan1232 小时前
工业级大模型学习之路031:Streamlit 高级功能多会话管理和知识库管理
python·学习·智能体
古月开发3 小时前
旧手机变身 AI 作业监督器:低成本家庭学习解决方案
人工智能·学习·智能手机
Lance_mu3 小时前
UFS协议学习大纲
嵌入式硬件·七牛云存储
小新同学^O^3 小时前
简单学习 --> SSE
学习