文章目录
- 准备工作:引入串口打印工具
- 任务创建的两种方式
- 重要的规律:FreeRTOS官方函数名命名规则
- 补充:FreeRTOS官方变量名命名风格
- xTaskCreate动态创建任务
- vTaskStartScheduler调度器开启函数
- 任务的线程安全问题
- 静态创建任务xTaskCreateStatic(了解)
- 总结
准备工作:引入串口打印工具
在正式讲解 FreeRTOS 任务的两种创建方式 之前,我们先引入一套串口打印工具。
原因其实很简单:
FreeRTOS 是一个多任务系统,一旦任务数量多了,代码就不再是"从上往下顺序执行"的形式了。
此时如果还只靠 LED 闪烁 来判断程序是否正常运行,不但信息量太少,而且调试效率极低。
因此,在后续的学习中,我们需要一种方式,能够:
- 实时输出任务的执行信息
- 观察不同任务是否被成功创建
- 判断任务是否在运行、是否发生切换
- 直观看到不同创建方式下的执行差异
最合适、也最常用的手段,就是------串口打印调试信息。
为此,这里我们提前封装了一套基于 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 与串口调试的注意事项:
- 在 printf1 的实现中,我们对字符串中的 "\n" 进行了额外处理,在实际发送到串口时,会自动转换为 "\r\n"。
- 这是因为,之前C语言标准库的printf函数也会自动根据平台处理换行符。
- 当前我们在Windows环境下使用此调试工具,所以换行符是"\r\n"。
- 如果不进行这样的处理,每次发送换行都需要明确使用"\r\n"。
- 使用串口助手给STM32发数据时,如果发送换行符:
- 建议使用串口工具,本身自带的发送换行符的功能。
- 不建议在发送框中手动输入 "\r\n" 或类似字符序列。
- 因为你不知道这个串口工具内部究竟如何处理换行符,如果它不按照你的预期发送字符,就会出问题。
- 如果多测试几次,尤其是将发送数据加长,很容易看到数据发送非常混乱。关于这一点,后续会进行解释。
带串口调试功能的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功能,所以这种创建方式我们无法使用。
该种创建任务的方式,主要面向安全隔离、工业安全、功能安全认证等场景。
在绝大多数一般的嵌入式场景中,都不需要这么严密的安全保障,所以很少会用到。
下面我们主要讲解两种创建任务的方式:动态创建和静态创建任务。
- 动态创建任务,xTaskCreate,最常用的任务创建方式,由 FreeRTOS 动态分配任务控制块和任务栈内存。
- 动态创建任务,使用简单,不需要操作任务内存分配的问题。
- 任务的空间分配在FreeRTOS内核堆上,依赖FreeRTOS内核堆
- 绝大多数场景都使用该函数创建任务,我们后续课程内容也主要使用动态创建任务。
- 静态创建任务,xTaskCreateStatic,由用户提供任务控制块和任务栈内存,FreeRTOS 只负责调度,任务的内存空间由用户提供。
- 静态创建任务,使用更加繁琐,但内存空间更可控(毕竟任务的内存来源与管理都依赖程序员)。
- 适合对内存确定性要求非常高的系统
- 比较少用,偶尔用到。
这两种创建任务的方式,其核心的差异就在于:
任务的内存区域由谁提供。
具体来说:
- 动态创建任务,任务的内存区域交由FreeRTOS在FreeRTOS内核堆上动态创建,程序员不需要操心任务内存区域的创建问题。
- 静态创建任务,任务的内存区域交由程序员手动进行创建/分配/回收,也就是由程序员手动来管理每一个任务的内存区域。
搞明白了这两种方式的差异,那么它们的使用场景也就明朗了:
- 大多数人都是懒鬼,况且任务的内存区域只要不出现溢出,放在哪里通常都没什么关系。所以动态创建任务是最常见的选择!
- 如果你特别在意整个系统的内存使用确定性:
- 必须明确,每一个任务在哪里分配内存,具体用多少,什么时候回收等问题
- 此时,就必须手动来管理每一个任务的内存区域
- 此时,就必须使用静态创建任务的方式。
总得来说,如果满足以下条件,更推荐使用静态创建任务:
- 项目周期比较长,稳定性特别重要,优先考虑静态创建任务。如果是一个小项目,本身不过分在意稳定性,不太需要静态创建任务。
- 系统中的任务都已经提前设计好了,不会临时改变任务。在这样确定的系统中,可以使用静态创建任务。
- 系统中的任务不太复杂,只有少数几个任务,在方便手动管理的情况下,考虑使用静态创建任务。
当然,最后还有一个思考题:
静态创建任务需要程序员手动创建任务的内存区域,那这个任务内存区域在哪里创建呢?
FreeRTOS内核堆是动态创建方式使用的内存区域,静态创建不能使用。
C语言库堆是malloc等函数使用的区域,但我们不建议在STM32开发中使用这些函数,所以C库堆也不行。
主栈区域实际上也是不可用的,这一点我们下面会专门讲解。
那用哪里的内存区域充当任务内存区域呢?
很简单,自己创建一个静态内存区域.bss,然后把任务内存分配到自己创建的静态内存区域.bss即可。
关于静态创建任务,下面我们再详谈。
重要的规律:FreeRTOS官方函数名命名规则
xTaskCreate和xTaskCreateStatic,是我们正式讲解FreeRTOS函数的前两个函数。
大家第一眼看到这两个函数名,想到的是什么?
我的第一印象中,在还没有想这两个函数有啥用之前,就想到了一个问题:
"TaskCreate"以及"Static"都是"见名知意",描述函数的功能作用。
那么"x"这个前缀是什么意思呢?
如果这个前缀是完全无意义的,那么开发者这么干就有点太carzy了。
事实上,几乎所有的FreeRTOS函数,乃至于变量名,都有这样的小写字母前缀。而不同的前缀,用以代表不同的返回值类型。
下面列举一些常见的FreeRTOS函数名前缀,及其代表的函数。
最简单的前缀形式:v
在 FreeRTOS 的命名规范体系中:
- 以 v 作为前缀的函数,通常表示该函数 没有返回值;
- 而只要 不是以 v 为前缀 的函数,就通常表示 该函数是有返回值的。
下面我们围绕这个规则,解释两个核心问题。
第一,什么样的函数会被设计成没有返回值呢?
这个问题,其实并不只是 FreeRTOS 的问题,而是一个非常典型、非常通用的工程设计问题:
在实际工程中,什么时候该设计一个 void 返回值的函数?
答案其实并不复杂,一句话就可以讲清楚:
如果一个函数的结果已经通过系统状态或副作用体现出来,
而且调用者不需要去处理结果,处理也没有意义,那么这个函数就可以被设计成void返回值类型。
换个角度理解,只要你看到一个函数的返回值类型是 void,就是告诉你:
- 要么这个函数压根不可能失败,只可能成功;
- 要么函数即使失败了,你也很容易从现象上看出来,不需要再通过返回值判断;
- 要么函数就算失败了,你也没什么补救手段,给你返回值也没有实际意义。
所以作为程序员,看到void函数,其实应该高兴,这意味着这个函数不需要处理返回值。
第二,举一个典型的返回值是void,以"v"作为前缀开头的函数。
其实这样的函数我们已经见过了,也用过了,那就是延时函数:
c
void vTaskDelay( const TickType_t xTicksToDelay );
这是 FreeRTOS 中最典型的一个 v 前缀函数。
关于此函数的原理,我们后面再谈,这里我们只聊它的void返回值类型。
此函数之所以设计成没有返回值,是因为:
- 这个函数几乎不存在失败的可能性,在正常系统运行条件下基本只会成功;
- 就算没有真正延时成功,你也会立刻感知到------任务没有阻塞、代码继续往下跑,现象非常明显;
- 即便给它设计一个返回值,调用者也很难据此做出有意义的补救行为,给返回值也没意义。
最常见前缀名形式:x
在所有"有返回值"的 API 前缀里,出现频率最高的是 x。
在 FreeRTOS 的命名体系中,如果某个 API 的函数名前缀是 x,可以把它理解为类似数学中的"未知量 x"。
它代表的含义是:
- 函数一定具有返回值!
- 函数的返回值代表函数执行完成后的结果,也就是说:该函数通过返回值告诉程序员,函数执行完成后的结果。
- 但不同函数的执行结果不同,类型不同,代表的含义也大不相同:
- 有些函数,其返回值代表执行成功或失败。
- 有些函数,其返回值会返回操作完成后的对象。
- 有些函数,其返回值是当前的系统时间。
- ...
- 于是,就用"x"这种数学意义上的未知量,来代表这一大类函数的返回值。
- 当然:既然函数的返回值是结果,且是有含义、有价值的结果,那我们就最好接收并处理它。
需要注意的是:
这里的 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:
BaseType_t→longUBaseType_t→unsigned 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 的命名规范体系中:
p表示 pointer(指针)v表示 void
合在一起:pv 表示返回一个 void * 类型的指针。
那函数什么情况下会返回一个通用指针类型呢?
这我们是不陌生的,因为C语言标准库函数中的malloc、calloc等都返回void*类型。
也就是说:
当一个函数用于申请一块内存
并将这块内存的起始地址作为结果返回时(将申请的内存块交给外界)
就非常适合设计成返回 void * 类型。
所以,以 pv 为前缀的函数,表示其返回值是void*类型,具体来说就是,返回一块内存区域的指针。
FreeRTOS 中,最典型、也是最常见的 pv 前缀函数就是:
c
void *pvPortMalloc( size_t xSize );
该函数用于从 FreeRTOS 内核堆中申请一块内存,并返回这块内存的起始地址(指针)。
函数的返回值不仅仅是返回内存块,还兼具指示成功失败的作用:
- 成功时 , 返回值是一个有效的内存地址(
void *),不是NULL - 失败时 ,返回值为
NULL
返回值本身,就是函数的执行结果
可以看到"pv"前缀的函数,它们的返回值设计哲学和malloc、calloc,其实是完全一致的。
不多见的两个前缀:pc和e
在 FreeRTOS 的命名规范体系中,除了 v、x、ux、pv 这些高频前缀 之外,还存在一些出现频率较低的前缀 ,例如:pc、e。
由于它们在实际使用中并不常见,这里我们做一个简要说明。
pc 前缀可以拆开理解为:
p→ pointer(指针)c→ char
也就是说:pc 前缀表示:函数返回一个 char * 类型的指针。
与 pv 返回 void * 不同,pc 返回的是明确类型的字符指针,通常用于:
- 返回字符串
- 返回可读文本信息
看到 pc 前缀,你只需要记住一句话就够了:它返回的是一个字符指针(大概率就是一个字符串)。
最常见的pc前缀的函数就是:
c
char *pcTaskGetName( TaskHandle_t xTaskToQuery );
该函数的作用是获取任务的任务名字符串,并且作为返回值返回。
e 前缀表示:该函数具有返回值,并且返回值类型是一个 enum(枚举类型)。
也就是说,这类函数的返回值:
- 不是简单的成功 / 失败
- 也不是数量或指针
- 而是多个离散状态之一
看到 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中,用两个宏分别指代创建任务的成功与失败:
- 任务创建成功,返回宏
pdPASS,这个宏本质上就是整数值1 - 任务创建失败,返回宏
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 );
也就是说:
如果你想要定义一个任务的入口函数,那就需要遵循上述函数指针描述的格式。
从声明的格式上来说,任务入口函数需要满足:
- 返回值类型必须是void,任务入口函数必须没有返回值
- 函数名随意,建议遵循"见名知意"的原则。
- 函数的形参列表,必须有1个形参,并且此形参的数据类型是:void*
任务入口函数的实现,也有相应的要求:
任务入口函数不允许执行完毕,所以任务函数内部应包含一个死循环。
FreeRTOS的任务函数是一个永不返回的函数。
在调用 xTaskCreate 时,创建任务成功,并不意味着会立即执行该任务函数。
而只是将该函数的地址保存到任务控制块(TCB)中。
当调度器启动,并且该任务上CPU时,处理器才会从 pxTaskCode 所指向的函数入口开始执行代码。
什么叫调度器启动呢?
其实就是一句函数调用:
c
// 启动 FreeRTOS 调度器
vTaskStartScheduler();
这个函数的具体细节,我们等到后面再详谈。
第二个参数:pcName
pcName 是 xTaskCreate 函数的第二个参数:
- 它的类型是
const char * const,本质上是一个只读字符串指针。 - 用于给任务指定一个任务名(Task Name)。
在 FreeRTOS 中,任务名并不参与调度,不会影响任务的优先级,也不会决定任务什么时候运行、运行多久。
从内核角度看,任务名只是一个附加信息。
换句话说,pcName 的存在目的只有一个:给"人"看的,不是给调度器看的。
在语法层面上来说:
任务的名字几乎只有一个限制,那就是字符串长度的限制。
在FreeRTOS配置文件中,存在以下宏:
c
// 没必要加长任务名的长度,实际上你把任务名起到超过10个字符长度就已经足够逆天了!
#define configMAX_TASK_NAME_LEN ( 16 )
也就是说,任务名的字符串,最大长度是15,超过这个长度就会自动截断多余部分。
但从工程实践的角度来说,对于任务名的使用提几点建议要求:
- 任务名显然必须在任务整个存活期间都有效。建议直接传参字面值字符串常量作为任务名,这是最佳的选择!
- 两个任务允许同名,FreeRTOS并没有限制这一点,但这么做,显然是自找麻烦。任务名不要重名!
- 任务名建议"见名知意"的表明任务的作用,这样在调试排查时,会更加清晰明了。
除此之外,还有个小细节:
这个参数名以"pc"作为前缀,这也隐含表示了它的类型是字符指针类型。
第三个参数:uxStackDepth
uxStackDepth 是 xTaskCreate 函数的第三个参数,它的类型是 configSTACK_DEPTH_TYPE。
uxStackDepth直译过来,就是栈的深度,当然这里的栈指的是当前创建任务的任务栈,而不是主栈!
这个参数的作用非常简单,用于指定该任务栈的大小。
为了解释这个参数,我们提出以下问题:
- configSTACK_DEPTH_TYPE具体是什么类型?
- 这个参数,如何来确定任务栈的大小?
下面我们来逐一分析讲解这两个问题。
首先,我们可以找到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);
这段代码看起来非常合理:
- 在
main()中定义局部变量Num - 将
Num的地址通过pvParameters传给任务 - 在任务入口函数中使用该参数
但实际上,这段代码是无法依照预期执行的。
问题的根源在于:主栈的使用方式发生了变化。
在 FreeRTOS 调度器启动之前:
main()使用主栈(MSP)main()的局部变量是安全的
但在 调度器启动之后:
- 主栈会被 FreeRTOS 用作 中断函数调用栈
- 所有中断相关函数的调用,都在主栈中完成
- 而FreeRTOS的运行过程中,中断必然会频繁触发,主栈空间会被频繁使用和覆盖。(为什么中断会频频繁触发后面讲)
而主栈的空间本身就很有限。这意味着:
- main 函数的栈帧并不会受到任何保护,很容易被中断相关函数栈覆盖。
- 一旦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);
但这样做,问题又来了:这种写法显然没有任何意义。
因为:
- 任务入口函数
- 以及这些全局 / 静态变量
通常都定义在同一个源文件中(就算不在同一个文件中,全局变量也可以跨文件共享访问)。
既然如此,直接访问即可,何必传参多此一举呢?
总结如下:
- 在大多数 FreeRTOS 工程中,任务集中在 main() 中创建。此时在创建任务时给任务传参,没有什么实际价值。
- pvParameters 在实际工程中基本用不上,直接传 NULL 是最稳妥、最清晰的做法。
当然,这种传参也不是完全一无是处,一点用没有。
如果想要实现对任务的复用,对任务入口函数的复用,可以使用这种方式,给任务的入口函数传参。
但这种场景不太多见,知道即可。
第五个参数:uxPriority
uxPriority 是 xTaskCreate() 的第五个参数,它的类型是:UBaseType_t。
在在 Cortex-M3 架构上移植使用FreeRTOS:
UBaseType_t的实际类型就是unsigned long。如下图所示:

这个参数的作用是什么呢?
非常简单:uxPriority 用于指定任务的优先级。
在 FreeRTOS 中,任务优先级是一个非负整数值。数值越大,优先级越高。
优先级的取值范围
在 FreeRTOS 中,任务优先级从 0 开始。
其中:
- 0 为最低优先级
- 数值越大,优先级越高
那么,最高优先级是多少呢?
在 FreeRTOS 的配置文件 FreeRTOSConfig.h 中,有如下宏定义:
#define configMAX_PRIORITIES ( 5 )
这个宏表示:系统中可用的优先级总数量。注意,它表示的是"数量",而不是"最大数值"。
所以此时的优先级取值范围是:0 ~ 4
也就是说:
- 最低优先级:0
- 最高优先级:configMAX_PRIORITIES - 1
是否需要修改这个值?
理论上可以手动修改 configMAX_PRIORITIES。
但在实际工程中:
- 过多的优先级会增加系统复杂度
- 调度逻辑会变得难以维护
- 通常 4~7 个优先级已经足够使用
因此,一般没有特殊需求时,不建议随意增大该数值。
优先级的作用
那么,任务优先级到底有什么作用?
核心只有一句话:
优先级决定哪个任务可以先获得 CPU 的执行权。
关于任务的优先级调度机制,我们之前已经讲解过了。
具体来说,总结如下:
- FreeRTOS采用优先级调度机制,当多个任务进入就绪状态时,调度器总是选择优先级最高的任务运行。
- 只要高优先级的任务不主动让出CPU执行权,那么高优先级的任务将会一直执行,"饿死"低优先级的任务。
- 如果开启了抢占式调度(默认开启),高优先级的任务进入就绪状态后,还可以抢占低优先级任务的执行。
为了更好的帮助大家理解FreeRTOS的优先级,我们举几个反例、优先级错误的理解:
第一个误解:高优先级的任务更重要。
FreeRTOS任务的优先级越高,只代表它会优先执行,甚至抢占执行。
也就是说:任务优先级越高,它就越容易优先执行,越容易立刻执行。
所以,任务的优先级高优先级,和任务的重要性、价值都没有关系,只代表该任务执行的"紧迫性",需要尽快执行罢了。
而任务的优先级越低,代表任务越不"紧迫",接受一定的延时执行。
第二个误解:高优先级任务一定先执行。
FreeRTOS任务的优先级调度,是在多个任务都进入就绪状态,同时参与CPU竞争时,高优先级任务先执行。
但如果存在下面这种情况:
- 任务A优先级很高,但长期处于阻塞状态,没有参与CPU竞争。
- 任务B优先级很低,但系统中没有任务与它同时进入就绪状态,没有任务与它竞争CPU,那么它仍然会最先执行。
第三个误解:核心的、关键的任务设置高优先级。
上面已经讲过了,任务的高优先级最主要和任务的紧迫性相关。
而系统的核心任务,通常执行时间较长,长期占用CPU。
如果将这种任务设置高优先级,会导致低优先级任务很难有机会被执行,反而会导致整体系统的不稳定。
所以,越高优先级的任务,应当越快执行完成,尽早让出CPU。
系统的核心关键任务,通常都不应当设置高优先级。
第四个误解:优先级设置的档位越多越好。
优先级的档位越多,系统越复杂,越难以直观的预判系统行为,系统的稳定性就会降低。
所以,我们应该尽量少的设计优先级档位。
那么关于任务系统优先级的设计,具体该怎么做呢?
实践中如何设计任务优先级
FreeRTOS这个实时操作系统,本身就设计用于资源受限的嵌入式系统。
在这种场景下,绝大多数 FreeRTOS 工程中,任务数量通常都不多,一般在 5 个以内,超过 10 个的情况并不常见。
任务规模本身就不大,因此优先级设计更应该追求简洁、清晰、可预测,而不是"分得很细"。
关于优先级设计,可以遵循以下几条工程化原则:
- 优先级档位越少越好。
- 优先级不是越多越精细,而是越少越稳定。
- 档位过多会增加系统行为的不确定性,使得任务调度的关系难以分析明白。
- 只有极少数"必须立即响应"的任务,才提升一个优先级等级。
- 被提升优先级的任务,应当是那些必须马上、立刻做出响应的任务。
- 高优先级的任务应当尽量少。
- 高优先级的任务应该尽量快速执行,执行完成后快速让出CPU。
- 能使用相同优先级的任务,尽量使用相同优先级。
- 同级任务在时间片轮转机制下公平运行,逻辑更清晰,系统行为更容易预测。
- 在FreeRTOS系统中,同优先级的任务,应当占据绝大部分。
这样的优先级设计方式有三个明显优势:
- 降低系统复杂度
- 提高系统稳定性
- 增强调度行为的可预测性
优先级设计的目标不是"区分地位"或者"区分重要性",而是"控制响应时效"。只有紧急必须立刻响应的任务,才需要设置为高优先级。
而越少的层级,越清晰的结构,往往意味着越成熟、越稳健的系统。
就像我们的生活:
绝大多数时候都是按部就班的做事情,偶尔来一个紧急的事情需要立刻处理。
绝大多数事情都是早一点晚一点无所谓,偶尔来一个事情必须立刻处理。
空闲任务和空闲任务优先级
在大多数时候,我们在创建任务时,都会使用一个宏来定义任务的优先级。
调用方式通常如下:
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内核会自动创建一个系统任务,这个系统任务就是空闲任务。
该任务不需要用户手动创建,也不允许用户手动创建。
除此之外,也不允许用户手动删除它。
只要系统进入多任务调度阶段,空闲任务就一定始终存在。
需要注意:
- 空闲任务不是可选的,而是必须存在的,哪怕系统中一个用户任务都不创建,也必须存在空闲任务。
- 空闲任务和用户任务没有本质区别,空闲任务仍然有任务栈、TCB的结构。只不过这个任务由内核自动创建,不是你手动调用API创建。
- 空闲任务也可以由程序员控制和操作,但相对而言,允许执行的操作更少。比如肯定不能删除,也最好不要挂起。
空闲任务的什么时候上CPU呢?
简单来说:
当系统中没有任何用户任务可以运行时,
CPU 就会去执行空闲任务。
换句话说,只要系统中还有就绪状态的用户任务可以执行,空闲任务就不会、也不应当被执行。
正如它的名字空闲一样,只要当整个系统没有其他用户任务可以上CPU时,空闲任务才**"勉为其难"**的来上CPU。
空闲任务当中执行什么逻辑呢?
空闲任务就是一个普通任务,但是它的入口函数并不是我们自定义的。
那么空闲任务执行时,究竟执行什么逻辑,做什么事情呢?
关于这一点,我们先按下不表,留到后续课程内容补充。
空闲任务的优先级是多少呢?
了解了空闲任务的作用,可以看到它实际上是系统中的"兜底任务"。
所以它的优先级必然需要设定为最低!
通常来说,空闲任务的优先级用以下宏来表示:
第六个参数:pxCreatedTask
pxCreatedTask 是 xTaskCreate() 的第六个参数。
这可能是此函数最复杂,最需要理解的一个参数了。
它的类型是:

再深入一层查看类型:

再深入一层:

所以这个pxCreatedTask参数,到底是什么类型呢?
结论如下:
struct tskTaskControlBlock,本身是一个结构体类型,该结构体就是任务TCB的描述结构。任务的TCB内存区域其实就是这样的一个结构体。TaskHandle_t,本身是任务TCB结构体指针类型,的类型别名。pxCreatedTask形参,它是"任务TCB结构体指针类型别名"的指针类型。
一个直接明了的图示如下:

所以,pxCreatedTask形参的实际类型是:
c
struct tskTaskControlBlock ** pxCreatedTask;
再说通俗点:
形参pxCreatedTask的具体类型是,指向"任务控制块(TCB)结构体对象指针"的二级指针。
参数的作用
搞清楚了 pxCreatedTask 这个参数的类型本质之后,下一个问题自然就是:
这个传参到底是用来干什么的?
为了更好地理解这个参数的作用,我们不妨先看一个非常熟悉、也非常经典的函数调用:
c
int num;
scanf("%d", &num);
scanf 中的"指针传参"在干什么?
在调用 scanf 时:
- 表面上看,我们是给
scanf传入了一个指针,也就是一个地址。 - 但从本质上来说,
scanf获得的是:这个指针背后所对应的一片内存区域。 - 最终,
scanf会把从键盘读取到的数据,直接写入这块内存区域中。 - 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 **;
因此:
Task1Handle是 TCB 指针&Task1Handle是 TCB 指针的指针(二级指针)
xTaskCreate() 获取到这个二级指针后,就拥有了一个能力:
可以在函数内部,修改调用者中,
Task1Handle这个指针变量的指向。
也就是说:
xTaskCreate创建了一个新的任务,在FreeRTOS内核堆上分配该任务的TCB结构体。- 此时
xTaskCreate函数内部就获取了该TCB块的指针。 - 然后通过二级指针,把这个地址写回给
Task1Handle
具体的源码如下所示:

所以:
在函数执行完成后,Task1Handle 就是该任务TCB结构体的指针。
参考注释当中的描述,Task1Handle就是此任务的handle。
把handle翻译成中文术语:
在函数执行完成后,Task1Handle 就是该任务的任务句柄。
任务句柄
经过上面的描述,相信你已经对"任务句柄"的概念有了大体的认知。
说白了:
任务句柄就是此任务TCB结构体的指针。
在 FreeRTOS 中,每创建一个任务,内核都会为这个任务分配一块 TCB 内存区域,并用一个指针来唯一标识它。
这个指针,就是我们平时所说的 任务句柄(TaskHandle_t)。
那任务句柄有什么作用呢?拿到了任务句柄能实现什么功能呢?
要回答这个问题,我们不妨先反过来问一句:
为什么这个东西叫"句柄"呢?
句柄是单词"handle"的术语翻译,而handle本身具有把柄、抓手的含义。
从这个词的原始含义出发,"句柄"这个命名,本身就已经在暗示它的用途。
在 FreeRTOS 中,"任务句柄"可以这样理解:
- FreeRTOS 在创建任务时,会把这个任务的"抓手、把柄"交给你。
- 你只要拿着这个"抓手、把柄",就可以对这个任务进行操作和控制。
给你任务的"句柄",就是给你一种途径和权限,让你可以操作、控制这个任务。
基于上面的理解,可以得出两个非常重要的结论:
- 任务句柄就是你控制、操作任务的权限凭证。
- 任务句柄就是FreeRTOS内核中,某个任务的唯一性标识。
那为什么不把任务句柄直接叫"TCB指针"呢?这样不是更直击本质吗?
这个问题非常关键,而且正是 FreeRTOS 命名设计的精妙之处。
如果它直接叫:"TCB指针",是很容易让人产生误解的:
- 既然它是一个指针,那我就可以直接解引用它。
- 既然能解引用,那我直接修改 TCB 里的成员,看起来也是"合理"的。
- 但实际上,任务 TCB 属于 FreeRTOS 内核管理的核心数据结构,不可能允许程序员随意修改。
所以直接称呼为指针是不合理的。
但程序员需要操作任务,但又不能直接操作 TCB,那怎么办呢?
FreeRTOS 给出的答案是:句柄 + API(实际上所有的操作系统都是这么干的)
FreeRTOS 的解决方式非常明确:
- 把 TCB 的指针交给你,但不鼓励、也不允许你直接操作它。
- 把这个指针包装成一个"句柄",强调它的抽象意义。
- 程序员只能拿着这个"句柄",通过 FreeRTOS 提供的 API,间接操作任务。
在这个过程中:
- 任务句柄只是一个"权限凭证"
- 真正对 TCB 的读写和修改全部由内核自主完成。
那为什么不干脆翻译成"把柄""抓手"这种更直观的词呢?
原因也很简单:
- 如果使用过于生活化的词汇,反而容易让人产生误解。
- 技术术语本身就应该是抽象的、克制的,用来和日常语言做区分。
- 这种抽象命名,反而会"逼迫"学习者去理解它背后的真实含义。
从这个角度看,"句柄"这个翻译是刻意而为之的。
最后,API配合任务句柄,是如何定位具体任务的呢?
FreeRTOS 提供了大量用于控制和操作任务的 API。
这些 API 在内部,都需要回答同一个问题:
我要操作的是哪一个任务?
很显然:任务句柄是某个任务的TCB指针,这个指针/地址显然是具有唯一性的。
所以,任务句柄天然就是某个任务的唯一性标识。
所有控制、操作任务的 API,都必须通过任务句柄来定位目标任务,所以这些任务都需要传参一个任务句柄。
当然,任务句柄这个唯一性标识是被FreeRTOS内核认可的,任务唯一性标识。
对于程序员而言,任务名则更加直观,程序员可以依赖任务名来区分任务。
但对于FreeRTOS内核来说,任务名就只是一个普通的字符串,没什么特殊含义。
扩展:FreeRTOS内核的句柄
在后续 FreeRTOS 的学习中,包括 Linux 这种通用操作系统的系统编程学习中,"句柄"都是一个非常常见、非常核心的概念。
广义来说:
操作系统内核提供给用户,通过 API 间接操作内核对象的一种"标识/凭证",都可以称为句柄。
句柄从实际类型来看:
- 可以是一个指针(最常见)
- 也可以是一个整型数据(比如Linux系统的文件描述符)
- 也可以是一个结构体对象
- ...
本质上,句柄就是:
用户访问内核对象的一种"受控入口"。
在FreeRTOS内核中,句柄大多都是一个原始类型的指针。
虽然你可以拿到这个原始类型的指针,但你却并不能直接访问这个原始类型。
比如,就以任务句柄为例:


表面上看:任务句柄就是一个指向 TCB 的指针。
那么问题来了:
既然是指针,那用户是不是可以直接访问 TCB 结构体成员?
答案是:不能。
原因在于:
- 用户代码只能看到 TCB 结构体的"声明"
- 但看不到 TCB 结构体的"完整定义"
这是因为:
- 用户代码仅能包含头文件,而头文件中仅有TCB结构体的类型声明。
- 真正的结构体定义在内核源码(tasks.c)内部,这是用户代码所不能直接查看的。
因此,对于用户而言,虽然拿到了一个"指向 TCB 的指针"。
但由于不知道结构体内部布局,自然也就无法直接访问和修改内部成员。
在 C 语言中,这是一种经典的封装手段,称为:
不透明指针(Opaque Pointer)设计模式。
我们都知道,C 语言本身没有:
- private
- protected
- class
- 访问控制关键字
所以如果想实现"封装",就必须通过一些设计技巧。"不透明指针"正是其中最常见的一种。
其核心思想是:
- 在头文件中只暴露结构体声明
- 在源文件中隐藏结构体完整定义
- 对外只提供操作该结构体的 API
这样一来:
- 用户只能通过 API 操作内核对象
- 无法直接篡改内部结构(因为具体类型定义不可见)
- 提高了模块的安全性和可维护性
FreeRTOS 的句柄设计,几乎全面采用这种模式,了解这一点对我们后续学习会非常有帮助。
任务句柄必须保存吗?
任务句柄的作用,是为了在 任务外部 对该任务进行控制和各种操作。
例如:
- 删除一个任务。
- 临时挂起(暂停)一个任务,以及恢复一个挂起的任务
- 给任务发通知(发数据)
- 修改任务优先级
- 以及各式各样的查询任务的操作。
所以:
任务句柄不是任务运行的必要条件,而是跨任务操控任务的通行证。
如果系统结构简单、任务彼此独立,完全可以不保存句柄。
如果确有需求保存任务句柄:
应当使用全局变量或静态全局变量,来保存任务句柄。
任务句柄总结
由于任务句柄非常重要,但确实比较抽象。
所以这里做一个细致全面的总结:
- 任务句柄本质上是任务 TCB 结构体的指针。
- FreeRTOS 通过任务句柄,为每个任务提供一个唯一的、被内核认可的标识。
- 程序员只能通过"任务句柄 + 内核 API"的方式,间接操作和控制任务,而不能直接修改任务的 TCB 内存区域。
- 如果系统结构较为简单明了,各任务之间相互独立,那么任务句柄并不需要被保存。
- 如果需要保存任务句柄以供使用,建议使用全局变量或静态全局变量来保存,用局部变量来保存句柄也没什么意义。
vTaskStartScheduler调度器开启函数
在前面讲"动态创建任务"时,我们已经多次见过 vTaskStartScheduler() 这个函数。
但当时的介绍是零散的、点到为止的。
现在我们单独用一节内容,系统地把它讲清楚。
此函数的声明原型非常简单:
void vTaskStartScheduler( void );
函数既不需要传参,也没有返回值。
那么这个函数有什么作用呢?
一句话总结:
vTaskStartScheduler函数调用后,使得嵌入式系统从"单一执行路径、裸机运行模式",进入"多任务调度"模式。
或者说,就是启动了FreeRTOS任务调度器。
在调用该函数之前:
- CPU 只是在顺序执行
main()函数中的代码,整个系统只有单一执行路径。 - 所有通过
xTaskCreate()创建的任务,都只是被"创建并登记",但并没有真正运行。
只有当 vTaskStartScheduler() 被调用之后:
- 调度器才会接管 CPU
- 任务才会按照调度规则,开始被调度执行。
由于课程进度的限制,我们暂时不需要将这个函数的作用完全了解。
目前我们只需要知道一个事情即可:
该函数会在FreeRTOS内核堆上创建一个优先级最低的空闲任务,作为系统的兜底任务,保证系统任意时刻总有任务可以执行。
关于此函数的调用,我们还需要注意以下细节:
第一,该函数正常情况下一定不会返回。
一旦调度器成功启动,CPU 将一直在各个任务之间切换执行,main() 后续的代码理论上,没有再执行的机会。
c
// main函数内,上面的代码会创建任务
// 启动 FreeRTOS 调度器
vTaskStartScheduler();
// 理论上不会运行到这里
当然,"理论上不会"并不等于"绝对不会"。
如果 vTaskStartScheduler() 返回了,通常只有两种原因:
- FreeRTOS内核堆空间内存不足。比如空闲任务创建失败了,就会导致该函数返回,或者某个任务栈运行时爆栈。
- 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 ) )
第二,建议在系统中所有任务都创建完成后,再开启调度器。
创建任务,本质上就是在系统中"注册登记"一个任务。
因此,推荐的做法是:
- 在
main()函数中 - 在
vTaskStartScheduler()调用之前 - 统一创建系统中的所有任务
不建议进行一些"花里胡哨"的操作,包括但不限于:
- 在调度器启动函数之后创建任务(实际无效)
- 在任务中再创建任务
- 在各种中断处理中随意创建任务
- ......
任务应该在调度器启动之前统一创建,这样任务系统结构清晰、行为可预期。
如果在系统运行过程中,在各种位置临时、随意地创建任务,只会增加系统复杂性和理解难度,纯粹自找麻烦。
第三,调度器启动后,不要依赖主栈中的数据,尤其是main函数的局部变量。
调度器启动后,主栈(MSP)会变成系统的公共资源。
需要明确几点事实:
- 所有中断处理函数,强制使用 MSP,不会使用任务栈
- 主栈空间本身就很小,通常默认只有 1KB,使用FreeRTOS后,也不会刻意调大(也没空间给主栈了)
- 调度器启动后,
main()函数的使命已经完成,其栈帧不再具备存在意义
因此,在调度器启动后:
main()的栈帧会很迅速的被中断和其他的一些系统行为覆盖main()中定义的局部变量也会随之失效
此时:
如果在任务中访问main函数中定义的局部变量(基于指针传参),几乎只能访问到随机值。
任务的线程安全问题
我们在前面的课程中讲过,FreeRTOS 中的任务兼具通用操作系统中进程 和线程的特点:
- 从内存资源分配的角度看,任务是 FreeRTOS 内核分配内存资源的基本单元
- 从 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) {
}
}
这段代码中:
- 创建了两个任务
- 两个任务优先级相同
- 采用时间片轮转机制并发执行
- 并且在任务中并发访问了同一份共享数据 ------ 全局变量
Count
每个任务都会对 Count 执行 5,000,000 次自增操作。
那么问题来了:
最终 Count 的结果,会是 10,000,000 吗?
答案是:一定不是,而且一定会小于 10,000,000。
这是为什么呢?
核心原因其实只有一句话:
Count++ 并不是一个原子操作。
啥意思?
在 C 语言层面,Count++ 看起来只是一条语句。
但从 CPU 的实际执行角度看,它至少会被拆成"读-改-写"三个步骤:
- 从内存中读取
Count的当前值,加载到通用寄存器(读) - CPU 在寄存器中将该值加 1(改)
- 将加 1 后的结果写回内存(写)
也就是说:
一次 Count++,并不是"一步完成"的,而是分为多步。
而且这些步骤也不存在**"要么都全部完成,要么一步都不做"**的约束限制。
这就是所谓的**"不是一个原子操作"**。
那么这会带来什么样的影响呢?
两个任务并发执行Count自增时,在任何一个
Count++的执行过程中,只要发生一次任务切换。
就可能出现如下情况:
- 任务1 读取了
Count的值(例如 100) - 任务1 执行了Count 加1
- 任务1 还没来得及将加后的结果写回内存,发生任务切换,任务2 开始执行
- 任务2 也读取到
Count = 100 - 任务2 也执行了Count 加1,将101写回内存后,任务切换
- 任务1 重新获取执行权后,继续将没有写回的101写入内存
最终的结果是:
- 两个任务都执行了一次
Count++ - 但
Count实际只增加了 1
这类"加法丢失"的情况,在多任务并发执行过程中会反复发生,是一种常态化的现象。
因此最终的 Count 结果:
- 一定小于 10,000,000
- 而且通常会小得多
这正是线程安全问题的一个典型表现。
如何保障线程安全
既然了解了线程安全的问题所在,那么如何解决这个问题呢?
线程安全问题产生的根本原因是:Count++ 不是一个原子操作。
那么一个很自然的想法就是:
如果能把
Count++变成一个原子操作,线程安全问题是不是就解决了?
事实上确实是这样。
所谓"原子操作",核心只有一句话:
原子操作在执行过程中,不允许被打断。
也就是说:
- 要么整个操作完整执行完
- 要么一步都不执行
- 中间不存在"被插一脚"的可能
如果 Count++ 能满足这一点,那么:
- 不论有多少个任务
- 不论如何调度
- 不论什么时候切换
都不可能再出现"加法丢失"的问题。
那具体怎么实现所谓"原子操作"呢?
这是我们后面的要讲解的内容,这里暂时先不深入讲解,下面简单介绍一下这种思想,即临界区。
临界区的概念(暂时了解)
在通用操作系统和实时操作系统中,为了解决并发访问共享资源的线程安全问题,都提供了一种叫做"临界区"的解决方案。
什么叫做临界区呢?
简单来说是这样的:
FreeRTOS中可以通过加锁、限制访问、禁止调度等方式让某一段访问共享资源的代码,在逻辑上表现为一个整体的原子操作。
也就是说:这段代码在执行过程中,不允许被其他任务打断。
从而形成一种约束关系:在同一时刻,只允许一个任务进入临界区,执行这段代码。
因此,当任务1正在执行临界区代码时:
- 其他任务无法同时进入该临界区
- 也就无法并发访问同一份共享资源
- 更无法对共享数据产生交叉修改
从而保证了对共享资源的并发访问是线程安全的。
我们来看一段伪代码。
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,还是任务2
- 在执行
Count++时 - 都必须完整地完成"读 → 改 → 写"的整个过程
- 才允许其他任务进入临界区
- 也就是说,无论是任务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正在发送 "Task1 \n",刚发到一半时发生了任务切换。
此时任务2获得 CPU,也开始调用 printf1("Task2 \n")。
那么串口线上发送的数据,就有可能变成:
Task1sk2
或者
Task1
T
Task2
ask1
Task1Task2
Tas
Task1
Tk2
Task2
ask1
Task
Task2
Tas1
(顺序非常混乱)
也就是说:
两个任务对串口的访问发生了交叉。
这种现象,本质上仍然:
多个任务并发访问共享资源,且没有使用任何限制手段保护机制,所以就出现了典型的"线程安全"问题。
我们再换一种更严谨的说法。
在 FreeRTOS 中:
- 两个优先级相同的任务会被时间片轮转调度
- 每个时间片到达时,都可能发生任务切换
- 任务切换的时机,是不可预测的
因此,只要 printf1() 的执行时间超过一个时间片(实际上一定超过一个时间片),它就可能在执行过程中被打断。
一旦被打断,而另一个任务也调用了 printf1(),那么串口发送的比特流就会交叉乱序,从而导致输出结果乱序。
这说明:
并发执行 + 共享资源 + 无保护机制,就会产生线程安全问题。
术语解释(补充了解)
出于以下目的:
- 真正搞懂线程安全的概念
- 便于在学习和工作中进行技术交流
我们在讨论线程安全时,最少需要了解下面几个术语的含义。下面逐一做一个简要说明。
第一,并发。
并发的概念我们不陌生:
在 FreeRTOS 中,当多个任务优先级相同,并开启时间片轮转调度时,任务会轮流获得 CPU。
虽然在任意时刻,CPU 只能执行一个任务,但由于任务切换时间非常短暂,人眼会感觉多个任务"同时在执行"。
这就是并发。
并发是出现线程安全问题的前提条件。
如果系统中只有一个任务,那么就不会存在并发,也就不存在竞争。
第二,共享资源。
共享资源,是指被多个任务并发访问的数据或硬件资源。
常见的共享资源包括:
- 全局变量
- 通信接口外设(如串口、I2C、SPI 等)
- 文件
- 内存缓冲区
- ...
并发 + 访问共享资源,都是线程安全问题产生的必要条件。
第三,原子操作。
所谓原子操作,是指:一个操作在执行过程中不可被打断。
虽然它在底层可能由多个步骤组成,但从实际执行过程看,它要么完整执行完毕,要么完全没有执行。
如果多个任务并发执行同一个原子操作,那么就不会出现线程安全问题。
第四,竞态条件。
竞态条件是一个很不好理解的概念,至少从字面意义上看,并不直观。
所谓竞态条件,是指:
多个执行实体(任务 / 线程 / 中断)在访问共享资源时,如果最终执行结果与它们的执行顺序有关,这就叫做"产生了竞态条件"。
竞态条件(Race Condition),直译过来是"赛跑条件"。
也就是说:
多个执行实体像在赛跑一样,"谁先谁后跑完",会直接影响最终的执行结果。
当执行顺序不同,结果也不同,这就叫产生了竞态条件。
比如,多个任务同时执行这样一行代码:
c
Count++;
这里就产生了竞态条件。
产生了竞态条件,就意味着程序的执行结果变得不可预测,因为任务的先后顺序难以预测。
这就属于线程安全问题。
或者换句话说:
产生竞态条件是原因,出现线程安全问题是结果。
只要存在竞态条件,程序的结果就依赖于执行时机,而执行时机在并发系统中是不可控的。
而如果加上临界区,将上面一行代码的操作,变为一个原子操作:
c
// 进入临界区
taskENTER_CRITICAL();
Count++;
// 退出临界区
taskEXIT_CRITICAL();
这样就消除了竞态条件,因为无论是任务1还是任务2,谁先谁后执行,都不会影响Count最终计算的结果。
第五,临界区。
为了解决竞态条件问题,我们需要对共享资源的访问进行保护。临界区(Critical Section),就是这样一种机制。
临界区指的是:访问共享资源的一段必须保证不可被并发打断的代码区域。
在任意时刻,只允许一个任务进入临界区执行。
当某个任务进入临界区后:
- 其他任务无法同时访问同一份共享资源
- 从而消除竞态条件
- 确保了最终执行结果,与并发执行的先后顺序无关。
在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 时:
xTaskCreate()可以使用(动态创建)xTaskCreateStatic()可以使用(静态创建)
两种方式彼此独立,互不影响。
那么什么时候考虑混用两种任务的创建方式呢?
动态创建任务:
- 由FreeRTOS自动分配和管理任务内存
- 优点是使用简单,代码整洁
- 但缺点是内存行为不完全可预测,内存使用相对不可控。
静态创建任务:
- 由程序员分配管理任务内存,FreeRTOS只负责任务调度
- 内存使用更可控,内存行为完全可预测
- 但缺点是使用起来更复杂,需要手动管理内存
实际工程中,如果混用两种创建任务的方式,通常:
- 核心任务(必须稳定运行的任务)使用静态创建
- 临时任务或功能性任务使用动态创建
这样可以在保证关键模块稳定性的同时,提高开发效率。
静态创建任务宏的第二层含义(重点)
关于宏:
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
这意味着:
- 可以使用
xTaskCreate()动态创建任务 - 可以使用
xTaskCreateStatic()静态创建任务 - 静态创建任务的任务栈和 TCB 空间必须由程序员手动提供。
- 空闲任务的任务栈和 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
这意味着:
- 手动创建任务,只能使用
xTaskCreateStatic()静态创建任务 - 所有任务(包含空闲任务)的内存区域都由程序员手动分配和管理,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 );
此函数的前面几个参数:
TaskFunction_t pxTaskCode,任务的入口函数。const char * const pcName,任务的任务名(用于调试/可视化观察)。const configSTACK_DEPTH_TYPE uxStackDepth,任务栈的深度(重点)。- 确定最终任务栈的大小为:任务栈深度 * 4 字节
- 任务栈的最低深度是:
configMINIMAL_STACK_SIZE,这个宏的大小可以配置,通常是128 - 也就是说,FreeRTOS推荐的任务栈最小深度是128,最小大小是512字节。
- void * const pvParameters,用于传参给入口函数。几乎无意义,不会这种方式给任务入口函数传参,通常直接写NULL。
- UBaseType_t uxPriority,任务的优先级。
- FreeRTOS采用任务抢占式调度,优先级高的任务只要不主动放弃CPU,会一直执行下去。
- 空闲任务的优先级是
tskIDLE_PRIORITY,这个宏的大小可以配置,通常就是0 - 设置任务优先级时,建议基于
tskIDLE_PRIORITY + n的方式写,表达更清晰。
因此,xTaskCreateStatic() 的前 5 个参数,和 xTaskCreate()(动态创建任务)在语义上是一样的。
真正的区别在于后面两个参数:
puxStackBuffer:任务栈内存由用户提供pxTaskBuffer:TCB 内存由用户提供
后面我们重点讲这两项。
第六个参数:puxStackBuffer
函数原型中第六个参数为:
c
StackType_t * const puxStackBuffer
这个参数的含义是:
任务栈的内存空间,由程序员手动提供。
在动态创建任务时:
c
xTaskCreate()
任务栈由 FreeRTOS 从 heap 中自动分配。
而在静态创建任务时:
c
xTaskCreateStatic()
FreeRTOS 不再动态分配任务栈。
而是要求:
由程序员提前准备好一块连续内存区域,作为任务栈。
这块内存空间的首地址,就是参数 puxStackBuffer。
c
StackType_t`的实际类型在STM32F1系列上是:`uint32_t
所以我们只需要创建一个此类型的数组,然后再把数组名传参即可。
这里有两个小问题:
- 这个数组应该是一个什么变量?
- 这个数组的长度可以随便写吗?
下面来剖析这两个问题。
任务栈数组应该是什么变量
既然任务栈是由程序员提供,那么这块内存的生命周期就必须满足一个前提条件:
任务存在多久,这块内存就必须有效多久,甚至更长。
任务栈内存空间的生命周期 >= 任务本身的生命周期
任务是运行在整个系统生命周期中的。
而创建任务的函数,往往只是执行一次。
因此,如果我们写成下面这样:
c
// main函数
StackType_t xTaskStack[128]; // 局部变量
xTaskCreateStatic(..., xTaskStack, ...);
这是错误的。
原因很简单:
xTaskStack是main函数中的局部变量- 调度器启动后,main函数所处的主栈空间会被侵占,其中存储的数据就无法保证正确了。
- 主栈空间的大小十分有限,通常只有1024个字节,用来存储任务栈空间实在过于勉强。
既然主栈空间不可用,FreeRTOS内核堆也不可用,那么:
任务栈数组必须定义为"静态存储区变量"。
可以使用:
- 全局变量或静态全局变量,优先推荐使用静态全局变量。
- 静态局部变量,可以使用,但它的作用域仅限于函数内部,不利于后续调试与维护,不推荐使用。
任务栈数组的长度
任务栈数组的长度显然是不能随便写的。
需要满足两个限制条件:
第一,必须等于 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 内核用来"管理任务"的核心数据结构,本质上就是一个结构体对象。
这个结构体当中会存储任务的关键信息,例如:
- 任务的任务名
- 任务的优先级
- 任务的栈基指针
- 任务的栈顶指针
- ...
在动态创建任务时:
c
xTaskCreate()
FreeRTOS 会:
- 从 FreeRTOS内核堆上 分配一块内存作为 TCB
- 从 FreeRTOS内核堆上 分配一块内存作为任务栈
程序员无需关心这些内存的地址与生命周期。
而在静态创建任务时:
c
xTaskCreateStatic()
FreeRTOS 不再自动分配 TCB和任务栈。
而是要求:
由程序员提前准备一块
StaticTask_t类型的内存,用于存放任务的 TCB。
这块内存对象的地址,就是 pxTaskBuffer。
实际上就是:
让你创建一个 StaticTask_t 类型的全局结构体对象,然后把它的指针传进去即可。
所以此参数的推荐调用方式如下:
c
static StaticTask_t xTaskTCB; // 静态全局变量
// main函数内部创建任务
xTaskCreateStatic(..., &xTaskTCB);
有了第六个参数(任务栈)的铺垫,想到这种写法其实并不难。
不过这里仍然会有两个小问题:
- 在动态创建任务时,TCB 的结构体类型似乎和
StaticTask_t不一样,这是怎么回事? pxTaskBuffer这个参数到底是什么?它是不是任务句柄?
下面来逐一进行讲解。
两种创建任务方式TCB结构体类型对比
FreeRTOS 内部真正使用的TCB类型,其实是:tskTCB、TCB_t等。
这个类型是定义在tasks.c源码内部的一个私有的结构体类型。
TCB中的数据直接关系到FreeRTOS内核任务调度的稳定性,所以不可能直接暴露给外界查看和使用。
同样的:
在静态创建任务时,此函数也不能真正的拿到TCB的真实类型信息。
但是静态创建任务,又要求程序员提供TCB的内存区域,需要创建TCB结构体对象,那怎么办呢?
FreeRTOS提供的解决办法是:
- 设计一个
StaticTask_t类型,静态 TCB 容器类型。 - 该类型的内存使用大小,内存使用布局,都与真实的TCB结构体类型,完全保持一致!
所以:
- FreeRTOS内核内部:用
TCB_t来创建TCB结构体对象,来进行任务管理。 - 用户侧静态创建任务时:用
StaticTask_t提供TCB内存块的模具、壳子、容器。
举一个更通俗的例子是:
你想在网上买鞋,不可能把脚寄过去,于是你使用"40、41..."这样的尺码来买鞋子。
这里的"尺码标准",就类似于 StaticTask_t 类型。
而真正穿在脚上的鞋子,就相当于内核内部真正使用的 TCB 数据结构。
你不需要知道鞋子的内部结构:
- 鞋底几层
- 缝线怎么做
- 鞋垫材质是什么
- ...
你只需要按照"标准尺码"购买即可。
这个参数是任务句柄吗?
关于这个问题的答案,我个人觉得答案应该是:是也不是。
先来说是:
pxTaskBuffer参数是一个指针,用于告诉FreeRTOS内核,在此指针指向的内存空间上创建管理任务的TCB结构体。
而任务句柄,在当前FreeRTOS系统中,本质上就是TCB结构体对象的指针。
所以从存储的地址上来说,pxTaskBuffer参数和此任务的任务句柄,是完全一致的。
那为什么又说不是呢?
这是因为二者的语义、类型以及设计思路都完全不同。
首先从语义上来说:
pxTaskBuffer是你提供给内核的一块"原始内存空间",它的作用是:作为 TCB 的存储位置。TaskHandle_t是内核返回给你的"任务标识符",它的作用是:用于后续对任务进行操作。
二者的语义作用是完全不同的。
其次是类型不同:
pxTaskBuffer是一个指向StaticTask_t结构体类型的指针。- 任务句柄的类型是
TaskHandle_t。
二者虽然大小和内存布局是一致的,但类型确实不一致。
最后再谈设计思路的不同:
任务句柄是某个任务唯一性标识的抽象类型,它并不一定就是TCB类型。
FreeRTOS 并不保证:TaskHandle_t 永远等于 TCB 的地址/指针。
只是当前实现"恰好如此"。
内核完全可以在未来改成:
- 句柄内部包含额外字段
- 句柄不再直接等于 TCB 指针
- 甚至通过句柄查表定位 TCB
而你提供的 pxTaskBuffer 仍然只是那块内存空间。
所以,从根本上来说,pxTaskBuffer参数不应该被视为任务句柄。
它们一个是"任务唯一性标识"的抽象概念,一个是具体TCB块的实现内存空间。
返回值
此函数的返回值类型是:
c
TaskHandle_t
这就非常明显了:
- 此函数创建任务成功时,返回任务句柄
- 失败时返回 NULL
任务句柄是任务的唯一性标识,配合FreeRTOS提供的任务操作API,可以实现对任务的各种操作与控制。
那么什么时候会导致静态创建任务失败呢?
静态创建任务几乎不会因为"内存不足"而失败,因为:
- TCB 是你自己提供的
- 栈空间是你自己提供的
如果你故意提供错误的参数,例如:
pxTaskCode = NULLpuxStackBuffer = NULLpxTaskBuffer = NULLuxStackDepth = 0- ...
则静态创建任务可能会失败。
静态创建任务的返回值可以酌情决定是否接收处理。
静态创建任务的推荐方式
对于静态创建任务,推荐采用下面的标准调用方式:
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函数中:
你只需要提供一个比较函数,用来定义排序规则。
至于:
- 比较函数的调用时机
- 比较函数的调用次数
都由 qsort 内部算法决定。
这里的控制反转是通过"函数指针参数传递"实现的:
- 用户在调用 qsort 时,将比较函数地址作为参数传入;
- qsort 在排序过程中,通过该函数指针调用比较函数;
- 从而实现"算法框架固定、比较规则可变"的灵活排序功能。
qsort函数的设计,是一种典型的函数回调的应用,教科书般的函数回调。
函数回调是实现控制反转思想最简单直接的一种方式。。
在任务创建中:
你提供任务入口函数。
但:
- 任务何时获得 CPU
- 任务何时开始执行
- 任务何时被切换
都由 FreeRTOS 调度器决定。
这里的控制反转同样基于函数地址:
- 任务函数地址在创建任务时保存到 TCB;
- 调度器在首次调度任务时,根据 TCB 中保存的函数地址跳转执行。
不同之处在于:
函数地址不是通过参数反复传递,而是存入任务TCB中。
任务入口函数的调用,某种程度上来说,也可以视为一种函数回调的机制,只不过没那么纯粹。
毕竟任务入口函数一旦调用,除非切换任务,否则会一直执行(任务入口函数不会结束)。
在中断机制中:
从底层机制来看,中断服务程序的执行,本质上仍然是一种"函数地址跳转"。
- 中断服务函数地址在编译/链接阶段写入中断向量表;
- 中断向量表烧录后存储在 Flash闪存 中;
- 当硬件中断触发时,CPU 根据中断号索引向量表;
- 然后跳转到对应函数执行。
这里的函数地址:不是运行时传递,不是存入SRAM内存结构中。
而是:
在系统启动前就已经写死在 Flash 中。
中断函数的调用,如果谈本质的话,也可以视为一种函数回调,只不过存在硬件(NVIC)参与控制,也不是传统意义上的回调。
qsort、任务调度、中断机制,三者虽然表现形式不同,但本质一致:
都是通过函数地址跳转执行用户代码。
区别仅在于,函数地址来源不同
- 参数传入
- 数据结构保存
- 向量表索引
相比之下,vApplicationGetIdleTaskMemory 的控制反转实现更加特殊。
它并没有通过函数指针参数来实现控制反转。
而是:
- FreeRTOS内核在固定位置声明该函数;
- 用户必须提供该函数实现;
- 链接阶段由链接器完成对应函数实现的链接,也就是绑定符号;
- FreeRTOS内核运行时直接调用该函数。
这种机制称为:钩子或钩子函数(Hook)
它的特点是:
- 函数名以及函数声明固定
- 实现由用户提供
- 链接阶段进行符号绑定
- 具体的调用时机由系统框架来决定。
为什么要学习控制反转的思想(了解)
通过上述几个例子我们可以看到:
无论是 qsort、任务调度、中断机制,还是 Hook,本质上都体现了一种共同的设计思想:
框架掌控流程,用户提供逻辑。
在传统顺序程序中:
- 程序员掌控执行流程;
- 每一个函数何时执行,由 main 函数明确控制。
而在控制反转模型中:
- 执行流程由系统或框架掌控;
- 程序员只需要提供某些"被调用的逻辑单元"。
至于:
- 何时执行
- 执行多少次
- 是否被打断
- 是否再次执行
都由框架或硬件决定。
如果从更抽象的层面来看:
控制反转的核心,并不在于"函数指针"这个技术细节,而在于:
控制权的转移。
具体的实现手段可以有很多种:
- 函数指针参数传递(qsort)
- 数据结构保存函数地址(TCB 中的任务入口函数)
- 向量表索引函数地址(中断机制)
- 固定函数声明 + 链接期绑定(钩子,Hook)
它们在技术实现上不同,但在设计思想上是完全统一的。
因此,我们可以得出一个更高层的结论:
控制反转是一种架构思想
回调、Hook、任务入口函数、中断服务函数,都是这种思想在不同层次上的具体体现。
从应用层到内核层,从软件框架到硬件机制,控制反转思想贯穿始终。
如果再往上升一个层次去看:
操作系统本身,就是一个巨大的控制反转框架。
- 用户程序并不控制 CPU;
- 用户程序并不决定自己何时执行;
- 中断并不由用户程序主动调用;
- 系统资源的分配也不由用户程序直接掌控。
一切都在"系统框架"的调度之下运行。
而我们写的代码,只是嵌入在这个框架中的一部分逻辑。
总之:
当系统规模变大,单纯依赖顺序控制无法构建复杂系统时,就必须引入控制反转,让框架接管流程,而程序员只专注于实现具体逻辑。
举几个大家现在能理解的例子,这段内容大家看个乐子即可:
案例一:裸机 while(1) 与 FreeRTOS 的对比
我们先看最原始的裸机程序:
c
int main(void){
init_uart();
init_led();
while (1){
if (uart_rx_flag){
handle_uart();
}
if (led_timeout){
toggle_led();
}
}
}
在这个模型里:
- 你控制主循环
- 你决定检查顺序
- 你决定执行频率
- 你决定任务优先级(靠代码顺序)
这是典型的"顺序控制模型"。
现在换成 FreeRTOS:
c
void vUartTask(void *arg){
while (1){
handle_uart();
vTaskDelay(10);
}
}
void vLedTask(void *arg){
while (1){
toggle_led();
vTaskDelay(10);
}
}
此时发生了什么?
- 你不再写主循环调度逻辑
- 你不再控制任务顺序
- 你不再控制切换时机
- 你不再控制优先级抢占过程
调度器接管了执行流程。
你只写"任务逻辑"。
这就是控制反转在嵌入式中的直接体现。
第二个例子,利用中断来接收串口数据。
如果你直接用轮询的方式:
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);
}
}
现在:
- 你不再等待阻塞接收数据
- 你不再决定什么时候接收数据,你没有这个控制权。
- 由硬件触发中断机制,来决定何时进行数据接收。
你只是提供"数据到来时要做什么"。
这是硬件层面的控制反转。
如果你能坚持看到这里,而且也能看懂的话,控制反转的思想将对你后续的学习进阶之路非常有帮助。
我希望大家能够记住几件事情:
第一,控制反转不是一定要使用的。顺序执行也不是万恶之源。
如果你只是简单点个灯,也没有必要将控制权交给其他框架,直接顺序执行就可以了。
只有复杂的系统,存在并发或异步执行的需求时,才需要用到控制反转。
第二,面对大型框架,复杂代码时:
如果需要手写逻辑让系统框架调用,不妨多思考"它在哪个生命周期阶段调用我",而不是总是思考"它为什么要调用我"。
这是一个认知上的提升,比如我们后面学习Qt框架,一定需要掌握这种认知,才能够真正学明白。
第三,所谓控制反转,是从"程序控制机器"走向"系统调度程序"的过程。
借助内核函数提供空闲任务的空间(了解)
说了一大堆理论,这一小节来具体实践一下如何提供空闲任务的内存空间。
首先我们先讲一种,几乎很少见,但确实存在的方式------借助FreeRTOS内核函数为空闲任务提供任务空间。
如下图所示

为了开启这个内核函数,我们需要在配置文件中添加以下宏定义:
c
// 以下三个配置宏开启后,空闲任务空间的创建由FreeRTOS内核函数提供
#define configKERNEL_PROVIDED_STATIC_MEMORY 1
#define portUSING_MPU_WRAPPERS 0
#define configTIMER_TASK_STACK_DEPTH 128
如此配置后,我们的main.c文件中的代码不需要任何改变,就可以直接完成静态创建任务,并启动FreeRTOS。
此时空闲任务采用以下配置:
- 默认的最小任务栈大小,即512字节。
- 任务栈和TCB的内存区域都分配为静态局部变量。
这种方式并不推荐使用,因为:
- 不可以自主控制空闲任务的任务栈的大小(通常尽量不改FreeRTOS内核源码)
- 静态局部变量在函数外部无法获取访问,不利于调试维护代码。
下面,我们讲一下更推荐的方式,即手动为空闲任务分配任务栈和TCB内存空间。
手动为空闲任务分配任务空间
将配置文件中的上述三行宏定义注释掉,我们采用手动为空闲任务分配任务空间的方式。
首先,我们从头文件中把函数的声明抄过来:

如下所示:
c
void vApplicationGetIdleTaskMemory( StaticTask_t ** ppxIdleTaskTCBBuffer,
StackType_t ** ppxIdleTaskStackBuffer,
configSTACK_DEPTH_TYPE * puxIdleTaskStackSize );
可以看到此函数有三个参数,分别是:
- TCB内存空间的二级指针。
- 任务栈内存空间的二级指针。
- 任务栈深度的一级指针。
我们已经学习使用C语言超过一个月了,这个函数的实现方式,对你来说应当非常简单了:
- 传参TCB内存空间的二级指针:
- 目的是在函数内部修改对应一级指针的指向,也就是TCB指针的指向
- 指向谁呢?
- 当然是指向我们自定义创建的TCB内存块。
- 传参任务栈内存空间的二级指针:
- 目的是在函数内部修改对应一级指针的指向,也就是任务栈指针的指向
- 指向谁呢?
- 当然是指向我们自定义创建的任务栈内存块。
- 传参任务栈深度的一级指针:
- 目的是在函数内部修改任务栈的深度
- 改为哪个数值呢?
- 当然是我们自定义创建的任务栈数组的长度。
一个标准的规范的调用方式如下:
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;
}
相比"内核自己偷偷分配",这种方式具有明显优势:
- 内存在哪你看得见;
- 栈多大你自己决定;
- 更利于内存分析与调试;
- 不需要开启特殊的配置宏;
- 行为完全可预期。
一句话总结:
这是最清晰、最可控、最符合嵌入式工程思维的一种写法。
静态创建任务的内存空间回收(了解)
在 FreeRTOS 中,如果我们静态创建任务:
c
xTaskCreateStatic( ... );
那么任务的:
- TCB
- 任务栈
都是由我们自己提供的静态内存。
问题来了:
当我们调用
vTaskDelete()删除这个任务时,这块内存会不会被自动"释放"?
当然不会。
静态创建任务时,任务的内存空间是由程序员显式提供的,程序的静态数据区。
而静态内存的生命周期,从系统上电开始就已经存在,直到系统断电才消失(SRAM)。
因此,这种内存本身就不存在"分配"和"释放"的过程,自然也不存在所谓的"自动释放"。
那怎么销毁这段空间呢?
答案是:
不能销毁,不能释放,不能free。毕竟这段空间是静态区域,没有所谓的"释放"。
这段空间只能被复用,被其他静态任务所重新使用。
但在工程实践中,这种"删除后复用"的场景极少出现。
因为使用静态创建任务的核心设计理念通常是:系统任务数量固定,结构稳定,不进行动态扩展或删除。
也就是说:
静态任务通常在系统初始化阶段创建完成,之后长期存在,基本不会删除。
所以静态任务的空间回收,工程实践中,基本上不会去做这个事情。
总结
至此,关于任务创建的两种方式,以及其中涉及的一些基础概念,我们已经完整梳理了一遍。
总体来说,在实际工程中使用更多的仍然是动态创建任务。
建议大家仔细阅读官方文档,把相关 API 与细节都好好自己梳理一番,结合任务内存布局,深入理解任务的创建。
当然,任务创建只是第一步。
创建任务,编写任务入口函数,解决的是:
系统中"需要有哪些任务",这些任务都在做什么事情。
而真正决定系统行为的,是:
当前"谁在运行"。
这就引出了接下来更核心的问题------
FreeRTOS 的任务调度机制。
在前面的章节中,我们已经从结果层面知道:
- 高优先级任务会抢占低优先级任务
- 高优先级任务会一直执行,直到主动放弃CPU
- 相同优先级任务会进行时间片轮转
- 阻塞态任务不会参与调度
- 进程的三种状态
- ...
这些是调度的"表现"。
但问题是:
FreeRTOS 内部究竟是如何实现这些行为的?
调度器是依靠什么结构来组织和管理任务的?
如果说"任务创建"是给系统添加成员,那么"任务调度"就是决定这些成员如何运转。
要真正理解调度机制,我们必须深入内核最核心的一部分------
任务列表(Task List)。