什么是任务
在 FreeRTOS 中,任务(Task)是内核调度的基本执行单位。
它本质上是一个独立运行的函数,并且拥有自己的运行环境。
FreeRTOS 为每个任务分配两块独立的内存:
- TCB(Task Control Block):保存任务的元信息,例如优先级、状态、上下文指针等。
- 任务栈(Stack):保存此任务的局部变量、函数调用现场和寄存器上下文。
任务之间的栈空间相互独立,因此每个任务可以像“单独运行”的程序一样执行自己的逻辑。
虽然单核 MCU 在任意时刻只能执行一条指令,但 FreeRTOS 通过优先级调度和快速的上下文切换让多个任务“轮流运行”,从而表现出类似多线程的效果。
因此,任务可以理解为:FreeRTOS 中由调度器管理的独立执行单元,是整个系统中最小的可调度对象。
一个最简单的任务长这样
1 | void StartDefaultTask(void *argument) |
任务的状态机及其生命周期
一个任务包含四种状态, 刚创建时的就绪态(Ready), 执行时的运行态(Runing), 阻塞时的阻塞态(Blocked), 暂停时的挂起态(Suspended)
当然还有被delete后的状态, 这里不列入其中
如何创建一个任务
函数参数及返回值
FreeRTOS提供了6个api函数供我们使用, 分别是:
xTaskCreate(), xTaskCreateStatic(),xTaskCreateRestricted(), xTaskCreateRestrictedStatic(), xTaskCreateAffinitySet(), and xTaskCreateStaticAffinitySet()
其中最基础的是xTaskCreate(), 函数定义如下:
1 | BaseType_t xTaskCreate( TaskFunction_t pvTaskCode, |
另外提一嘴, FreeRTOS这个项目使用的命名方法可以看作是一种匈牙利命名法的变体, 通过在变量名前加’i’, ‘u’ 之类的单词表示变量的类型
如uxPriority 就是指的unsigned basetype (basetype在不同的架构上位数不同, 如32位架构下是32位, 64位下则是64位)
话说回来, 介绍一下其中的参数及返回值
参数:
- pvTaskCode: 该参数为指向任务的函数的指针
- pcName: 用于调试时显示的任务名称, 可置NULL,
configMAX_TASK_NAME_LEN记录了其最大长度 - usStackDepth: 用于指定分配给任务使用的堆栈大小, 该值指定的是堆栈可容纳的字节数, 即 如果该参数为128, 则会分配128 * 4 个字节的堆栈空间.
configSTACK_DEPTH_TYPE规定了用于保存堆栈大小的数据类型, 默认为uint16_t, 意味着如果堆栈深度乘以堆栈宽度大于65535时会发生溢出 - pvParameters: 用于给任务的参数传值
- uxPriority: 该任务的优先级, 范围为0到
configMAX_PRIORITIES - 1 - pxCreatedTask: 用于储存该任务的句柄, 可置NULL
返回值:
- pdPASS: 标志着任务被成功创建
- pdFAIL: 表明没有足够的堆内存创建任务
不同的任务创建函数
从另外五个函数中可以提取出这几个关键词, static, Restricted, AffinitySet
其中:
- static表示使用静态的内存, 和c语言中static一个作用, 不多赘述
- restricted表示创建的任务的权限受到限制, 无法访问系统内存
- affinity表示可用于多核处理器的任务, 支持Symmetric Multi Processing(SMP)
创建任务的不同姿势
简单的创建几个任务
1 | int main( void ) |
一个任务不止能在main函数里创建, 还可以在任务之中进行任务的创建
1 | void vTask1( void * pvParameters ) |
通过任务模板进行实例的创建
1 | void vTaskFunction( void * pvParameters ) |
四种状态
就绪态和运行态
那么创建完一个任务之后, 其默认会处于就绪态
那么有了就绪态, 我们创建一个任务肯定想要让他执行, 于是有了运行态
运行完毕的任务回到就绪态, 然后从就绪态中选一个任务执行, 那么最简单的一个模型诞生了

这里提一嘴优先级, 顾名思义, 优先级决定了哪个任务能够先执行
上文还说到, freertos通过不断地上下文切换达到”伪”多线程的目的
那么在每次上下文切换的时候都会根据优先级决定谁会被选中为下一个运行的任务
试想以下场景: 我们现在有两个优先级不同的任务, 请问这两个任务会如何执行?
答案显而易见, 低优先级的任务会被高优先级的任务饿死
那么我们两个状态的模型就不够用了
于是诞生了阻塞态和挂起态
阻塞态和挂起态
当搞优先级的任务进入阻塞态后, 那么就绪态就只剩下低优先级的任务可以执行了
这样, 低优先级的任务就饿不死了
阻塞态分为两种类型:
时间(Temporal)事件和同步(Synchronization)事件
同步事件可由FreeRTOS 队列、二进制信号量、计数信号量、互斥体、递归互斥体、事件组、流缓冲区、消息缓冲区和直接任务通知产生
任务可以阻塞同步事件并设置超时,从而有效地同时阻塞两种类型的事件。例如,任务可以选择等待最多 10 毫秒以便数据到达队列。如果 10 毫秒内有数据到达或 10 毫秒后没有数据到达,任务将离开阻塞状态
时间事件会在指定时间到达或过去一定时间后发生
对应void vTaskDelay( TickType_t xTicksToDelay )和
void vTaskDelayUntil( TickType_t * pxPreviousWakeTime, TickType_t xTimeIncrement )函数
通过这两个函数即可设定一个任务执行的频率
其中xTicksToDelay 参数指的是所需等待的tick数, 通过pdMS_TO_TICKS()函数将毫秒数转换为tick数
tick数和mcu的频率挂钩, 所以通过函数将毫秒数进行转换可以避免因频率更改带来的错误
通过将FreeRTOSConfig.h 中的 INCLUDE_vTaskSuspend 设置为 1, 来启用 portMAX_DELAY
将 xTicksToWait 设置为 portMAX_DELAY 将导致任务无限期等待(不会超时)
pxPreviousWakeTime参数指的是当前的tick数, xTimeIncrement 指定多少tick产生一次事件
pxPreviousWakeTime仅需指定一次, 之后会在函数内自增
两个函数不同点在于, 前者是相对的, 而后者是绝对的
使用示例
1 | void vTaskFunction( void * pvParameters ) |
挂起态
处于挂起状态的任务不可用于调度程序。进入挂起状态的唯一方法是通过调用 vTaskSuspend() API 函数,而退出状态的唯一方法是调用 vTaskResume() 或 xTaskResumeFromISR() API 函数。大多数应用程序不使用挂起状态

至此, 总结出四种状态的状态转换图
删除(Delete)一个任务
vTaskDelete() API 函数删除任务。仅当 FreeRTOSConfig.h 中的 INCLUDE_vTaskDelete 设置为 1 时,vTaskDelete() API 函数才可用
void vTaskDelete( TaskHandle_t xTaskToDelete )通过传递任务句柄的指针完成删除
当任务被删除时,只有内核本身分配给任务的内存才会被自动释放。如果不再需要,在任务执行期间分配的任何内存或其他资源都必须显式释放
当任务被删除时, 内核本身分配给任务的资源由空闲任务来进行释放
这里暂且把空闲任务看作为一个最低优先级的, 只在就绪态只存在空闲任务时才执行的一个任务
因为资源需要通过空闲任务来进行释放, 所以需要保证空闲任务不被饿死, 之后会说明
调度算法
FreeRTOS通过调度器来决定什么时候执行什么任务
优先级
FreeRTOS保证总是选择位于就绪态的最高优先级的任务进入运行态
那么相同优先级的任务呢?
FreeRTOS采用循环调度的算法, 即像一个队列一样, 运行完毕的任务回到队列末尾, 位于队列首的任务被选中进入运行态
优先级的最大值由configMAX_PRIORITIES决定
优先级的范围从0到configMAX_PRIORITIES - 1, 0即最低优先级
对于优先级, 有两种调度程序可选, 分别是通用调度(Generic Scheduler)和架构优化调度(Architecture-Optimized Scheduler)
通用调度没有对configMAX_PRIORITIES施加上限, 但由于更大的值意味着需要更多的RAM以及更长的最坏情况执行时间, 所以一般来说, 建议最小化configMAX_PRIORITIES
架构优化调度可以根据不同的架构优化configMAX_PRIORITIES的值, 例如在32位架构上该值为32, 64位架构上该值为64
要使用架构优化调度, 只需要在FreeRTOSConfig.h 中将 configUSE_PORT_optimized_TASK_SELECTION 设置为 1, 为0则为通用调度
但不是所有FreeRTOS端口都有架构优化的实现, 可以根据configUSE_PORT_optimized_TASK_SELECTION的默认值来判断, 如果为1, 则说明有, 反之则无
优先级不只能在任务创建时指定, 还可以在程序中动态进行修改
当 FreeRTOSConfig.h 中的 INCLUDE_vTaskPrioritySet 设置为 1 时
可以使用vTaskPrioritySet()在程序中动态修改任务的优先级
1 | void vTaskPrioritySet( TaskHandle_t xTask, |
当 FreeRTOSConfig.h 中的 INCLUDE_uxTaskPriorityGet 设置为 1 时
还可以使用uxTaskPriorityGet()动态获取任务的优先级
1 | UBaseType_t uxTaskPriorityGet( TaskHandle_t xTask ); |
空闲任务
空闲任务, 顾名思义, 就是在没有任务可以执行时 执行的任务
其作用主要是起占位符作用, 因为FreeRTOS需要保证在任何时刻都有任务可以进入运行态
他的优先级为最低的0, 保证了不会因优先级更大而阻碍别的任务
空闲任务还有一个作用就是清除由vTaskDelete() 函数残留的内核资源
虽然空闲任务的优先级为最低的0, 但也可能存在相同的, 优先级为0的任务, 这时, 我们肯定不想空闲任务和我们的任务抢夺运行时间
FreeRTOSConfig.h 中的 configIDLE_SHOULD_YIELD决定空闲任务是否会和其他0优先级任务抢夺运行时间
当configIDLE_SHOULD_YIELD置0时, 空闲任务和其他任务一样, 遵循循环调度的算法
当configIDLE_SHOULD_YIELD置1时, 当有其他0优先级任务处于就绪态时, 空闲任务将在每次迭代中让步, 将运行时间让给其他任务

在每次进行上下文切换时(这里每个间隔称为一个时间片, 每个事件片的末尾执行一次上下文切换), 先是由循环调度运行空闲任务, 此时空闲任务发现就绪态还有其他0优先级的任务, 于是主动让出该时间片的剩余时间.
t6到t7时间段还揭示了高优先级的任务是如何抢占低优先级的任务的(详见后文)
空闲任务是可以通过钩子函数进行hook的
1 | void vApplicationIdleHook( void ); |
空闲任务挂钩的常见用途包括:
执行低优先级、后台或连续处理功能,而无需为此目的创建应用程序任务而产生 RAM 开销。
测量空闲处理能力的量。 (只有当所有较高优先级的应用程序任务都没有要执行的工作时,空闲任务才会运行;因此,测量分配给空闲任务的处理时间量可以清楚地指示空闲处理时间。)
将处理器置于低功耗模式,在没有要执行的应用程序处理时提供一种简单且自动的节能方法(尽管可实现的节能效果低于无滴答空闲模式所实现的节能效果)。
空闲任务挂钩函数必须遵守以下规则:
空闲任务挂钩函数绝不能尝试阻止或挂起自身。
注意:**以任何方式阻止空闲任务都可能导致没有任务可进入运行状态的情况。**如果应用程序任务使用 vTaskDelete() API 函数删除自身,则空闲任务挂钩必须始终在合理的时间段内返回到其调用者。这是因为空闲任务负责清理分配给删除自身的任务的内核资源。如果空闲任务永久保留在空闲挂钩函数中,则无法进行此清理
时间片, 抢占式与协作式
时间片
试想当有两个优先级相同的, 连续运行(不会进入阻塞态)的任务, 如果正在运行中的那一个任务一直不主动退出的话, 那另一个任务将会被饿死
如何使两个同级的任务”同时”执行呢?
于是有了时间片(time slice)
时间片是一段时间, 在一段时间后, 即时间片的末尾, 调用一次上下文切换, 通过循环调度, 使就绪态中的任务进入运行态
时间片的长度由configTICK_RATE_HZ决定, configTICK_RATE_HZ被设置为100Hz时, 指一个时间片为1 / 100(s), 即10ms
configTICK_RATE_HZ 的最佳值取决于应用程序,但典型值为 100

抢占式调度
抢占式即高优先级会直接打断低优先级任务的执行, 上文默认是使用的抢占式算法
高优先级的任务通常标志着 紧急, 重要
因此需要保证高优先级任务从 进入就绪态 到 从就绪态进入运行态 的时间
任务的抢占是可以嵌套的, 这意味着在低优先级任务被高优先级任务抢占时候, 高优先级的任务是可以被更高优先级的任务所抢占
即使遇到时间片结束时的上下文切换, 下一个时间片仍然会执行更高优先级的任务, 因为此时就绪态中最高优先级的任务仍然是他自己
三种调度算法
是否使用时间片以及是否使用抢占式调度可以通过FreeRTOSConfig.h中configUSE_PREEMPTION 和 configUSE_TIME_SLICING的值进行配置
第三个配置常量 configUSE_TICKLESS_IDLE 也会影响调度算法, 他决定了时间片末尾的上下文切换请求在空闲时是否会关闭 以最大限度地降低功耗, 默认为0(关闭)
configUSE_PREEMPTION 和 configUSE_TIME_SLICING的不同配置决定了程序使用哪一种调度算法

带有时间切片的固定优先级抢占式调度(Prioritized Preemptive Scheduling with Time Slicing)

这也是最为常见的一种算法
无时间分片的优先抢占式调度(Prioritized Preemptive Scheduling without Time Slicing)

由于没有时间片提供的定期的上下文切换, 导致空闲任务一直占着cpu导致任务2执行时间远小于空闲任务(configIDLE_SHOULD_YIELD为0的情况)
协同调度(Cooperative Scheduling)

协同调度算法下的任务只有在一个任务运行结束时才会进行切换
可用于数据传输的情况
例如任务1将要发送’abcdefg’
任务2将要发送’1234567890’
我们肯定不想在任务2发送期间被任务1抢占使得发送出的数据被破坏
但这是一个比较奇怪的例子, 使用无时间分片的优先抢占式调度, 然后将两者的任务优先级置为相同 也可以解决这个问题, 但这样必须限制发送数据的任务为相同优先级
而且进程间通信往往有更好的方法
TLS与可重入
抄录
线程本地存储(TLS)允许应用程序开发人员在每个任务的任务控制块中存储任意数据。此功能最常用于存储通常由不可重入函数存储在全局变量中的数据。可重入函数是可以从多个线程安全运行而没有任何副作用的函数。当在没有线程本地存储的多线程环境中使用不可重入函数时,必须特别注意检查临界区内这些函数调用的带外结果。过度使用临界区会降低 RTOS 性能,因此线程本地存储通常优于使用临界区。到目前为止,线程本地存储最常见的用途是 C 标准库和 POSIX 系统使用的 ISO C 标准中使用的 errno 全局变量。 errno 全局用于为常见标准库函数(例如 strtof 和 strtol)提供扩展结果或错误代码
大多数嵌入式 libc 实现都提供 API 以确保不可重入函数可以在多线程环境中正常工作。 FreeRTOS 支持两个常用开源库的重入 API:newlib 和 picolibc。这些预构建的 C 运行时线程本地存储实现可以通过在其项目的 FreeRTOSConfig.h 文件中定义下面列出的相应宏来启用
应用程序开发人员可以通过在 FreeRTOSConfig.h 文件中定义以下宏来实现线程本地存储: 将 configUSE_C_RUNTIME_TLS_SUPPORT 定义为 1 以启用 C 运行时线程本地存储支持。将 configTLS_BLOCK_TYPE 定义为 c 类型,该类型应用于存储 C 运行时线程本地存储数据。将 configINIT_TLS_BLOCK 定义为初始化 C 运行时线程本地存储块时应运行的 C 代码。将 configSET_TLS_BLOCK 定义为切换新任务时应运行的 c 代码 将 configDEINIT_TLS_BLOCK 定义为在取消初始化 C 运行时线程本地存储块时应运行的 c 代码
上下文切换原理
当在任务中手动调用vTaskStartScheduler()第一次启动调度器时会触发SVC异常
OS收到请求后在SVC Handler中做好上下文切换的准备, 同时悬起一个pendSV异常
当CPU退出SVC且被嵌套的中断例程执行结束后, 进入pendSV, 进行上下文切换
在任务中手动调用taskYIELD()时会直接悬起一个pendSV异常
一个时间片末尾产生的Systick中断现象与手动调用taskYIELD()相同
参考文档
FreeRTOS 从入门到精通6–任务管理这件事(下)(对比PLC,安卓)
Mastering-the-FreeRTOS-Real-Time-Kernel.v1.1.0 (Richard Barry -and- The FreeRTOS Team)
Cortex-M3权威指南(Joseph Yiu 著 宋岩 译 www.ouravr.com热心网友 校对)