1. FreeRTOS 是什么?

FreeRTOS 是一个实时操作系统内核(RTOS),专为嵌入式系统设计。它把"多任务能力"以库的形式提供给原本只能跑单一程序的裸机(bare metal)应用。

没有 FreeRTOS 的裸机程序:
    main() 里从头到尾只做一件事,或者靠手动轮询/中断勉强"多任务"
加入 FreeRTOS 之后:
    FreeRTOS 内核管理多个任务的切换,每个任务像独立的程序一样运行
    你只需要把 FreeRTOS 的 .c 文件加进项目,调用它的 API 即可

FreeRTOS 的发行包形式:一个 zip 压缩包,里面包含:

  • 所有官方支持平台的源码
  • 大量预配置好的 demo 工程
  • FreeRTOS+ 生态组件

2. 关键概念:什么是"端口"(Port)?

FreeRTOS 支持约 20 种编译器 和超过 30 种处理器架构。每一种"编译器 + 处理器架构"的组合,称为一个端口(Port)

举例:
    GCC 编译器 + ARM Cortex-M3   → 这是一个端口
    IAR 编译器 + ARM Cortex-M3   → 这是另一个端口(编译器不同)
    GCC 编译器 + AVR              → 又一个端口(架构不同)

不同端口的移植代码存放在不同子目录中,核心逻辑代码是共用的。

3. FreeRTOS 发行包的目录结构

整个 zip 包解压后,顶层只有两个主目录:

FreeRTOS 发行包根目录

FreeRTOS/

FreeRTOS-Plus/

Source/FreeRTOS 内核源码

Demo/各平台预配置 demo 工程

Source/FreeRTOS+ 生态组件源码

Demo/FreeRTOS+ demo 工程

重要原则:整个发行包只有一份 FreeRTOS 内核源码(在 FreeRTOS/Source/),所有 demo 工程都共用这份源码。不要移动或重命名这个目录,否则 demo 工程会找不到源文件。

4. 内核源文件详解

4.1 所有端口都需要的公共源文件

FreeRTOS/Source/

tasks.c必须,任务管理核心

list.c必须,内核链表实现

queue.c几乎必须,队列和信号量

timers.c可选,软件定时器

event_groups.c可选,事件组

croutine.c可选,协程(已过时)

各文件说明:

文件 是否必须 作用
tasks.c 必须 任务创建、删除、调度,是内核核心
list.c 必须 内核内部链表,tasks.c 依赖它
queue.c 几乎必须 队列和信号量(两者共用这个文件)
timers.c 可选 软件定时器,用到定时器才加
event_groups.c 可选 事件组,用到才加
croutine.c 可选 协程(极少使用,已不维护,不推荐)

4.2 端口专属源文件

除了公共源文件,还需要加入当前"编译器 + 架构"专属的移植文件:

FreeRTOS/Source/portable/

MemMang/5种堆内存方案heap_1.c ~ heap_5.c

compiler1/编译器1专属目录

compiler2/编译器2专属目录

architecture1/架构1移植文件

architecture2/架构2移植文件

architecture1/架构1移植文件

architecture2/架构2移植文件

使用规则
FreeRTOS/Source/portable/[你的编译器]/[你的架构]/ 目录下,把所有 .c 和汇编文件加入工程。
关于堆内存文件heap_n.c):
FreeRTOS V9.0.0 之前:必须包含一个 heap_n.c
FreeRTOS V9.0.0 及以后:只有当 configSUPPORT_DYNAMIC_ALLOCATION = 1(或未定义,默认为 1)时才需要包含。
五种方案简介:

文件 特点 适用场景
heap_1.c 只分配不释放,最简单 任务创建后不删除的系统
heap_2.c 可释放但不合并碎片 任务大小固定的系统
heap_3.c 封装标准 malloc/free 已有 libc 的环境
heap_4.c 可释放且合并碎片(推荐) 大多数场景,通用性好
heap_5.c 支持跨非连续内存区域 内存不连续的硬件

4.3 必须包含的头文件路径(Include Path)

需要在编译器的头文件搜索路径中添加以下三个目录

1. FreeRTOS/Source/include
   └── 核心头文件(FreeRTOS.h、task.h、queue.h 等)
2. FreeRTOS/Source/portable/[compiler]/[architecture]
   └── 端口专属头文件(portmacro.h 等)
3. 你的项目目录(包含 FreeRTOSConfig.h 的目录)
   └── 应用配置头文件

4.4 在源文件中正确包含头文件

任何使用 FreeRTOS API 的 .c 文件,都必须按以下顺序包含头文件:

// 第一步:必须最先包含 FreeRTOS.h(它会包含 FreeRTOSConfig.h 和基础定义)
#include "FreeRTOS.h"
// 第二步:根据你使用的 API,选择对应的头文件
#include "task.h"         // 任务相关 API
#include "queue.h"        // 队列相关 API
#include "semphr.h"       // 信号量相关 API
#include "timers.h"       // 软件定时器 API
#include "event_groups.h" // 事件组 API

注意FreeRTOS.h 必须在其他 FreeRTOS 头文件之前包含,否则编译会报错。

5. FreeRTOSConfig.h 的位置规则

FreeRTOSConfig.h 是应用专属的配置文件,有一个非常重要的规则:

正确位置:放在你的应用项目目录里
错误位置:放在 FreeRTOS 源码目录里
原因:
    FreeRTOS 源码目录是通用的,供所有项目共用
    FreeRTOSConfig.h 是针对某个具体应用定制的,属于应用代码
    放错位置会导致配置混乱或影响其他项目

最佳实践:直接复制对应端口 demo 工程里的 FreeRTOSConfig.h,在此基础上修改,不要从零写。

6. Demo 工程介绍

每个 FreeRTOS 端口都有至少一个 demo 工程,存放在 FreeRTOS/Demo/ 目录下。

FreeRTOS/Demo/

Demo_x/针对某个平台的 demo 工程

Demo_y/另一个平台的 demo 工程

Demo_z/...

Common/所有 demo 共用的任务实现

Minimal/演示各 API 用法的通用任务

Demo 工程的用途

1. 提供一个"开箱即用"的工程模板(文件配置、编译选项都已设好)
2. 让你无需任何前期知识就能快速体验 FreeRTOS
3. 展示各种 API 的正确用法
4. 作为你自己项目的起点

Demo 工程的类型

  • 通用 demo 任务:在 Demo/Common/Minimal/ 里,纯粹展示 API 用法,没有实际业务功能
  • Blinky(闪灯)demo:最简单的 demo,通常只有 2 个任务 + 1 个队列,适合入门
    每个 demo 在 FreeRTOS.org 网站都有对应页面,说明如何配置硬件、如何编译、预期行为是什么。

7. 如何创建自己的 FreeRTOS 项目

方式一(推荐):基于 Demo 工程改造

步骤:
    1. 打开对应端口的 demo 工程,确认它能正常编译和运行
    2. 删除 Demo/Common/ 里的 demo 任务源文件(和你的应用无关)
    3. 精简 main() 函数,只保留两行代码(见下方示例)
    4. 确认精简后的项目仍然能编译
    5. 在此基础上添加自己的任务和逻辑

精简后的 main() 模板:

// ============================================================
// FreeRTOS 新项目的 main() 模板
// ============================================================
#include "FreeRTOS.h"
#include "task.h"
int main( void )
{
    // 第一步:初始化硬件(时钟、GPIO、串口等)
    // 这个函数由你自己实现,或者复制 demo 里的版本
    prvSetupHardware();
    // ---- 在这里创建你的应用任务 ----
    // xTaskCreate( vMyTask, "MyTask", 128, NULL, 1, NULL );
    // 第二步:启动 FreeRTOS 调度器
    // 调用之后,调度器开始运行,之后的代码正常情况下不会执行
    vTaskStartScheduler();
    // 如果执行到这里,说明堆内存不足以启动调度器
    // 增大 configTOTAL_HEAP_SIZE 可以解决
    for( ;; );
    return 0;
}

方式二:从零创建

适合不想用 demo 的情况,步骤较多:

1. 用你的工具链创建一个空项目(不含任何 FreeRTOS 文件)
2. 确认空项目能编译、下载、运行(先保证工具链本身没问题)
3. 按下表把 FreeRTOS 源文件加入项目
4. 复制对应 demo 的 FreeRTOSConfig.h 到项目目录
5. 添加三个 Include Path(见 4.3 节)
6. 从 demo 工程复制编译器设置(优化级别、警告选项等)
7. 安装必要的 FreeRTOS 中断处理程序(参考 demo 或官网文档)

需要加入的文件清单:

文件 路径 说明
tasks.c FreeRTOS/Source/ 必须
list.c FreeRTOS/Source/ 必须
queue.c FreeRTOS/Source/ 几乎必须
timers.c FreeRTOS/Source/ 按需
event_groups.c FreeRTOS/Source/ 按需
端口移植文件(全部) FreeRTOS/Source/portable/[compiler]/[arch]/ 必须
heap_n.c(选一个) FreeRTOS/Source/portable/MemMang/ V9+ 按需,V9 前必须

8. 数据类型与命名规范(Coding Style)

理解 FreeRTOS 的命名规范,能让你一眼读懂任何 API 的含义。

8.1 两个重要的特殊数据类型

TickType_t — tick 计数器类型

FreeRTOS 有一个周期性的 tick 中断,就像心跳一样驱动整个调度系统。

  • tick 计数:从启动到现在发生了多少次 tick 中断
  • tick 周期:两次 tick 中断之间的时间间隔
  • 时间指定方式:所有超时时间都用"多少个 tick"来表示
    TickType_t 就是用来存储 tick 计数和指定时间的类型:
configUSE_16_BIT_TICKS = 1 → TickType_t = uint16_t(16位,适合8/16位单片机)
configUSE_16_BIT_TICKS = 0 → TickType_t = uint32_t(32位,推荐)

两种位宽的最大可表示时间(以 1ms/tick 为例):
16位最大=216−1=65535 tick≈65 秒 \text{16位最大} = 2^{16} - 1 = 65535 \text{ tick} \approx 65 \text{ 秒} 16位最大=2161=65535 tick65 
32位最大=232−1≈4.29×109 tick≈49.7 天 \text{32位最大} = 2^{32} - 1 \approx 4.29 \times 10^9 \text{ tick} \approx 49.7 \text{ 天} 32位最大=23214.29×109 tick49.7 
在 32 位架构上没有理由使用 16 位,32 位 tick 范围更大且效率相同。

BaseType_t — 平台最高效整数类型
32 位架构 → BaseType_t = 32 位整数
16 位架构 → BaseType_t = 16 位整数
8  位架构 → BaseType_t = 8  位整数

用于:返回值、布尔值(pdTRUE/pdFALSE)、小范围整数。这样能保证在任何平台上都使用最高效的原生整数类型。
FreeRTOS 从不使用裸 int,因为 int 的大小在不同平台上不一致。

8.2 变量命名前缀规则

单字母前缀(类型):
  c  → char
  s  → int16_t(short)
  l  → int32_t(long)
  x  → BaseType_t 或其他非标准类型(结构体、句柄等)
修饰前缀:
  u  → unsigned(无符号)
  p  → pointer(指针)
组合示例:
  uc → unsigned char(uint8_t)
  pc → pointer to char(字符串指针)
  ux → unsigned BaseType_t
  px → BaseType_t 类型的指针,或其他指针
  xHigherPriorityTaskWoken → x 类型(BaseType_t)的普通变量
  pxHigherPriorityTaskWoken → x 类型的指针变量

实际例子

uint8_t  ucRxData[ 20 ];           // uc = unsigned char
char    *pcStringToSend;            // pc = pointer to char
BaseType_t xHigherPriorityTaskWoken;// x  = BaseType_t
BaseType_t *pxHigherPriorityTaskWoken; // px = 指向 BaseType_t 的指针

8.3 函数命名规则

格式:[返回类型前缀][文件名前缀]FunctionName()
返回类型前缀:
  v   → void(无返回值)
  x   → BaseType_t 或其他类型
  ux  → unsigned BaseType_t
  pv  → pointer to void(void 指针)
  prv → 私有函数(文件内部使用,外部不可见)
文件名前缀(驼峰形式):
  Task        → task.c
  Queue       → queue.c
  Timer       → timers.c
  Semaphore   → semphr.h(信号量 API 实际上是宏)

解析示例

vTaskPrioritySet()
│    │
│    └── Task → 定义在 task.c
└── v → 返回 void
xQueueReceive()
│    │
│    └── Queue → 定义在 queue.c
└── x → 返回 BaseType_t
pvTimerGetTimerID()
│    │
│    └── Timer → 定义在 timers.c
└── pv → 返回 pointer to void(void*)
prvCheckTasksWaitingTermination()
│
└── prv → 私有函数,外部不可调用

8.4 宏命名规则

大多数宏全部大写,前缀(小写)标识定义位置:

前缀 定义位置 示例
port portable.hportmacro.h portMAX_DELAY
task task.h taskENTER_CRITICAL()
pd projdefs.h pdTRUE, pdFALSE
config FreeRTOSConfig.h configUSE_PREEMPTION
err projdefs.h errQUEUE_FULL

最常用的四个宏

宏名 数值 含义
pdTRUE 1
pdFALSE 0
pdPASS 1 操作成功
pdFAIL 0 操作失败

注意:信号量 API 虽然是宏实现的,但命名遵循函数命名规则(如 xSemaphoreTake()),而不是宏命名规则(不是全大写)。

8.5 为什么代码里有大量强制类型转换?

FreeRTOS 需要用十几种不同的编译器编译,每个编译器对"什么情况下必须显式转换"的要求不同。为了在所有编译器上都不产生警告,源码里的类型转换比通常情况下要多。
这是正常现象,不是代码质量问题。

9. 从零理解整个项目结构

下面用一张图把整章内容串联起来:

FreeRTOS.zip 发行包

FreeRTOS/Source/内核源码 只有一份

FreeRTOS/Demo/各平台 demo 工程

FreeRTOS-Plus/生态组件

公共文件tasks.c list.c queue.c

可选文件timers.c event_groups.c

portable/端口专属文件

MemMang/heap_1~5.c

compiler/architecture/移植层代码

你的目标平台 demo

FreeRTOSConfig.h复制并修改

main.c删除 demo 任务 只保留骨架

你的应用工程

10. 一个完整的"从 Demo 改造"示例

下面展示改造后的最小可运行工程结构(以 ARM Cortex-M3 + GCC 为例):

// ============================================================
// main.c — 从 Demo 改造后的最小 FreeRTOS 工程
// 演示创建两个任务通过队列通信
// ============================================================
// 必须最先包含 FreeRTOS.h
#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"
#include 
// 队列句柄(用 x 前缀,因为是非标准类型)
static QueueHandle_t xQueue = NULL;
// ---- 发送任务:每隔 500ms 往队列发一个数字 ----
// 参数命名:pv 前缀 = pointer to void(void 指针参数)
static void vSenderTask( void *pvParameters )
{
    int32_t lValueToSend;   // l 前缀 = int32_t(long)
    BaseType_t xStatus;     // x 前缀 = BaseType_t
    lValueToSend = ( int32_t ) pvParameters; // 把参数当数字用
    for( ;; )
    {
        // 往队列发送数据,等待最多 0ms(不阻塞)
        xStatus = xQueueSendToBack( xQueue, &lValueToSend, 0 );
        if( xStatus != pdPASS )
        {
            // 发送失败,队列满了
        }
        // 延时 500ms(pdMS_TO_TICKS 把毫秒转为 tick 数)
        vTaskDelay( pdMS_TO_TICKS( 500 ) );
    }
}
// ---- 接收任务:每隔 100ms 从队列取数据 ----
static void vReceiverTask( void *pvParameters )
{
    int32_t lReceivedValue;     // l 前缀 = int32_t
    BaseType_t xStatus;         // x 前缀 = BaseType_t
    // 100ms 转为 tick 数
    const TickType_t xTicksToWait = pdMS_TO_TICKS( 100 );
    for( ;; )
    {
        // 从队列取数据,等待最多 100ms
        xStatus = xQueueReceive( xQueue, &lReceivedValue, xTicksToWait );
        if( xStatus == pdPASS )
        {
            // 成功收到数据 lReceivedValue,在这里处理...
        }
        else
        {
            // 100ms 内没有数据,队列为空
        }
    }
}
int main( void )
{
    // 步骤1:初始化硬件(时钟配置、外设初始化等)
    // prvSetupHardware();  // 这个函数从 demo 的 main.c 复制过来
    // 步骤2:创建队列(容纳 5 个 int32_t 元素)
    xQueue = xQueueCreate( 5, sizeof( int32_t ) );
    if( xQueue != NULL )
    {
        // 步骤3:创建发送任务(优先级 1,把数值 100 作为参数传入)
        xTaskCreate(
            vSenderTask,            // 任务函数
            "Sender",               // 任务名(调试用,不超过 configMAX_TASK_NAME_LEN)
            128,                    // 栈大小(单位:word,128 word = 512 bytes)
            ( void * ) 100,         // 任务参数(pvParameters)
            1,                      // 优先级(0 最低)
            NULL                    // 任务句柄(不需要则传 NULL)
        );
        // 步骤4:创建接收任务(优先级 2,比发送任务优先级高)
        xTaskCreate(
            vReceiverTask,
            "Receiver",
            128,
            NULL,
            2,                      // 优先级 2 > 优先级 1
            NULL
        );
        // 步骤5:启动调度器,从这里开始 FreeRTOS 接管控制权
        vTaskStartScheduler();
    }
    // 正常情况下永远不会到这里
    // 如果到了这里,说明 configTOTAL_HEAP_SIZE 太小,堆内存不足
    for( ;; );
    return 0;
}

FreeRTOS 堆内存管理 — 从零理解

一、基础概念铺垫

什么是栈(Stack)和堆(Heap)?

内存空间示意图(程序运行时):
高地址
┌─────────────────┐
│      栈 Stack   │  ← 函数调用、局部变量,自动管理,有大小限制
│   (向下增长)   │
├─────────────────┤
│       ↓         │
│    (空闲区)   │
│       ↑         │
├─────────────────┤
│      堆 Heap    │  ← 程序员手动申请/释放,灵活但需要管理
├─────────────────┤
│    全局/静态区   │
├─────────────────┤
│      代码区      │
└─────────────────┘
低地址
  • :自动管理,函数进入时分配,函数退出时释放,速度快但空间有限
  • :手动管理,用 malloc() 申请,用 free() 释放,灵活但可能出问题

为什么嵌入式系统不直接用标准 malloc/free?

标准库的 malloc() / free() 有以下问题:

问题 说明
不可用 某些小型嵌入式系统根本没有标准库
体积大 实现代码占用宝贵的 Flash 空间
非线程安全 多任务同时调用可能出错
不确定性 每次调用耗时不同,实时系统无法接受
内存碎片 多次申请/释放后,内存变得零散
调试困难 堆溢出会破坏其他变量,极难排查

FreeRTOS 的解决方案

FreeRTOS 不直接调用 malloc()/free(),而是用自己的接口:

// FreeRTOS 内部申请内存(替代 malloc)
void* pvPortMalloc(size_t xSize);
// FreeRTOS 内部释放内存(替代 free)
void vPortFree(void* pv);

这两个函数有 5 种不同的实现,分别对应不同的使用场景。

二、五种内存分配方案详解

总览对比


方案 文件 可释放 碎片风险 确定性 适用场景
heap_1 heap_1.c 完全确定 只创建不删除的系统
heap_2 heap_2.c 中等 不确定 固定大小反复创建/删除
heap_3 heap_3.c 依赖系统 不确定 已有 malloc/free 的环境
heap_4 heap_4.c 不确定 通用推荐方案
heap_5 heap_5.c 不确定 内存不连续的复杂硬件

2.1 Heap_1 — 只进不出的简单分配

核心思想

用一个大数组,每次分配就往后移动指针,永远不释放

configTOTAL_HEAP_SIZE = 整个数组大小
初始状态 A:
┌─────────────────────────────────────────┐
│              全部空闲                    │
└─────────────────────────────────────────┘
 ^
 下一次分配从这里开始
创建第1个任务后 B:
┌──────────┬──────────┬──────────────────┐
│  TCB_1   │ Stack_1  │     空闲          │
└──────────┴──────────┴──────────────────┘
                        ^
                        指针向右移动
创建第3个任务后 C:
┌──────┬───────┬──────┬───────┬──────┬───────┬────────┐
│TCB_1 │Stack_1│TCB_2 │Stack_2│TCB_3 │Stack_3│  空闲   │
└──────┴───────┴──────┴───────┴──────┴───────┴────────┘

TCB(Task Control Block)= 任务控制块,保存任务的状态信息(优先级、堆栈指针等)

特点
  • 实现极简,内存永不碎片化
  • 完全确定性(每次分配时间相同)
  • 没有 vPortFree 实现,无法释放内存
  • 适合安全关键系统(航空、医疗):分配一次,运行终身
配置方式
// 在 FreeRTOSConfig.h 中设置堆的总大小
#define configTOTAL_HEAP_SIZE  ( 10 * 1024 )  // 10KB 堆空间

2.2 Heap_2 — 最佳适配,但不合并碎片

核心思想

同样用大数组,但支持释放,使用最佳适配算法(Best Fit):
找到大小最接近需求的空闲块来分配。

最佳适配算法示例
堆中有三个空闲块:
┌────┐  ┌──────────────────────┐  ┌──────────────────────────────────────────────┐
│ 5B │  │         25B          │  │                    100B                       │
└────┘  └──────────────────────┘  └──────────────────────────────────────────────┘
申请 20B:最接近 20B 的是 25B 那块 → 把 25B 拆成 20B + 5B
┌────┐  ┌─────────────────┐ ┌──┐  ┌──────────────────────────────────────────────┐
│ 5B │  │      20B(已用)   │ │5B│  │                    100B                       │
└────┘  └─────────────────┘ └──┘  └──────────────────────────────────────────────┘
任务创建/删除/重建过程
状态 A:创建了3个任务
┌──────┬───────┬──────┬───────┬──────┬───────┬─────────────────┐
│TCB_1 │Stack_1│TCB_2 │Stack_2│TCB_3 │Stack_3│     大块空闲      │
└──────┴───────┴──────┴───────┴──────┴───────┴─────────────────┘
状态 B:删除了任务2
┌──────┬───────┬──────┬───────┬──────┬───────┬─────────────────┐
│TCB_1 │Stack_1│ 空闲 │ 空闲  │TCB_3 │Stack_3│     大块空闲      │
└──────┴───────┴──────┴───────┴──────┴───────┴─────────────────┘
                 ^TCB_2^  ^Stack_2^ 释放后变成两个独立空闲块!
状态 C:创建新任务4(大小和任务2一样)
┌──────┬───────┬──────┬───────┬──────┬───────┬─────────────────┐
│TCB_1 │Stack_1│TCB_4 │Stack_4│TCB_3 │Stack_3│     大块空闲      │
└──────┴───────┴──────┴───────┴──────┴───────┴─────────────────┘
         最佳适配算法把 TCB_2 的空位给 TCB_4,把 Stack_2 的空位给 Stack_4
缺点:不合并相邻空闲块
如果申请的内存大小一直变化,碎片会越来越多:
┌──┬──────────┬──┬──────────┬──┬──────────┬──┐
│用│  10B空闲  │用│  10B空闲  │用│  10B空闲  │用│
└──┴──────────┴──┴──────────┴──┴──────────┴──┘
申请 25B 失败!虽然总空闲 = 30B,但没有连续的 25B 空间

内存碎片(Fragmentation):空闲内存总量够,但全是小块,无法满足大块申请。

2.3 Heap_3 — 直接包装标准库

核心思想

直接调用系统的 malloc() / free(),但在调用前后暂停 FreeRTOS 调度器,确保线程安全。

// heap_3 的本质逻辑(伪代码):
void* pvPortMalloc(size_t size) {
    vTaskSuspendAll();     // 暂停调度器,防止其他任务打断
    void* p = malloc(size); // 调用标准库
    xTaskResumeAll();      // 恢复调度器
    return p;
}
void vPortFree(void* p) {
    vTaskSuspendAll();
    free(p);
    xTaskResumeAll();
}
特点
  • 堆大小由链接器脚本决定,configTOTAL_HEAP_SIZE 无效
  • 性能、碎片特性完全依赖系统 malloc 实现
  • 适合:已有成熟 malloc 实现,且不想重新实现的项目

2.4 Heap_4 — 首次适配 + 合并碎片(推荐)

核心思想

用大数组,使用首次适配算法(First Fit):找到第一个足够大的空闲块。
最重要的是:释放内存时,自动合并相邻的空闲块(Coalescence)。

首次适配算法示例
堆中有三个空闲块(按地址顺序):
┌────┐  ┌──────────────────────────────────────────────────────────┐  ┌────────────────────────┐
│ 5B │  │                       200B                               │  │          100B           │
└────┘  └──────────────────────────────────────────────────────────┘  └────────────────────────┘
申请 20B:第一个"装得下 20B"的块是 200B 那块 → 拆成 20B + 180B
┌────┐  ┌──────────┐ ┌──────────────────────────────────────────┐  ┌──────────────────────────┐
│ 5B │  │ 20B(已用) │ │                  180B                    │  │           100B            │
└────┘  └──────────┘ └──────────────────────────────────────────┘  └──────────────────────────┘
合并碎片过程(heap_4 独特优势)
状态 A:三个任务都存在
┌──────┬───────┬──────┬───────┬──────┬───────┬─────────────────┐
│TCB_1 │Stack_1│TCB_2 │Stack_2│TCB_3 │Stack_3│     大块空闲      │
└──────┴───────┴──────┴───────┴──────┴───────┴─────────────────┘
状态 B:删除任务2(heap_2 是两个小块,heap_4 自动合并!)
┌──────┬───────┬──────────────────────┬──────┬───────┬──────────┐
│TCB_1 │Stack_1│      合并后的大空闲块   │TCB_3 │Stack_3│  大块空闲 │
└──────┴───────┴──────────────────────┴──────┴───────┴──────────┘
                 TCB_2 空间 + Stack_2 空间 → 合并为一整块
状态 C:分配队列(Queue)占用部分合并空间
┌──────┬───────┬───────┬──────┬──────┬───────┬──────────────────┐
│TCB_1 │Stack_1│ Queue │ 剩余 │TCB_3 │Stack_3│      大块空闲      │
└──────┴───────┴───────┴──────┴──────┴───────┴──────────────────┘
状态 D:用户代码直接申请一小块内存
┌──────┬───────┬───────┬──────┬───────┬──────┬───────┬──────────┐
│TCB_1 │Stack_1│ Queue │ User │ 剩余  │TCB_3 │Stack_3│  大块空闲 │
└──────┴───────┴───────┴──────┴───────┴──────┴───────┴──────────┘
状态 E:删除队列(Queue 两侧变成空闲)
┌──────┬───────┬──────┬──────┬──────┬──────┬───────┬──────────┐
│TCB_1 │Stack_1│ 空闲 │ User │ 空闲 │TCB_3 │Stack_3│  大块空闲 │
└──────┴───────┴──────┴──────┴──────┴──────┴───────┴──────────┘
状态 F:释放用户内存(左右空闲块全部合并)
┌──────┬───────┬─────────────────────┬──────┬───────┬──────────┐
│TCB_1 │Stack_1│    大空闲(合并后)   │TCB_3 │Stack_3│  大块空闲 │
└──────┴───────┴─────────────────────┴──────┴───────┴──────────┘
配置堆的起始地址(高级)

默认情况下,heap_4 的数组由链接器自动放置。如果需要手动指定位置(比如放在快速的内部 RAM):

// 在 FreeRTOSConfig.h 中开启手动配置
#define configAPPLICATION_ALLOCATED_HEAP  1
// 然后在某个 .c 文件中声明数组并指定位置:
// GCC 语法:放到名为 .my_heap 的内存段
uint8_t ucHeap[ configTOTAL_HEAP_SIZE ] __attribute__ ( ( section( ".my_heap" ) ) );
// IAR 语法:放到绝对地址 0x20000000
uint8_t ucHeap[ configTOTAL_HEAP_SIZE ] @ 0x20000000;

2.5 Heap_5 — 多块不连续内存的管理

核心思想

heap_4 只能管理一整块连续内存。但有些芯片的 RAM 分布在地址空间的不同位置(不连续),这时就需要 heap_5。

某芯片内存分布示意图:
0xFFFFFFFF ┌───────────────┐
           │               │(其他外设寄存器等)
0x037FFF   ├───────────────┤
           │  RAM3 (32KB)  │  ← 第三块 RAM
0x030000   ├───────────────┤
           │               │
0x027FFF   ├───────────────┤
           │  RAM2 (32KB)  │  ← 第二块 RAM
0x020000   ├───────────────┤
           │               │
0x01FFFF   ├───────────────┤
           │  RAM1 (65KB)  │  ← 第一块 RAM
0x010000   ├───────────────┤
           │               │
0x000000   └───────────────┘

三块 RAM 不连续,heap_4 无法同时管理它们,heap_5 可以!

使用步骤

第一步:定义每块内存区域

// ============================================================
// heap_5 使用示例 — 完整可运行代码
// ============================================================
#include <stdint.h>
#include <stddef.h>
// 假设这是硬件上三块 RAM 的起始地址和大小
#define RAM1_START_ADDRESS   ( (uint8_t*) 0x00010000 )
#define RAM1_SIZE            ( 65 * 1024 )   // 65KB
#define RAM2_START_ADDRESS   ( (uint8_t*) 0x00020000 )
#define RAM2_SIZE            ( 32 * 1024 )   // 32KB
#define RAM3_START_ADDRESS   ( (uint8_t*) 0x00030000 )
#define RAM3_SIZE            ( 32 * 1024 )   // 32KB

第二步:构建 HeapRegion_t 数组

// HeapRegion_t 结构体定义(FreeRTOS 内部定义,这里展示其含义)
typedef struct HeapRegion {
    uint8_t* pucStartAddress;  // 这块内存的起始地址
    size_t   xSizeInBytes;     // 这块内存的字节数
} HeapRegion_t;
// 必须按起始地址从小到大排列,最后一项用 NULL 结尾
const HeapRegion_t xHeapRegions[] = {
    { RAM1_START_ADDRESS, RAM1_SIZE },  // 第一块,地址最小
    { RAM2_START_ADDRESS, RAM2_SIZE },  // 第二块
    { RAM3_START_ADDRESS, RAM3_SIZE },  // 第三块,地址最大
    { NULL, 0 }                         // 结束标志,必须有!
};

第三步:初始化 heap_5(必须在创建任何内核对象之前!)

int main(void) {
    // heap_5 是唯一需要显式初始化的方案
    // 必须在 xTaskCreate、xQueueCreate 等之前调用!
    vPortDefineHeapRegions( xHeapRegions );
    // 之后才能创建任务、队列等...
    // xTaskCreate(...);
    // 启动调度器
    // vTaskStartScheduler();
    return 0;
}
更好的做法:用变量代替硬编码地址

直接写死地址 0x0001nnnn 有个问题:链接器每次编译后,变量占用的空间可能不同,如果堆的起始地址没更新,就会覆盖正常变量
推荐做法:声明一个普通数组,让链接器自动安排它的位置:

// ============================================================
// 推荐写法:用数组变量,避免硬编码地址
// ============================================================
#include <stdint.h>
#include <stddef.h>
// 只手动处理 RAM2 和 RAM3(RAM1 交给链接器管理)
#define RAM2_START_ADDRESS   ( (uint8_t*) 0x00020000 )
#define RAM2_SIZE            ( 32 * 1024 )
#define RAM3_START_ADDRESS   ( (uint8_t*) 0x00030000 )
#define RAM3_SIZE            ( 32 * 1024 )
// 在 RAM1 中声明一个数组作为 heap_5 管理的那部分
// 链接器会自动把它放到 RAM1 中某个合法的地址
#define RAM1_HEAP_SIZE ( 30 * 1024 )  // 30KB 给堆,剩余给其他变量
static uint8_t ucHeap[ RAM1_HEAP_SIZE ];  // 链接器自动放置
// ucHeap 的地址由链接器决定,永远不会和其他变量冲突!
const HeapRegion_t xHeapRegions[] = {
    { ucHeap,             RAM1_HEAP_SIZE },  // RAM1 的堆部分(地址自动)
    { RAM2_START_ADDRESS, RAM2_SIZE      },  // 全部 RAM2
    { RAM3_START_ADDRESS, RAM3_SIZE      },  // 全部 RAM3
    { NULL, 0 }
};
int main(void) {
    vPortDefineHeapRegions( xHeapRegions );
    // ... 其余代码
    return 0;
}

为什么推荐这种写法?

对比点 硬编码地址 用 ucHeap 数组
地址计算 手动,容易出错 链接器自动计算
编译后地址变化 需要手动更新 自动正确
内存重叠风险 无(链接器会报错)
数组太大 悄悄覆盖数据 链接失败(有保护)

三、实用工具函数

3.1 查询当前空闲堆大小

// 函数原型
size_t xPortGetFreeHeapSize( void );
// 使用示例
#include <stdio.h>
void vSomeTask(void* pvParameters) {
    // 创建完所有内核对象后,查询剩余空闲堆
    size_t freeBytes = xPortGetFreeHeapSize();
    printf("当前空闲堆: %zu 字节\n", freeBytes);
    // 如果返回 2000,说明 configTOTAL_HEAP_SIZE 可以减少 2000 字节
    // 这样可以节省 RAM
    while(1) { /* 任务主循环 */ }
}

注意:heap_3 不提供此函数(因为堆由系统 malloc 管理,FreeRTOS 不知道剩余多少)。

3.2 查询历史最小空闲堆大小

// 函数原型
size_t xPortGetMinimumEverFreeHeapSize( void );
// 使用示例
void vCheckHeapUsage(void) {
    size_t minFree = xPortGetMinimumEverFreeHeapSize();
    printf("历史最低空闲堆: %zu 字节\n", minFree);
    // 例如返回 200,说明系统曾经只剩 200 字节可用
    // 这非常危险,需要增大 configTOTAL_HEAP_SIZE
    if (minFree < 512) {
        printf("警告:堆空间曾经接近耗尽!\n");
    }
}

注意:只有 heap_4 和 heap_5 提供此函数。

3.3 内存申请失败钩子函数

pvPortMalloc() 返回 NULL(内存不足时),可以自动调用一个回调函数:

// 第一步:在 FreeRTOSConfig.h 中开启
#define configUSE_MALLOC_FAILED_HOOK  1
// 第二步:在你的代码中实现这个固定名字的函数
void vApplicationMallocFailedHook( void ) {
    // 内存申请失败时会自动调用这里
    // 方案1:记录错误日志
    printf("严重错误:堆内存申请失败!系统停止。\n");
    // 方案2:让系统进入安全停止状态
    taskDISABLE_INTERRUPTS();
    for(;;) {
        // 死循环,等待看门狗复位
    }
}

内存申请失败的完整流程:

调用 pvPortMalloc 申请内存

堆中有足够空间?

分配成功,返回指针

返回 NULL

configUSE_MALLOC_FAILED_HOOK == 1?

调用 vApplicationMallocFailedHook

调用者收到 NULL,需自行处理

用户自定义错误处理

内核对象创建失败,返回错误码

四、如何选择合适的方案?

永远不删除

会删除

是,直接用

否,自己管理

连续

大小固定

大小变化

不连续

我需要哪种 heap?

系统运行后
会删除任务/队列吗?

heap_1
最简单安全

环境已有
标准 malloc?

heap_3
包装标准库

硬件 RAM
是连续的吗?

申请大小
是否固定?

heap_2
或 heap_4

heap_4
推荐通用选择

heap_5
多块内存管理

五、完整示例代码(heap_4 典型用法)

// ============================================================
// FreeRTOS heap_4 完整使用示例
// 演示:任务创建、内存查询、内存申请失败处理
// ============================================================
#include <stdint.h>
#include <stddef.h>
#include <stdio.h>
// -- FreeRTOS 相关头文件(实际使用时取消注释)--
// #include "FreeRTOS.h"
// #include "task.h"
// 模拟 FreeRTOS 配置(实际在 FreeRTOSConfig.h 中定义)
#define configTOTAL_HEAP_SIZE      ( 10 * 1024 )  // 10KB 堆空间
#define configUSE_MALLOC_FAILED_HOOK  1            // 开启失败钩子
// ============================================================
// 内存申请失败钩子(固定函数名,不能改)
// ============================================================
void vApplicationMallocFailedHook( void ) {
    // 实际项目中可以:
    // 1. 触发断言
    // 2. 写入错误日志到 Flash
    // 3. 进入安全模式等待看门狗
    printf("[错误] 堆内存申请失败!系统停止。\n");
    for(;;) {}  // 等待看门狗复位
}
// ============================================================
// 示例任务1:传感器数据采集
// ============================================================
void vSensorTask( void* pvParameters ) {
    printf("[传感器任务] 启动\n");
    // 查询当前剩余堆空间
    // size_t freeHeap = xPortGetFreeHeapSize();
    // printf("[传感器任务] 当前剩余堆: %zu 字节\n", freeHeap);
    while(1) {
        // 采集传感器数据...
        // vTaskDelay( pdMS_TO_TICKS(100) );
    }
}
// ============================================================
// 示例任务2:数据处理
// ============================================================
void vProcessTask( void* pvParameters ) {
    printf("[处理任务] 启动\n");
    // 动态申请一块工作缓冲区
    // uint8_t* pBuffer = (uint8_t*) pvPortMalloc( 256 );
    // if ( pBuffer == NULL ) {
    //     // 申请失败(如果开了钩子,会先调用钩子)
    //     printf("[处理任务] 缓冲区申请失败!\n");
    //     vTaskDelete(NULL);  // 删除自身
    // }
    while(1) {
        // 处理数据...
        // vTaskDelay( pdMS_TO_TICKS(50) );
    }
    // 任务结束前释放内存
    // vPortFree( pBuffer );
    // vTaskDelete(NULL);
}
// ============================================================
// 主函数
// ============================================================
int main( void ) {
    printf("FreeRTOS heap_4 示例启动\n");
    printf("总堆大小: %d 字节\n", configTOTAL_HEAP_SIZE);
    // 创建任务(实际代码):
    // xTaskCreate(
    //     vSensorTask,    // 任务函数
    //     "Sensor",       // 任务名称(调试用)
    //     128,            // 栈大小(单位:字,不是字节!)
    //     NULL,           // 传给任务的参数
    //     2,              // 优先级(数字越大越高)
    //     NULL            // 任务句柄(不需要可以填 NULL)
    // );
    // xTaskCreate( vProcessTask, "Process", 256, NULL, 1, NULL );
    // 所有任务创建完毕后,查询堆使用情况
    // size_t remaining = xPortGetFreeHeapSize();
    // printf("任务创建后剩余堆: %zu 字节\n", remaining);
    // 如果 remaining 很大,可以缩小 configTOTAL_HEAP_SIZE 节省 RAM
    // 启动调度器(之后 main 函数不再返回)
    // vTaskStartScheduler();
    // 正常情况下不会到达这里
    // 如果到达这里,说明堆空间不足以创建 idle 任务
    printf("错误:调度器启动失败(堆空间可能不足)\n");
    return -1;
}

六、关键知识点总结

内存碎片的直觉理解

把堆想象成一条停车场:

满载状态:
[车A][车B][车C][车D][车E][车F]
车B、车D、车F 离开后:
[车A][空2格][车C][空2格][车E][空2格]
现在来了一辆需要4个格子的大车:
停不下!虽然总空格 = 6,但没有连续的4格!
  • heap_2:不合并,碎片越来越多
  • heap_4:自动合并相邻空位,大车能停进来

确定性的含义

确定性(Deterministic)= 每次调用耗时相同,可以预测。
Talloc=常数T_{alloc} = \text{常数}Talloc=常数
实时系统(如汽车刹车控制、心脏起搏器)要求操作必须在固定时间内完成,所以:

  • heap_1:完全确定性,适合安全关键系统
  • heap_2/heap_4:不确定,但比标准 malloc 快很多

FreeRTOS 任务管理 — 从零理解

一、什么是任务(Task)?

在 FreeRTOS 里,任务就是一个独立运行的小程序。你可以把它想象成:

普通程序:main() 从头到尾执行一次就结束
FreeRTOS:
  任务1 ──► 永远循环,打印传感器数据
  任务2 ──► 永远循环,控制电机
  任务3 ──► 永远循环,处理网络通信
  调度器  ──► 决定"现在让谁运行"

CPU 在多个任务之间快速切换,速度快到让人感觉它们在"同时"运行。

二、任务函数的写法

固定格式

FreeRTOS 任务函数有且只有一种原型:

// 任务函数原型:返回值必须是 void,参数必须是 void*
void 任务函数名( void *pvParameters );

典型任务结构

#include <stdint.h>
// ============================================================
// 典型任务函数结构(含详细注释)
// ============================================================
void ATaskFunction( void *pvParameters )
{
    // 局部变量:每个任务实例都有自己独立的一份
    // (如果声明为 static,则所有实例共享同一份——通常不要这样做)
    int32_t lVariableExample = 0;
    // 任务必须是无限循环,永远不能 return 或执行到函数末尾
    for( ;; )  // 等同于 while(1)
    {
        // ── 在这里放任务要做的事情 ──
        lVariableExample++;
    }
    // 正常情况下永远不会到达这里
    // 如果因某种原因需要退出循环,必须显式删除自身,而不是 return
    vTaskDelete( NULL );  // NULL 表示删除"调用此函数的任务"(即自身)
}

为什么不能 return?
任务函数返回后,CPU 不知道该跳到哪里,会导致系统崩溃。必须用 vTaskDelete(NULL) 正确退出。

三、任务的状态机

3.1 简化版(两个状态)

┌─────────────┐     切换进来(Switch In)      ┌─────────────┐
│  未运行      │ ─────────────────────────►  │   运行中     │
│ Not Running  │ ◄─────────────────────────  │   Running    │
└─────────────┘     切换出去(Switch Out)      └─────────────┘
单核处理器:同一时刻只有 1 个任务处于 Running 状态
其余所有任务:都在 Not Running 状态

3.2 完整版(四个状态)

xTaskCreate()创建任务

调度器选中(最高优先级)

被更高优先级任务抢占
或时间片用完

调用阻塞API
(vTaskDelay/等待队列等)

等待的事件发生
或超时时间到

vTaskSuspend()

vTaskSuspend()

vTaskSuspend()

vTaskResume()

就绪Ready

运行Running

阻塞Blocked

挂起Suspended


状态 说明 类比
Running(运行中) CPU 正在执行该任务的代码 正在台上表演的演员
Ready(就绪) 随时可以运行,等待调度器选中 在后台等待出场的演员
Blocked(阻塞) 在等待某件事(延时/数据/信号量) 演员在等道具,暂时下场
Suspended(挂起) 被强制暂停,调度器完全忽略它 演员请假,不参与演出

四、创建任务:xTaskCreate()

函数原型

BaseType_t xTaskCreate(
    TaskFunction_t  pvTaskCode,     // 任务函数指针
    const char*     pcName,         // 任务名称(仅用于调试)
    uint16_t        usStackDepth,   // 栈大小(单位:字,不是字节!)
    void*           pvParameters,   // 传给任务的参数
    UBaseType_t     uxPriority,     // 优先级(0最低)
    TaskHandle_t*   pxCreatedTask   // 任务句柄(不需要可填NULL)
);

参数 说明 注意事项
pvTaskCode 任务函数的名字(函数指针) 不要加括号,写函数名本身
pcName 任意字符串,调试时看的 超过 configMAX_TASK_NAME_LEN 会被截断
usStackDepth 栈深度,单位是"字"(word) 32位系统:100字 = 400字节
pvParameters 传入任务的参数,任意数据转成 void* 可传字符串、结构体指针等
uxPriority 0 到 (configMAX_PRIORITIES-1) 数字越大优先级越高
pxCreatedTask 返回任务句柄,用于后续操作 不需要时填 NULL

返回值:pdPASS 表示成功,pdFAIL 表示堆内存不足创建失败。

栈大小的理解

栈大小的计算:
实际字节数=usStackDepth×字的字节数\text{实际字节数} = \text{usStackDepth} \times \text{字的字节数}实际字节数=usStackDepth×字的字节数
在 32 位 ARM 上,一个字 = 4 字节,所以:
usStackDepth=100⇒100×4=400 字节\text{usStackDepth} = 100 \Rightarrow 100 \times 4 = 400 \text{ 字节}usStackDepth=100100×4=400 字节

五、完整示例代码

示例1:创建两个独立任务

// ============================================================
// 示例1:创建两个任务,各自打印不同字符串
// 对应书中 Example 1
// ============================================================
#include <stdint.h>
#include <stdio.h>
// -- 实际使用时取消注释 --
// #include "FreeRTOS.h"
// #include "task.h"
// 模拟 vPrintString(实际是 FreeRTOS 提供的线程安全打印)
void vPrintString(const char* str) {
    printf("%s", str);
}
// 空循环延时计数(实际项目应使用 vTaskDelay)
#define mainDELAY_LOOP_COUNT  (1000000UL)
// ─── 任务1 ────────────────────────────────────────────────
void vTask1( void *pvParameters )
{
    const char *pcTaskName = "Task 1 is running\r\n";
    volatile uint32_t ul;  // volatile 防止编译器优化掉这个循环
    for( ;; )
    {
        vPrintString( pcTaskName );
        // 粗糙的延时:空转循环(不推荐,仅演示用)
        // 缺点:CPU 100% 占用,浪费处理器资源
        for( ul = 0; ul < mainDELAY_LOOP_COUNT; ul++ )
        {
            // 什么也不做,纯粹在消磨时间
        }
    }
}
// ─── 任务2 ────────────────────────────────────────────────
void vTask2( void *pvParameters )
{
    const char *pcTaskName = "Task 2 is running\r\n";
    volatile uint32_t ul;
    for( ;; )
    {
        vPrintString( pcTaskName );
        for( ul = 0; ul < mainDELAY_LOOP_COUNT; ul++ )
        {
        }
    }
}
// ─── 主函数 ───────────────────────────────────────────────
int main( void )
{
    // 创建任务1:优先级1,不使用参数,不需要句柄
    xTaskCreate(
        vTask1,     // 任务函数
        "Task 1",   // 调试用名称
        1000,       // 栈深度(1000字 = 4000字节,对小单片机来说通常太大)
        NULL,       // 不传参数
        1,          // 优先级 1
        NULL        // 不需要句柄
    );
    // 创建任务2:同样优先级1
    xTaskCreate( vTask2, "Task 2", 1000, NULL, 1, NULL );
    // 启动调度器 —— 之后 main() 不会返回(除非堆内存不足)
    vTaskStartScheduler();
    // 如果执行到这里,说明调度器启动失败(通常是堆不够用)
    for( ;; );
    return 0;
}

执行时序图:两个相同优先级的任务轮流运行

时间 ──────────────────────────────────────────────►
Task1  ████████░░░░░░░░████████░░░░░░░░████████
Task2  ░░░░░░░░████████░░░░░░░░████████░░░░░░░░
       t1      t2      t3      t4      t5      t6
       ^       ^       ^
       |       |       +── Tick中断触发,调度器切换任务
       |       +────────── Task2开始运行
       +────────────────── Task1开始运行
█ = 正在运行    ░ = 未运行(就绪状态)

两个任务优先级相同,调度器用时间片轮转(Round Robin)让它们交替运行。

示例2:用参数复用同一个任务函数

// ============================================================
// 示例2:一个任务函数,创建两个实例,通过参数区分
// 对应书中 Example 2
// ============================================================
#include <stdint.h>
#include <stdio.h>
// 两个任务实例各自要打印的字符串
// 必须是 static 或全局变量,确保任务运行期间字符串一直有效
static const char *pcTextForTask1 = "Task 1 is running\r\n";
static const char *pcTextForTask2 = "Task 2 is running\r\n";
// 一个函数,两种用法
void vTaskFunction( void *pvParameters )
{
    char *pcTaskName;
    volatile uint32_t ul;
    // pvParameters 是 void*,强制转换为 char* 才能使用
    // 这就是为什么两个任务实例打印不同内容
    pcTaskName = ( char * ) pvParameters;
    for( ;; )
    {
        printf("%s", pcTaskName);  // 打印各自的字符串
        for( ul = 0; ul < 1000000UL; ul++ ) {}
    }
}
int main( void )
{
    // 创建实例1:传入字符串1作为参数
    xTaskCreate(
        vTaskFunction,              // 同一个函数
        "Task 1",
        1000,
        (void*) pcTextForTask1,     // 传入参数:Task1 的字符串
        1,
        NULL
    );
    // 创建实例2:传入字符串2作为参数
    xTaskCreate(
        vTaskFunction,              // 还是同一个函数!
        "Task 2",
        1000,
        (void*) pcTextForTask2,     // 传入参数:Task2 的字符串
        1,
        NULL
    );
    vTaskStartScheduler();
    for( ;; );
    return 0;
}

每个任务实例都有自己的栈,所以局部变量互不干扰:

内存中:
┌──────────────────────────────────────────────┐
│  Task1 的栈                                   │
│  pcTaskName ─────────────► "Task 1 is running"│
│  ul = 某个值                                  │
├──────────────────────────────────────────────┤
│  Task2 的栈                                   │
│  pcTaskName ─────────────► "Task 2 is running"│
│  ul = 另一个值                                │
└──────────────────────────────────────────────┘
同一个函数,两套独立的局部变量空间

六、任务优先级

优先级范围:000(configMAX_PRIORITIES−1)(configMAX\_PRIORITIES - 1)(configMAX_PRIORITIES1)
数字越大=优先级越高=越先被调度器选中\text{数字越大} = \text{优先级越高} = \text{越先被调度器选中}数字越大=优先级越高=越先被调度器选中

优先级不同时的现象(任务饥饿)

// 示例3:不同优先级
xTaskCreate( vTaskFunction, "Task 1", 1000, (void*)pcTextForTask1, 1, NULL );
xTaskCreate( vTaskFunction, "Task 2", 1000, (void*)pcTextForTask2, 2, NULL );
// Task2 优先级更高 → Task2 永远在 Running,Task1 永远得不到执行时间

执行时序:

时间 ──────────────────────────────────────────────►
Task2  ████████████████████████████████████████████  (优先级2,永远运行)
Task1  ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░  (优先级1,永远等待)
这种情况叫做"任务饥饿"(Task Starvation):低优先级任务被高优先级任务饿死

解决方案:让高优先级任务主动进入阻塞状态(使用 vTaskDelay)

七、Tick 中断与时间度量

Tick 是什么?

FreeRTOS 用一个周期性中断(Tick 中断)来驱动调度器。
Ttick=1configTICK_RATE_HZT_{tick} = \frac{1}{configTICK\_RATE\_HZ}Ttick=configTICK_RATE_HZ1
例如:configTICK_RATE_HZ=100⇒Ttick=10 msconfigTICK\_RATE\_HZ = 100 \Rightarrow T_{tick} = 10 \text{ ms}configTICK_RATE_HZ=100Ttick=10 ms
每次 Tick 中断触发时:

  1. 调度器检查有没有更高优先级的任务就绪
  2. 检查有没有阻塞中的任务到期了(延时到了)
  3. 决定下一个运行的任务

毫秒转换为 Tick

// 不要直接写 tick 数,使用宏转换,这样改变 configTICK_RATE_HZ 时不需要改代码
TickType_t xTimeInTicks = pdMS_TO_TICKS( 200 );
// configTICK_RATE_HZ=100时:200ms / 10ms = 20 ticks
// configTICK_RATE_HZ=1000时:200ms / 1ms = 200 ticks

Tick 中断执行时序:

时间 ──────────────────────────────────────────────►
Tick   ^         ^         ^         ^         ^
中断   |         |         |         |         |
       t1        t2        t3        t4        t5
       |←10ms→  |←10ms→  |
调度器 ▼         ▼         ▼
在每个Tick中断里短暂运行,然后让选中的任务继续
Task1  ████████░░░░░░░░████████░░░░░░░░████████
Task2  ░░░░░░░░████████░░░░░░░░████████░░░░░░░░

八、阻塞延时 vs 空转延时

方法1(差):空转延时

// 坏做法:空转循环延时
for( ul = 0; ul < 1000000; ul++ ) {}
// CPU 100% 占用,低优先级任务永远得不到执行时间

方法2(好):vTaskDelay()

// vTaskDelay 的原型
void vTaskDelay( TickType_t xTicksToDelay );
// 好做法:阻塞延时
void vTaskFunction( void *pvParameters )
{
    char *pcTaskName = (char*) pvParameters;
    // 250ms 转换为 tick 数(推荐写法)
    const TickType_t xDelay250ms = pdMS_TO_TICKS( 250 );
    for( ;; )
    {
        printf("%s", pcTaskName);
        // 调用后任务立刻进入 Blocked 状态
        // CPU 可以去运行其他任务,完全不浪费资源
        vTaskDelay( xDelay250ms );
        // 250ms 后自动回到 Ready 状态,等待调度器选中
    }
}

两种方式的对比时序:

空转延时(浪费CPU):
Task2(高) ████████████████████████████████████  ← 一直占着CPU
Task1(低) ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░  ← 永远没机会
vTaskDelay(释放CPU):
Task2(高) ██░░░░░░░░░░░░░░░░░░░██░░░░░░░░░░░░░░  ← 打印后进入阻塞
Task1(低) ░░██████░░░░░░░░░░░░░░░██████░░░░░░░░  ← 有机会运行了
Idle任务  ░░░░░░░░████████████░░░░░░░░████████░  ← CPU空闲时运行
█ = Running    ░ = 等待/阻塞

方法3(更精准):vTaskDelayUntil()

vTaskDelay 的延时是相对时间(从调用那刻开始算),如果任务执行时间有变化,周期会漂移。
vTaskDelayUntil 使用绝对时间(从上次唤醒时刻算),保证严格的固定周期:
T下次唤醒=T上次唤醒+xTimeIncrementT_{下次唤醒} = T_{上次唤醒} + xTimeIncrementT下次唤醒=T上次唤醒+xTimeIncrement

// vTaskDelayUntil 原型
void vTaskDelayUntil(
    TickType_t *pxPreviousWakeTime,  // 上次唤醒时间(自动更新)
    TickType_t  xTimeIncrement       // 周期间隔
);
void vTaskFunction( void *pvParameters )
{
    char *pcTaskName = (char*) pvParameters;
    TickType_t xLastWakeTime;
    // 初始化:记录当前时刻作为"上次唤醒时间"
    // 这是唯一需要手动写的地方,之后由 vTaskDelayUntil 自动更新
    xLastWakeTime = xTaskGetTickCount();
    for( ;; )
    {
        printf("%s", pcTaskName);
        // 严格每 250ms 运行一次,不受任务执行时间影响
        vTaskDelayUntil( &xLastWakeTime, pdMS_TO_TICKS( 250 ) );
        // xLastWakeTime 在函数内部被自动更新为下次的目标时刻
    }
}

vTaskDelay vs vTaskDelayUntil 的区别:

vTaskDelay(相对延时):
                        任务执行时间
                        ┌──────────┐
任务运行 ───────────────┤          ├────► vTaskDelay(250ms) ────► 250ms后醒来
                        └──────────┘
周期 = 任务执行时间 + 250ms  ← 周期不固定!
vTaskDelayUntil(绝对延时):
         250ms的周期
    ┌────────────────────┐
    │  任务执行  │ 睡眠  │  任务执行  │ 睡眠  │
    └────────────────────┘
周期严格 = 250ms  ← 周期固定!

九、空闲任务(Idle Task)

为什么需要空闲任务?

CPU 不能什么都不做,必须至少有一个任务可以运行。当所有用户任务都处于 Blocked 或 Suspended 状态时,空闲任务(Idle Task)自动填补空白。

  • 空闲任务由 vTaskStartScheduler() 自动创建
  • 优先级永远是 0(最低)
  • 只要有任何用户任务就绪,立即让出 CPU
    重要:如果用了 vTaskDelete(),空闲任务负责清理被删除任务的内存。所以空闲任务不能被完全饿死!

空闲钩子函数(Idle Hook)

可以在空闲任务的每次循环中自动调用一个用户函数,做一些低优先级的后台工作:

// 第一步:在 FreeRTOSConfig.h 中开启
// #define configUSE_IDLE_HOOK  1
// 第二步:实现固定名字的函数(名字不能改!)
volatile uint32_t ulIdleCycleCount = 0UL;
void vApplicationIdleHook( void )
{
    // 空闲钩子的限制:
    // 1. 绝对不能调用任何阻塞函数(不能 vTaskDelay 等)
    // 2. 如果用了 vTaskDelete,必须及时返回,不能死循环
    ulIdleCycleCount++;  // 统计 CPU 空闲周期数
}

通过 ulIdleCycleCount 可以判断系统负载

  • 两次任务执行之间,ulIdleCycleCount 增加越多,说明 CPU 越空闲
  • 增加很少,说明 CPU 很繁忙

十、动态修改优先级

// 设置某个任务的优先级
void vTaskPrioritySet(
    TaskHandle_t pxTask,        // 目标任务句柄(NULL=自身)
    UBaseType_t  uxNewPriority  // 新优先级
);
// 查询某个任务的优先级
UBaseType_t uxTaskPriorityGet(
    TaskHandle_t pxTask         // 目标任务句柄(NULL=自身)
);

优先级互换示例

// ============================================================
// 示例8:两个任务动态互换优先级
// ============================================================
#include <stdint.h>
#include <stdio.h>
// Task2 的句柄(需要在 Task1 中操作 Task2 的优先级)
TaskHandle_t xTask2Handle = NULL;
void vTask1( void *pvParameters )
{
    UBaseType_t uxPriority;
    // 查询自身当前优先级(NULL = 自身)
    uxPriority = uxTaskPriorityGet( NULL );
    for( ;; )
    {
        printf("Task 1 is running\r\n");
        printf("About to raise the Task 2 priority\r\n");
        // 把 Task2 的优先级提高到 Task1 优先级 + 1
        // 这会导致 Task2 立刻抢占 Task1(因为 Task2 优先级更高了)
        vTaskPrioritySet( xTask2Handle, ( uxPriority + 1 ) );
        // Task2 执行完并降低自己优先级后,Task1 才能继续到这里
    }
}
void vTask2( void *pvParameters )
{
    UBaseType_t uxPriority;
    uxPriority = uxTaskPriorityGet( NULL );
    for( ;; )
    {
        // 能执行到这里,说明 Task1 已经把 Task2 优先级提高了
        printf("Task 2 is running\r\n");
        printf("About to lower the Task 2 priority\r\n");
        // 降低自身优先级(NULL = 自身)
        // uxPriority - 2 会低于 Task1,Task1 立刻抢占 Task2
        vTaskPrioritySet( NULL, ( uxPriority - 2 ) );
    }
}
int main( void )
{
    // Task1 优先级2,Task2 优先级1(Task1 先运行)
    xTaskCreate( vTask1, "Task 1", 1000, NULL, 2, NULL );
    // 注意最后一个参数:保存 Task2 的句柄,Task1 需要用它操作 Task2
    xTaskCreate( vTask2, "Task 2", 1000, NULL, 1, &xTask2Handle );
    vTaskStartScheduler();
    for( ;; );
    return 0;
}

执行时序:

时间 ──────────────────────────────────────────────►
Task1  ████████░░░████████░░░████████░░░
Task2  ░░░░░░░░███░░░░░░░░███░░░░░░░░███
       t1   t2  t3  t4   t5
t1: Task1 运行(优先级2)
t2: Task1 把 Task2 优先级提到3 → Task2 立刻抢占 Task1
t3: Task2 把自己优先级降回来 → Task1 重新抢占 Task2
循环往复...

十一、删除任务

// 删除任务
void vTaskDelete( TaskHandle_t pxTaskToDelete );
// pxTaskToDelete = NULL → 删除自身
// pxTaskToDelete = 句柄 → 删除指定任务

注意:内核自动分配的内存(TCB、栈)由空闲任务负责清理。任务自己申请的内存(比如 pvPortMalloc)必须自己释放!

删除任务示例

// ============================================================
// 示例9:Task1 不断创建 Task2,Task2 创建后立刻删除自身
// ============================================================
#include <stdint.h>
#include <stdio.h>
TaskHandle_t xTask2Handle = NULL;
void vTask2( void *pvParameters )
{
    printf("Task 2 is running and about to delete itself\r\n");
    // 用自身句柄删除自己(也可以用 NULL,效果相同)
    vTaskDelete( xTask2Handle );
    // 删除后这里的代码永远不会执行
}
void vTask1( void *pvParameters )
{
    const TickType_t xDelay100ms = pdMS_TO_TICKS( 100UL );
    for( ;; )
    {
        printf("Task 1 is running\r\n");
        // 创建 Task2,优先级更高,立刻运行
        xTaskCreate( vTask2, "Task 2", 1000, NULL, 2, &xTask2Handle );
        // Task2 比 Task1 优先级高,所以 Task2 立刻运行并删除自身
        // 然后 Task1 才能继续到这里
        // 延时100ms,让空闲任务有时间清理 Task2 的内存
        vTaskDelay( xDelay100ms );
    }
}
int main( void )
{
    xTaskCreate( vTask1, "Task 1", 1000, NULL, 1, NULL );
    vTaskStartScheduler();
    for( ;; );
    return 0;
}

执行时序:

时间 ──────────────────────────────────────────────►
Task1  ██░░░░░░░░░░░░████░░░░░░░░░░░░████
Task2  ░░█░░░░░░░░░░░░░░█░░░░░░░░░░░░░░░
Idle   ░░░░████████░░░░░░░████████░░░░░░
       t1 t2       t3  t4 t5
t1: Task1 运行,创建 Task2
t2: Task2(高优先级)抢占 Task1,运行后立刻删除自身
t3: Task1 恢复,调用 vTaskDelay 进入阻塞
    空闲任务运行,清理 Task2 占用的内存
t4: Task1 阻塞到期,再次运行,循环...

十二、调度算法

三种调度配置


模式 configUSE_PREEMPTION configUSE_TIME_SLICING 说明
抢占式 + 时间片 1 1 默认推荐,最常用
抢占式 + 无时间片 1 0 高级用法,同优先级任务不轮转
协作式 0 任意值 任务必须主动让出CPU

12.1 抢占式 + 时间片(默认)

关键词解释:

  • 固定优先级(Fixed Priority):调度器不会改变任务的优先级
  • 抢占(Pre-emptive):高优先级任务就绪时,立刻打断低优先级任务
  • 时间片(Time Slicing):相同优先级的任务,每人分一个 Tick 周期,轮流运行
多优先级抢占示意图:
高优先级 Task1(事件驱动) ──────██░░░░░░░░░░░░░░████░░
中优先级 Task2(周期运行) ──████░░████░░░░░░████░░████
低优先级 Task3(事件驱动) ░░░░░░░░░░░████░░░░░░░░░░░░░
空闲任务             ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
Task1 出现时,立刻抢占 Task2 和 Task3(无论时间片)
Task2 周期到来时,立刻抢占 Task3(Task2 优先级更高)

12.2 configIDLE_SHOULD_YIELD

当有用户任务(优先级0)和空闲任务同时处于就绪状态时:

configIDLE_SHOULD_YIELD = 0(默认):
空闲任务  ████████████████░░░░░░░░████████████████
用户0级  ░░░░░░░░░░░░░░░░████████░░░░░░░░░░░░░░░░
         ← 空闲任务用完整时间片 →
configIDLE_SHOULD_YIELD = 1:
空闲任务  █░░░░░░░░░░░░░░░░░░░░░░█░░░░░░░░░░░░░░
用户0级  ░████████████████████░░░████████████████
         空闲任务每次循环完就立刻让位给用户任务

12.3 协作式调度(Co-operative)

任务不会被强制打断,只有在以下情况才切换:

  1. 任务调用阻塞函数(进入 Blocked 状态)
  2. 任务主动调用 taskYIELD() 让出 CPU
协作式调度时序:
Task1(高) ░░░░░░████████░░░░░░░░░░░░  
Task2(中) ░░████░░░░░░░░████░░░░░░░░  
Task3(低) ████░░░░░░░░░░░░░░████░░░░  
          t1  t2  t3  t4  t5  t6
t1: Task3 运行
t2: Task3 调用 taskYIELD(),让出 CPU
    此时 Task1、Task2 都已就绪,选优先级最高的 Task2
t4: Task2 写数据到队列,Task3 收到数据退出阻塞
    但 Task3 不会立刻抢占,只有 Task2 进入阻塞或 yield 才切换

协作式 vs 抢占式:

对比项 抢占式 协作式
响应速度 快(高优先级立刻运行) 慢(等当前任务主动让出)
资源共享安全性 需要互斥锁保护 相对安全(不会随意打断)
编程难度 需要考虑并发 相对简单
适用场景 实时性要求高 实时性要求低、资源受限

十三、综合流程图

pdFAIL
堆内存不足

pdPASS

调用vTaskDelay

调用vTaskDelete

一直运行

main 函数开始

调用 xTaskCreate 创建任务

创建成功?
返回 pdPASS?

错误处理

继续创建其他任务

调用 vTaskStartScheduler

调度器启动
自动创建空闲任务

哪个任务优先级最高
且处于Ready状态?

选中该任务进入Running状态

任务做什么?

任务进入Blocked状态
CPU让给其他任务

任务被删除
空闲任务清理内存

等下一个Tick中断
检查是否有高优先级任务就绪

延时到期
回到Ready状态

十四、关键 API 速查


函数 作用 需要开启的配置
xTaskCreate() 创建任务 默认可用
vTaskDelete() 删除任务 INCLUDE_vTaskDelete = 1
vTaskDelay() 相对延时(进入阻塞) INCLUDE_vTaskDelay = 1
vTaskDelayUntil() 绝对延时(固定周期) INCLUDE_vTaskDelayUntil = 1
vTaskPrioritySet() 修改任务优先级 INCLUDE_vTaskPrioritySet = 1
uxTaskPriorityGet() 查询任务优先级 INCLUDE_uxTaskPriorityGet = 1
vTaskSuspend() 挂起任务 INCLUDE_vTaskSuspend = 1
vTaskResume() 恢复挂起的任务 INCLUDE_vTaskSuspend = 1
taskYIELD() 主动让出CPU(协作式) 默认可用
xTaskGetTickCount() 获取当前Tick计数 默认可用

FreeRTOS 队列管理 — 从零理解

一、队列是什么?为什么需要它?

在多任务系统里,任务之间需要传递数据。最直观的想法是用一个全局变量,但这会带来竞争问题:

危险的全局变量做法:
Task A ──► global_value = 10  ┐
                               ├── 如果同时写,数据损坏!
Task B ──► global_value = 20  ┘

队列(Queue) 是 FreeRTOS 提供的安全通信机制,它像一个传送带:

Task A(发送方)          队列(传送带)            Task B(接收方)
                         ┌──┬──┬──┬──┬──┐
   写入数据 ──────────►  │10│20│30│  │  │  ──────────► 读取数据
                         └──┴──┴──┴──┴──┘
                          尾                头
                         (新数据从这进)     (旧数据从这出)
                              FIFO:先进先出

队列的三大特性:

  • 任意任务都可以往同一个队列写或读
  • 读写操作是线程安全的(FreeRTOS 内核保证)
  • 队列满/空时,任务可以自动阻塞等待,不浪费 CPU

二、队列的核心特性

2.1 按值拷贝(Copy by Value)

FreeRTOS 的队列把数据完整复制进去,而不是保存指针:

按值拷贝(FreeRTOS 的做法):
Task A 的栈:           队列内部存储区:
┌───────────┐           ┌───────────┐
│  x = 10   │  复制 ──► │    10     │  ← 真实数据存在队列里
└───────────┘           └───────────┘
  函数返回后 x 消失,     队列里的 10 依然有效!
  但队列不受影响
按引用(危险做法,FreeRTOS 不用):
Task A 的栈:           队列里:
┌───────────┐           ┌───────────┐
│  x = 10   │  指针 ──► │  地址0x..  │  ← 只存了地址!
└───────────┘           └───────────┘
  函数返回后 x 消失,     队列里的指针变成"野指针",读到垃圾数据!

按值拷贝的好处:

  • 栈变量可以直接发送,函数返回后数据依然有效
  • 发送方可以立刻重用变量
  • 发送方和接收方完全解耦,不用协商"谁负责释放内存"

当数据太大时,可以把指针存进队列(把指针本身拷贝进去),这是一种变通方法。

2.2 阻塞读(Blocking on Queue Reads)

读队列时,如果队列为空:

有数据

队列为空

有数据写入

超时到期

任务调用 xQueueReceive

队列有数据?

立刻取出数据
返回 pdPASS

xTicksToWait == 0?

立刻返回
errQUEUE_EMPTY

任务进入 Blocked 状态
等待数据或超时

等待期间发生了什么?

任务唤醒
取出数据 返回 pdPASS

任务唤醒
返回 errQUEUE_EMPTY

多个任务等同一个队列时:优先级最高的任务先被唤醒;优先级相同时,等待最久的先唤醒。

2.3 阻塞写(Blocking on Queue Writes)

写队列时,如果队列已满,逻辑完全对称:

队列满时的等待:
Sender 任务                队列(已满)
   │                      ┌──┬──┬──┬──┬──┐
   │ 写入 → 队列满了       │10│20│30│40│50│
   │                      └──┴──┴──┴──┴──┘
   ▼
进入 Blocked 状态,等待有空位
   │
   │(Receiver 取走一个元素)
   ▼
自动唤醒,完成写入

T等待最大时间=xTicksToWait×TtickT_{等待最大时间} = xTicksToWait \times T_{tick}T等待最大时间=xTicksToWait×Ttick

三、核心 API 函数详解

3.1 创建队列

QueueHandle_t xQueueCreate(
    UBaseType_t uxQueueLength,  // 队列最多能装多少个元素
    UBaseType_t uxItemSize      // 每个元素的字节大小
);

返回 NULL 表示堆内存不足,创建失败。

3.2 发送数据

// 发到队列尾部(最常用)
BaseType_t xQueueSendToBack(
    QueueHandle_t xQueue,       // 队列句柄
    const void*   pvItemToQueue, // 要发送的数据地址
    TickType_t    xTicksToWait  // 队列满时的最大等待时间
);
// xQueueSend() 和 xQueueSendToBack() 完全等价
// 发到队列头部(插队,优先处理)
BaseType_t xQueueSendToFront( QueueHandle_t, const void*, TickType_t );

返回值:pdPASS(成功)或 errQUEUE_FULL(超时仍满)

3.3 接收数据

BaseType_t xQueueReceive(
    QueueHandle_t xQueue,    // 队列句柄
    void*         pvBuffer,  // 接收缓冲区(数据复制到这里)
    TickType_t    xTicksToWait // 队列空时的最大等待时间
);

返回值:pdPASS(成功)或 errQUEUE_EMPTY(超时仍空)
注意xQueueReceive 会把数据从队列中取走(移除)。

3.4 查询队列中的元素数量

UBaseType_t uxQueueMessagesWaiting( QueueHandle_t xQueue );
// 返回当前队列中的元素个数,0 表示空

3.5 偷看数据(不移除)

BaseType_t xQueuePeek(
    QueueHandle_t xQueue,
    void*         pvBuffer,
    TickType_t    xTicksToWait
);
// 和 xQueueReceive 一样,但数据留在队列里不被删除

3.6 覆盖写(邮箱模式专用)

BaseType_t xQueueOverwrite(
    QueueHandle_t xQueue,
    const void*   pvItemToQueue
);
// 无论队列是否满,都直接覆盖,永远返回 pdPASS
// 只用于长度为 1 的队列(邮箱)

四、完整示例代码

示例10:接收任务优先级更高

// ============================================================
// 示例10:两个发送任务 + 一个接收任务
// 接收任务优先级(2) > 发送任务优先级(1)
// 队列里同时最多只有1个元素(接收方总是立刻取走)
// ============================================================
#include <stdint.h>
#include <stdio.h>
// -- 实际使用时取消注释 --
// #include "FreeRTOS.h"
// #include "task.h"
// #include "queue.h"
// 全局队列句柄:所有任务共享同一个队列
QueueHandle_t xQueue;
// ─── 发送任务(创建两个实例)──────────────────────────────
static void vSenderTask( void *pvParameters )
{
    int32_t lValueToSend;
    BaseType_t xStatus;
    // pvParameters 传入要发送的值(100 或 200)
    // 强制转换:void* → int32_t
    lValueToSend = ( int32_t ) pvParameters;
    for( ;; )
    {
        // 发送到队列尾部
        // 参数1:队列句柄
        // 参数2:要发送的数据地址(注意是取地址)
        // 参数3:0 表示队列满了就立刻失败,不等待
        //         这里不等待是因为接收任务优先级更高,
        //         数据一放进去就会立刻被取走,队列不会满
        xStatus = xQueueSendToBack( xQueue, &lValueToSend, 0 );
        if( xStatus != pdPASS )
        {
            // 发送失败,说明队列满了,这在本例中不应该发生
            printf( "Could not send to the queue.\r\n" );
        }
        // 没有 vTaskDelay,发送任务连续运行
        // 但因为优先级低,发送后立刻被接收任务抢占
    }
}
// ─── 接收任务(优先级最高)──────────────────────────────────
static void vReceiverTask( void *pvParameters )
{
    int32_t lReceivedValue;
    BaseType_t xStatus;
    // 100ms 超时:如果队列 100ms 内没有数据,放弃等待
    const TickType_t xTicksToWait = pdMS_TO_TICKS( 100 );
    for( ;; )
    {
        // 调试检查:因为接收任务优先级最高,
        // 它运行时队列应该为空(数据刚放进去就被取走了)
        if( uxQueueMessagesWaiting( xQueue ) != 0 )
        {
            printf( "Queue should have been empty!\r\n" );
        }
        // 从队列头部取数据
        // 参数1:队列句柄
        // 参数2:接收缓冲区(数据复制到 lReceivedValue)
        // 参数3:最多等待 100ms(实际上发送任务一直在发,不会超时)
        xStatus = xQueueReceive( xQueue, &lReceivedValue, xTicksToWait );
        if( xStatus == pdPASS )
        {
            // 成功取到数据
            printf( "Received = %ld\r\n", lReceivedValue );
        }
        else
        {
            // 超时了(本例中不应发生)
            printf( "Could not receive from the queue.\r\n" );
        }
    }
}
// ─── 主函数 ───────────────────────────────────────────────
int main( void )
{
    // 创建队列:最多装5个 int32_t 类型的元素
    // 虽然设置了5个容量,但因为接收任务优先级高,
    // 实际上队列里同时最多只有1个元素
    xQueue = xQueueCreate( 5, sizeof( int32_t ) );
    if( xQueue != NULL )
    {
        // 创建发送任务实例1:持续发送 100
        // (void*)100 把整数 100 强制转为 void* 传参(小技巧)
        xTaskCreate( vSenderTask, "Sender1", 1000, ( void* ) 100, 1, NULL );
        // 创建发送任务实例2:持续发送 200
        xTaskCreate( vSenderTask, "Sender2", 1000, ( void* ) 200, 1, NULL );
        // 创建接收任务:优先级2(高于发送任务的优先级1)
        xTaskCreate( vReceiverTask, "Receiver", 1000, NULL, 2, NULL );
        // 启动调度器
        vTaskStartScheduler();
    }
    else
    {
        printf( "Queue creation failed!\r\n" );
    }
    for( ;; );
    return 0;
}

执行时序分析:

时间 ──────────────────────────────────────────────►
Receiver  █░░█░░█░░█░░█░░
Sender2   ░█░░░░░█░░░░░░░   (优先级相同,轮流运行)
Sender1   ░░░█░░░░░█░░░░░
          t1 t2 t3 t4 t5
t1: Receiver 先运行(优先级最高),队列空,进入 Blocked
t2: Sender2 运行,写入 200 → 立刻唤醒 Receiver(高优先级抢占)
t3: Receiver 取出 200,打印,队列再次为空,再次 Blocked
    Sender1 获得运行机会,写入 100 → 唤醒 Receiver
t4: 如此循环...
输出:
Received = 200
Received = 100
Received = 200
Received = 100
...(交替)

示例11:发送任务优先级更高 + 传递结构体

// ============================================================
// 示例11:发送任务(优先级2) > 接收任务(优先级1)
// 队列传递结构体,接收方能知道数据来自哪个发送者
// ============================================================
#include <stdint.h>
#include <stdio.h>
// ─── 定义数据源的枚举类型 ──────────────────────────────────
typedef enum
{
    eSender1,   // 来自 Sender1
    eSender2    // 来自 Sender2
} DataSource_t;
// ─── 定义传输的结构体 ──────────────────────────────────────
// 把"数据内容"和"数据来源"打包在一起发送
typedef struct
{
    uint8_t      ucValue;      // 数据值
    DataSource_t eDataSource;  // 谁发的
} Data_t;
// 两个发送任务各自预定义的结构体(const,不会被修改)
static const Data_t xStructsToSend[ 2 ] =
{
    { 100, eSender1 },  // Sender1 发送这个
    { 200, eSender2 }   // Sender2 发送这个
};
// 全局队列句柄
QueueHandle_t xQueue;
// ─── 发送任务 ─────────────────────────────────────────────
static void vSenderTask( void *pvParameters )
{
    BaseType_t xStatus;
    // 100ms 超时:等待队列有空位
    const TickType_t xTicksToWait = pdMS_TO_TICKS( 100 );
    for( ;; )
    {
        // pvParameters 是指向 xStructsToSend[0] 或 [1] 的指针
        // 直接用 pvParameters 作为数据地址传入
        xStatus = xQueueSendToBack( xQueue, pvParameters, xTicksToWait );
        if( xStatus != pdPASS )
        {
            // 等了 100ms 还是满的,发送失败(本例中不应发生)
            printf( "Could not send to the queue.\r\n" );
        }
        // 发送任务优先级更高,发完数据后继续运行
        // 直到队列再次满了,才进入 Blocked 等待接收方取走数据
    }
}
// ─── 接收任务(优先级最低)────────────────────────────────
static void vReceiverTask( void *pvParameters )
{
    Data_t xReceivedStructure;  // 接收缓冲区:一个结构体
    BaseType_t xStatus;
    for( ;; )
    {
        // 调试:发送任务优先级高,只有队列满了它们才会 Blocked
        // 接收任务只有在两个发送任务都 Blocked 时才能运行
        // 所以接收任务运行时,队列应该恰好满了(3个元素)
        if( uxQueueMessagesWaiting( xQueue ) != 3 )
        {
            printf( "Queue should have been full!\r\n" );
        }
        // 不需要等待(block time = 0),因为队列此时必然是满的
        xStatus = xQueueReceive( xQueue, &xReceivedStructure, 0 );
        if( xStatus == pdPASS )
        {
            // 根据结构体中的 eDataSource 判断数据来源
            if( xReceivedStructure.eDataSource == eSender1 )
            {
                printf( "From Sender 1 = %d\r\n", xReceivedStructure.ucValue );
            }
            else
            {
                printf( "From Sender 2 = %d\r\n", xReceivedStructure.ucValue );
            }
        }
        else
        {
            printf( "Could not receive from the queue.\r\n" );
        }
    }
}
// ─── 主函数 ───────────────────────────────────────────────
int main( void )
{
    // 队列容量:3个 Data_t 结构体
    xQueue = xQueueCreate( 3, sizeof( Data_t ) );
    if( xQueue != NULL )
    {
        // 发送任务优先级2(高)
        // 注意传入的是结构体的地址
        xTaskCreate( vSenderTask, "Sender1", 1000,
                     (void*) &( xStructsToSend[0] ), 2, NULL );
        xTaskCreate( vSenderTask, "Sender2", 1000,
                     (void*) &( xStructsToSend[1] ), 2, NULL );
        // 接收任务优先级1(低)
        xTaskCreate( vReceiverTask, "Receiver", 1000, NULL, 1, NULL );
        vTaskStartScheduler();
    }
    for( ;; );
    return 0;
}

执行时序分析(发送方优先级更高时):

队列容量 = 3
时间轴:
Sender1   ████░░░░████░░░░████
Sender2   ░░░░████░░░░████░░░░
Receiver  ░░░░░░░░████░░░░████
          t1  t2  t3  t4  t5
t1: Sender1 运行,连续填满队列(3个)→ 进入 Blocked
t2: Sender2 运行,队列满了 → 立刻进入 Blocked
t3: 两个发送者都 Blocked,Receiver 终于能运行
    Receiver 取走1个 → Sender1(等最久)立刻抢占 Receiver
t4: Sender1 填满队列 → 进入 Blocked
    Receiver 再次运行,取走1个 → Sender2 抢占(等最久)
...
关键规律:等待最久的发送任务优先获得空位

为什么前4条消息都来自 Sender1?
因为 Sender1 先创建先运行,一次性填满了队列3个位置,Sender2 还没来得及发送就已经队列满了。

五、传递大数据:队列传指针

当数据很大(比如一帧图像),按值拷贝太慢,改为传指针

// ============================================================
// 队列传指针示例
// 发送方:申请缓冲区 → 写数据 → 把指针发进队列
// 接收方:从队列取出指针 → 读数据 → 释放缓冲区
// ============================================================
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
// 队列存的是 char*(字符指针),不是字符串本身
QueueHandle_t xPointerQueue;
// ─── 发送任务:申请内存,写字符串,发指针 ─────────────────
void vStringSendingTask( void *pvParameters )
{
    char *pcStringToSend;
    const size_t xMaxStringLength = 50;
    int xStringNumber = 0;
    for( ;; )
    {
        // 动态申请一块内存(实际项目可能用内存池)
        pcStringToSend = (char*) pvPortMalloc( xMaxStringLength );
        if( pcStringToSend != NULL )
        {
            // 格式化字符串写入缓冲区
            snprintf( pcStringToSend, xMaxStringLength,
                      "String number %d\r\n", xStringNumber );
            xStringNumber++;
            // 发送的是"指针的地址"(&pcStringToSend)
            // 队列里存的是 pcStringToSend 这个指针值本身
            // 注意:是把指针拷贝进队列,不是把字符串内容拷贝进去
            xQueueSend( xPointerQueue,
                        &pcStringToSend,    // 取指针变量的地址
                        portMAX_DELAY );
            // 发送完后,不能立刻 free!
            // 接收方还没读完呢,所有权已经转移给接收方
        }
    }
}
// ─── 接收任务:取指针,读数据,释放内存 ────────────────────
void vStringReceivingTask( void *pvParameters )
{
    char *pcReceivedString;
    for( ;; )
    {
        // 接收指针(把队列里的指针值复制到 pcReceivedString)
        xQueueReceive( xPointerQueue,
                       &pcReceivedString,   // 接收缓冲区(存放指针值)
                       portMAX_DELAY );
        // 现在 pcReceivedString 指向发送方申请的那块内存
        printf( "%s", pcReceivedString );
        // 用完之后,接收方负责释放内存
        vPortFree( pcReceivedString );
        // 释放后 pcReceivedString 变成悬空指针,但我们不再用它了
    }
}

指针传递的内存所有权规则:

发送前:             队列传输中:           接收后:
┌──────────────┐    ┌─────────────┐    ┌──────────────┐
│ 发送方拥有    │    │ 队列持有指针  │    │ 接收方拥有   │
│ 缓冲区       │──►│ (短暂中转)   │──►│ 缓冲区       │
│ 负责写数据   │    └─────────────┘    │ 负责释放内存  │
└──────────────┘                       └──────────────┘
规则:指针入队后,发送方不能再修改或释放该内存!
      接收方收到指针后,负责最终 free

六、多数据源:用结构体统一所有消息

当一个任务要接收来自多个来源、多种类型的数据时,最优雅的做法是用一个结构体把所有信息打包:

// ============================================================
// 仿照 FreeRTOS+TCP 的设计:用枚举+结构体统一所有消息类型
// ============================================================
#include <stdint.h>
#include <stdio.h>
// 消息类型枚举
typedef enum
{
    eNetworkDownEvent = 0,  // 网络断开
    eNetworkRxEvent,        // 收到网络数据包
    eTCPAcceptEvent,        // 有新的 TCP 连接请求
} eIPEvent_t;
// 统一消息结构体
typedef struct
{
    eIPEvent_t eEventType;  // 消息类型
    void      *pvData;      // 附带数据(指针,可为 NULL)
} IPStackEvent_t;
// ─── 发送"网络断开"事件(无附带数据)──────────────────────
void vSendNetworkDownEvent( void )
{
    IPStackEvent_t xEvent;
    xEvent.eEventType = eNetworkDownEvent;
    xEvent.pvData = NULL;  // 没有附带数据
    // xSendEventStructToIPTask(&xEvent);  发送给 TCP 任务
}
// ─── 发送"收到数据包"事件(附带数据指针)──────────────────
void vSendRxDataEvent( void *pxRxedData )
{
    IPStackEvent_t xEvent;
    xEvent.eEventType = eNetworkRxEvent;
    xEvent.pvData = pxRxedData;  // 指向收到的数据缓冲区
    // xSendEventStructToIPTask(&xEvent);
}
// ─── TCP 任务:从队列接收消息并分发处理 ────────────────────
void vTCPTask( void *pvParameters )
{
    IPStackEvent_t xReceivedEvent;
    for( ;; )
    {
        // 阻塞等待队列中有消息
        xQueueReceive( /* xNetworkEventQueue */ NULL,
                       &xReceivedEvent,
                       portMAX_DELAY );
        // 用 switch 分发处理不同类型的消息
        switch( xReceivedEvent.eEventType )
        {
            case eNetworkDownEvent:
                // 无附带数据,直接处理
                // prvProcessNetworkDownEvent();
                printf("Network is down, reconnecting...\r\n");
                break;
            case eNetworkRxEvent:
                // pvData 是指向数据包的指针,转换后处理
                // prvHandleEthernetPacket(
                //     (NetworkBufferDescriptor_t*)(xReceivedEvent.pvData) );
                printf("Received a network packet.\r\n");
                break;
            case eTCPAcceptEvent:
                // pvData 存的是 socket 句柄(直接当整数用)
                // FreeRTOS_Socket_t* xSocket =
                //     (FreeRTOS_Socket_t*)(xReceivedEvent.pvData);
                printf("TCP accept event.\r\n");
                break;
            default:
                break;
        }
    }
}

这种设计的优势:

传统做法(多个队列):
  队列A ──► 处理网络事件
  队列B ──► 处理用户输入
  队列C ──► 处理定时器
  需要轮询或队列集,复杂!
统一结构体做法(一个队列):
  ┌─────────────────┐
  │ 一个队列         │
  │ 存放 IPStackEvent│ ──► TCP任务:switch → 分发处理
  │ 类型+数据指针    │
  └─────────────────┘
  简洁!高效!

七、队列集(Queue Set):同时等多个队列

当必须使用多个独立队列时(比如集成第三方代码),用队列集让任务同时监听所有队列:

工作原理

队列集(Queue Set)的工作方式:
队列1 ──► 有数据时,把队列1的句柄发到队列集
队列2 ──► 有数据时,把队列2的句柄发到队列集
         ┌───────────────────┐
         │   队列集           │
         │  [队列1句柄]       │  ← xQueueSelectFromSet 从这里取
         │  [队列2句柄]       │
         └───────────────────┘
任务调用 xQueueSelectFromSet,获取"哪个队列有数据"
然后再从那个队列读数据

队列集的大小计算

uxEventQueueLength=∑i=1n队列i的容量uxEventQueueLength = \sum_{i=1}^{n} \text{队列}_{i}\text{的容量}uxEventQueueLength=i=1n队列i的容量
例如:队列1容量5,队列2容量3,则队列集大小 = 5+3=85 + 3 = 85+3=8

队列集完整示例

// ============================================================
// 示例12:队列集——一个任务同时监听两个队列
// ============================================================
#include <stdint.h>
#include <stdio.h>
// 两个独立的队列
static QueueHandle_t xQueue1 = NULL;
static QueueHandle_t xQueue2 = NULL;
// 队列集
static QueueSetHandle_t xQueueSet = NULL;
// ─── 发送任务1:每 100ms 发送一次 ─────────────────────────
void vSenderTask1( void *pvParameters )
{
    const TickType_t xBlockTime = pdMS_TO_TICKS( 100 );
    const char * const pcMessage = "Message from vSenderTask1\r\n";
    for( ;; )
    {
        vTaskDelay( xBlockTime );  // 等 100ms
        // 发送字符串指针到 Queue1
        // block time = 0:因为接收任务优先级更高,
        // 数据一进去立刻被取走,队列不会满
        xQueueSend( xQueue1, &pcMessage, 0 );
    }
}
// ─── 发送任务2:每 200ms 发送一次 ─────────────────────────
void vSenderTask2( void *pvParameters )
{
    const TickType_t xBlockTime = pdMS_TO_TICKS( 200 );
    const char * const pcMessage = "Message from vSenderTask2\r\n";
    for( ;; )
    {
        vTaskDelay( xBlockTime );  // 等 200ms
        xQueueSend( xQueue2, &pcMessage, 0 );
    }
}
// ─── 接收任务:从队列集得知哪个队列有数据 ────────────────
void vReceiverTask( void *pvParameters )
{
    QueueHandle_t xQueueThatContainsData;
    char *pcReceivedString;
    for( ;; )
    {
        // 阻塞等待队列集中任意一个队列有数据
        // 返回的是"哪个队列有数据"的句柄
        // portMAX_DELAY:永久等待,不超时
        xQueueThatContainsData = (QueueHandle_t) xQueueSelectFromSet(
                                     xQueueSet,
                                     portMAX_DELAY );
        // 现在 xQueueThatContainsData 肯定有数据(因为用了无限等待)
        // 从那个队列里读数据(block time = 0,因为已知有数据)
        xQueueReceive( xQueueThatContainsData, &pcReceivedString, 0 );
        printf( "%s", pcReceivedString );
    }
}
// ─── 主函数 ───────────────────────────────────────────────
int main( void )
{
    // 每个队列容量为1(因为接收任务优先级高,数据立刻被取走)
    xQueue1 = xQueueCreate( 1, sizeof( char* ) );
    xQueue2 = xQueueCreate( 1, sizeof( char* ) );
    // 队列集大小 = 队列1容量(1) + 队列2容量(1) = 2
    xQueueSet = xQueueCreateSet( 1 * 2 );
    // 把两个队列都加入队列集
    // 注意:只有空队列才能被加入队列集!
    xQueueAddToSet( xQueue1, xQueueSet );
    xQueueAddToSet( xQueue2, xQueueSet );
    // 发送任务优先级1(低)
    xTaskCreate( vSenderTask1, "Sender1", 1000, NULL, 1, NULL );
    xTaskCreate( vSenderTask2, "Sender2", 1000, NULL, 1, NULL );
    // 接收任务优先级2(高)
    xTaskCreate( vReceiverTask, "Receiver", 1000, NULL, 2, NULL );
    vTaskStartScheduler();
    for( ;; );
    return 0;
}

时序说明:

时间轴(以100ms为单位):
100ms: Sender1 发送 → Receiver 唤醒,打印 Sender1 消息
200ms: Sender1 发送 + Sender2 发送(同时到)
       → Receiver 处理Queue1(优先级高的先处理?其实先到先得)
       → Receiver 再处理Queue2
300ms: Sender1 发送 → 打印 Sender1
400ms: Sender1 + Sender2 → 打印两次
...
输出(Sender1 每100ms,Sender2每200ms,Sender1出现频率是Sender2的2倍):
Message from vSenderTask1
Message from vSenderTask1
Message from vSenderTask2
Message from vSenderTask1
Message from vSenderTask1
Message from vSenderTask2
...

队列集处理多种类型的完整模板

// ============================================================
// 队列集包含:字符指针队列 + uint32_t队列 + 二值信号量
// ============================================================
#include <stdint.h>
#include <stdio.h>
QueueHandle_t    xCharPointerQueue;   // 传字符串指针的队列
QueueHandle_t    xUint32tQueue;       // 传 uint32_t 的队列
SemaphoreHandle_t xBinarySemaphore;   // 二值信号量
QueueSetHandle_t xQueueSet;           // 队列集
void vAMoreRealisticReceiverTask( void *pvParameters )
{
    QueueSetMemberHandle_t xHandle;  // 泛型句柄(可能是队列或信号量)
    char *pcReceivedString;
    uint32_t ulReceivedValue;
    const TickType_t xDelay100ms = pdMS_TO_TICKS( 100 );
    for( ;; )
    {
        // 最多等 100ms,获取"有数据的队列/信号量"的句柄
        xHandle = xQueueSelectFromSet( xQueueSet, xDelay100ms );
        if( xHandle == NULL )
        {
            // 超时:100ms 内没有任何队列/信号量有数据
            printf("Timeout, no data received.\r\n");
        }
        else if( xHandle == (QueueSetMemberHandle_t) xCharPointerQueue )
        {
            // 字符指针队列有数据,读出字符串指针
            xQueueReceive( xCharPointerQueue, &pcReceivedString, 0 );
            printf("String: %s\r\n", pcReceivedString);
        }
        else if( xHandle == (QueueSetMemberHandle_t) xUint32tQueue )
        {
            // uint32_t 队列有数据,读出整数
            xQueueReceive( xUint32tQueue, &ulReceivedValue, 0 );
            printf("Value: %lu\r\n", ulReceivedValue);
        }
        else if( xHandle == (QueueSetMemberHandle_t) xBinarySemaphore )
        {
            // 信号量被给出,获取信号量
            xSemaphoreTake( xBinarySemaphore, 0 );
            printf("Semaphore taken!\r\n");
        }
    }
}

八、邮箱(Mailbox)

队列 vs 邮箱的区别

队列(Queue):数据穿过,先进先出
  写方 ──► [A][B][C] ──► 读方(A 被取走后消失)
邮箱(Mailbox):数据留着,随时可读,新数据覆盖旧数据
  写方 ──► [最新值]   读方可以反复 Peek,数据不消失
             ▲
         下次写入时覆盖

邮箱本质上就是长度为1的队列 + xQueueOverwrite(覆盖写)+ xQueuePeek(不移除地读)。

邮箱完整示例

// ============================================================
// 邮箱示例:一个任务持续更新"最新状态",
//           多个任务随时查看(不消耗数据)
// ============================================================
#include <stdint.h>
#include <stdio.h>
// 邮箱里存放的数据类型:带时间戳的传感器读数
typedef struct xExampleStructure
{
    TickType_t xTimeStamp;  // 上次更新时刻(Tick 计数)
    uint32_t   ulValue;     // 传感器数值
} Example_t;
// 邮箱句柄(本质是队列句柄)
QueueHandle_t xMailbox;
// ─── 更新邮箱(写入新数据,覆盖旧数据)──────────────────
void vUpdateMailbox( uint32_t ulNewValue )
{
    Example_t xData;
    xData.ulValue    = ulNewValue;
    xData.xTimeStamp = xTaskGetTickCount();  // 记录当前时刻
    // xQueueOverwrite:无论邮箱是否已满,直接覆盖
    // 永远返回 pdPASS,不会阻塞
    xQueueOverwrite( xMailbox, &xData );
}
// ─── 读取邮箱(不移除数据,多个任务可以各自读)──────────
BaseType_t vReadMailbox( Example_t *pxData )
{
    TickType_t xPreviousTimeStamp;
    BaseType_t xDataUpdated;
    // 记录上次读到的时间戳,用于比较数据是否更新了
    xPreviousTimeStamp = pxData->xTimeStamp;
    // xQueuePeek:读出数据但不移除
    // portMAX_DELAY:邮箱空时无限等待(首次读时可能需要等)
    xQueuePeek( xMailbox, pxData, portMAX_DELAY );
    // 比较时间戳:如果比上次读的时间更新,说明数据有更新
    if( pxData->xTimeStamp > xPreviousTimeStamp )
    {
        xDataUpdated = pdTRUE;   // 数据更新了
    }
    else
    {
        xDataUpdated = pdFALSE;  // 数据没变化
    }
    return xDataUpdated;
}
// ─── 创建邮箱 ─────────────────────────────────────────────
void vSetupMailbox( void )
{
    // 长度必须是1!xQueueOverwrite 只设计用于长度1的队列
    xMailbox = xQueueCreate( 1, sizeof( Example_t ) );
}

九、完整 API 速查表


函数 作用 中断安全版本
xQueueCreate() 创建队列
xQueueSendToBack() 发到队列尾 xQueueSendToBackFromISR()
xQueueSendToFront() 发到队列头 xQueueSendToFrontFromISR()
xQueueReceive() 取数据(移除) xQueueReceiveFromISR()
xQueuePeek() 读数据(不移除) xQueuePeekFromISR()
xQueueOverwrite() 覆盖写(邮箱用) xQueueOverwriteFromISR()
uxQueueMessagesWaiting() 查询元素数量 uxQueueMessagesWaitingFromISR()
xQueueReset() 清空队列
xQueueCreateSet() 创建队列集
xQueueAddToSet() 把队列加入队列集
xQueueSelectFromSet() 从队列集等待

中断服务程序(ISR)里只能用 FromISR 结尾的版本!

十、设计选型总结

一个来源

多个来源

不大,直接拷贝

很大,拷贝代价高

不能(第三方限制)

需要任意任务都能查看最新值

需要任务间传递数据

数据来源有几个?

数据大不大?

能用统一结构体?

普通队列
xQueueSendToBack

队列传指针
注意内存所有权

一个队列
传结构体
最优解

队列集
xQueueCreateSet

邮箱
长度1队列
xQueueOverwrite+xQueuePeek


场景 推荐方案 原因
普通数据传递 队列(值拷贝) 简单安全
大块数据 队列(指针) 效率高,注意所有权
多来源数据 队列(结构体) 一个队列搞定,最优
被迫用多队列 队列集 避免轮询
共享最新状态 邮箱(Peek) 多任务可同时读

FreeRTOS 第五章:软件定时器管理 — 从零理解

1. 什么是软件定时器?

软件定时器就像一个"闹钟":

  • 你设定好时间(period,周期)
  • 时间到了,它自动调用一个函数(callback,回调函数)
  • 整个过程完全由 FreeRTOS 内核管理,不需要任何硬件定时器支持

关键优点:如果没有回调函数在执行,软件定时器不占用任何 CPU 时间。

启用软件定时器的两个步骤

  1. FreeRTOS/Source/timers.c 加入你的工程
  2. FreeRTOSConfig.h 中设置:configUSE_TIMERS = 1

2. 回调函数的格式

回调函数必须符合固定的原型格式:

// 回调函数原型
// 参数:xTimer —— 触发本次回调的定时器句柄(handle)
// 返回值:void(没有返回值)
void ATimerCallback( TimerHandle_t xTimer );

注意事项(非常重要):
回调函数在一个特殊任务(守护任务/Daemon Task)的上下文中执行,因此:

  • 函数必须从头跑到尾,不能进入阻塞(Blocked)状态
  • 不能调用会让任务阻塞的函数,比如 vTaskDelay()
  • 可以调用 xQueueReceive(),但 xTicksToWait 必须设为 0(即不等待)

3. 定时器的两种类型

3.1 单次定时器(One-shot Timer)

  • 启动后,只执行一次回调函数
  • 执行完毕后进入休眠(Dormant)状态
  • 可以手动重启,但不会自动重复

3.2 自动重载定时器(Auto-reload Timer)

  • 启动后,每隔一个周期自动执行一次回调函数
  • 执行完毕后自动重新进入运行状态,永远循环

对比示意(ASCII 时序图)

时间轴:  t1  t2  t3  t4  t5  t6  t7  t8  t9  t10 t11 t12 t13 t14 t15 t16
         |   |   |   |   |   |   |   |   |   |   |   |   |   |   |   |
Timer1   ^---------------------------------X
(单次)   t1启动                           t7执行(周期=6),之后不再执行
Timer2   ^--------------------X-----------X-----------X-----------X
(自动重载) t1启动             t6执行      t11执行     t16执行...
         (周期=5,每5个tick执行一次)

4. 定时器的两种状态

定时器在任何时刻只处于两种状态之一:

状态 说明
休眠(Dormant) 定时器存在,但不运行,回调不会被调用
运行(Running) 定时器在倒计时,到期后执行回调

状态转换图

自动重载定时器:

xTimerCreate() 创建

xTimerStart() / xTimerReset() / xTimerChangePeriod()

xTimerStop()

定时器到期,执行回调,自动重启

Dormant

Running

单次定时器:

xTimerCreate() 创建

xTimerStart() / xTimerReset() / xTimerChangePeriod()

xTimerStop() 或 定时器到期执行回调后进入休眠

Dormant

Running

5. 软件定时器的运行上下文

5.1 守护任务(Daemon Task / Timer Service Task)

FreeRTOS 调度器启动时,会自动创建一个守护任务
所有软件定时器的回调函数都在这个守护任务里执行。
守护任务的优先级和栈深度由配置文件控制:

// 在 FreeRTOSConfig.h 中配置
configTIMER_TASK_PRIORITY      // 守护任务的优先级
configTIMER_TASK_STACK_DEPTH   // 守护任务的栈大小

5.2 定时器命令队列(Timer Command Queue)

应用代码不能直接控制守护任务,而是通过一个队列发命令:

应用任务                    定时器命令队列              守护任务
-----------                ----------------           ----------
xTimerStart()  ---写入---> [ 启动定时器命令 ] --读取--> 处理命令
xTimerStop()   ---写入---> [ 停止定时器命令 ] --读取--> 处理命令
xTimerReset()  ---写入---> [ 重置定时器命令 ] --读取--> 处理命令

队列长度由配置控制:

configTIMER_QUEUE_LENGTH   // 定时器命令队列的长度

5.3 守护任务的调度优先级影响

守护任务和普通任务一样参与调度,只有它是最高优先级的就绪任务时才运行。

情况一:调用任务优先级 > 守护任务优先级
时间轴:  t1    t2         t3    t4         t5
         |     |          |     |          |
守护任务  [阻塞]            [阻塞]  [运行:处理命令] [阻塞]
Task1    [运行] [运行:xTimerStart()] [运行]   [阻塞]
Idle                                          [运行]

执行顺序说明:

  • t2:Task1 调用 xTimerStart(),命令写入队列,守护任务从阻塞变就绪,但因优先级低不能抢占
  • t3:xTimerStart() 返回,Task1 继续运行
  • t4:Task1 主动阻塞,守护任务才得以运行并处理命令
情况二:守护任务优先级 > 调用任务优先级
时间轴:  t1    t2    t3    t4    t5
         |     |     |     |     |
守护任务  [阻塞] [运行:处理命令] [阻塞]
Task1    [运行] [就绪]          [运行:xTimerStart()返回] [阻塞]
Idle                                                    [运行]

执行顺序说明:

  • t2:Task1 调用 xTimerStart(),命令入队,守护任务优先级高,立刻抢占 Task1
  • t3:守护任务处理完命令,重新阻塞
  • t4:Task1 恢复运行,xTimerStart() 才真正返回

重要:定时器的到期时间从命令发送时开始计算,而不是从守护任务处理命令时算起。这通过命令中携带的时间戳实现。

6. 创建和启动定时器

6.1 xTimerCreate() — 创建定时器

TimerHandle_t xTimerCreate(
    const char * const pcTimerName,       // 定时器名字(仅调试用)
    TickType_t xTimerPeriodInTicks,        // 周期,单位:tick
    UBaseType_t uxAutoReload,              // pdTRUE=自动重载,pdFALSE=单次
    void * pvTimerID,                      // 定时器ID(用户自定义用途)
    TimerCallbackFunction_t pxCallbackFunction  // 回调函数指针
);

参数 含义
pcTimerName 定时器名称,仅用于调试,FreeRTOS 不使用它
xTimerPeriodInTicks 周期(tick数),可用 pdMS_TO_TICKS(ms) 转换
uxAutoReload pdTRUE = 自动重载;pdFALSE = 单次
pvTimerID 初始ID,可存任意值(整数/指针/函数指针)
pxCallbackFunction 回调函数
返回值 NULL = 创建失败(堆内存不足);非NULL = 成功,返回句柄

6.2 xTimerStart() — 启动定时器

BaseType_t xTimerStart(
    TimerHandle_t xTimer,       // 定时器句柄
    TickType_t xTicksToWait     // 等待队列空间的最长时间(tick)
);

返回值 含义
pdPASS 命令成功写入队列
pdFALSE 队列满,写入失败

不能在中断服务例程(ISR)中调用 xTimerStart()!应使用 xTimerStartFromISR()

6.3 完整示例:创建并启动单次 + 自动重载定时器

#include <stdio.h>
#include "FreeRTOS.h"
#include "task.h"
#include "timers.h"
// 定义周期常量
// pdMS_TO_TICKS(ms) 把毫秒转换成 tick 数
#define mainONE_SHOT_TIMER_PERIOD     pdMS_TO_TICKS( 3333 )  // 3.333 秒
#define mainAUTO_RELOAD_TIMER_PERIOD  pdMS_TO_TICKS( 500 )   // 0.5 秒
// 用来统计回调执行次数
static uint32_t ulCallCount = 0;
// -------------------------------------------------------
// 单次定时器回调函数
// 参数 xTimer:触发本次回调的定时器句柄
// -------------------------------------------------------
static void prvOneShotTimerCallback( TimerHandle_t xTimer )
{
    // 获取当前 tick 计数
    TickType_t xTimeNow = xTaskGetTickCount();
    // 打印当前时间
    printf( "单次定时器回调执行,当前时间: %lu ticks\n", (unsigned long)xTimeNow );
    ulCallCount++;
}
// -------------------------------------------------------
// 自动重载定时器回调函数
// -------------------------------------------------------
static void prvAutoReloadTimerCallback( TimerHandle_t xTimer )
{
    // 获取当前 tick 计数
    TickType_t xTimeNow = xTaskGetTickCount();
    printf( "自动重载定时器回调执行,当前时间: %lu ticks\n", (unsigned long)xTimeNow );
    ulCallCount++;
}
int main( void )
{
    TimerHandle_t xAutoReloadTimer, xOneShotTimer;
    BaseType_t xTimer1Started, xTimer2Started;
    // -------------------------------------------------------
    // 创建单次定时器
    // uxAutoReload = pdFALSE → 单次模式
    // -------------------------------------------------------
    xOneShotTimer = xTimerCreate(
        "OneShot",                   // 名字(仅调试用)
        mainONE_SHOT_TIMER_PERIOD,   // 周期 = 3333 ticks
        pdFALSE,                     // 单次模式
        0,                           // ID 初始值为 0
        prvOneShotTimerCallback      // 回调函数
    );
    // -------------------------------------------------------
    // 创建自动重载定时器
    // uxAutoReload = pdTRUE → 自动重载模式
    // -------------------------------------------------------
    xAutoReloadTimer = xTimerCreate(
        "AutoReload",                   // 名字(仅调试用)
        mainAUTO_RELOAD_TIMER_PERIOD,   // 周期 = 500 ticks
        pdTRUE,                         // 自动重载模式
        0,                              // ID 初始值为 0
        prvAutoReloadTimerCallback      // 回调函数
    );
    // 检查两个定时器是否创建成功(返回值非 NULL 才成功)
    if( ( xOneShotTimer != NULL ) && ( xAutoReloadTimer != NULL ) )
    {
        // 启动单次定时器
        // xTicksToWait = 0:若命令队列满,立即返回(不等待)
        // 此时调度器未启动,所以等待参数会被忽略
        xTimer1Started = xTimerStart( xOneShotTimer, 0 );
        // 启动自动重载定时器
        xTimer2Started = xTimerStart( xAutoReloadTimer, 0 );
        // 两个定时器都成功启动后,才启动调度器
        if( ( xTimer1Started == pdPASS ) && ( xTimer2Started == pdPASS ) )
        {
            vTaskStartScheduler();  // 启动 FreeRTOS 调度器
        }
    }
    // 正常情况下永远不会执行到这里
    for( ;; );
    return 0;
}

运行结果(示意):

自动重载定时器回调执行,当前时间: 500 ticks
自动重载定时器回调执行,当前时间: 1000 ticks
自动重载定时器回调执行,当前时间: 1500 ticks
...(每500 ticks执行一次)
单次定时器回调执行,当前时间: 3333 ticks   ← 只执行这一次
自动重载定时器回调执行,当前时间: 3500 ticks
...

7. 定时器 ID 的使用

每个软件定时器都有一个 ID(void * 类型),可以存任意值:整数、指针、函数指针等。

相关函数

// 设置 ID
void vTimerSetTimerID( const TimerHandle_t xTimer, void *pvNewID );
// 读取 ID
void *pvTimerGetTimerID( TimerHandle_t xTimer );

这两个函数直接操作定时器,不经过命令队列,因此非常高效。

应用场景:多个定时器共用一个回调函数

思路:通过 ID 存储每个定时器各自的执行计数,在回调中用句柄区分是哪个定时器触发的。

#include <stdio.h>
#include "FreeRTOS.h"
#include "task.h"
#include "timers.h"
#define mainONE_SHOT_TIMER_PERIOD     pdMS_TO_TICKS( 3333 )
#define mainAUTO_RELOAD_TIMER_PERIOD  pdMS_TO_TICKS( 500 )
// 全局句柄,用于在回调函数中判断是哪个定时器
static TimerHandle_t xOneShotTimer;
static TimerHandle_t xAutoReloadTimer;
// -------------------------------------------------------
// 两个定时器共用同一个回调函数
// -------------------------------------------------------
static void prvTimerCallback( TimerHandle_t xTimer )
{
    TickType_t xTimeNow;
    uint32_t ulExecutionCount;
    // 从 ID 中读取执行次数(ID 存的是 uint32_t,需要强制转换)
    ulExecutionCount = ( uint32_t ) pvTimerGetTimerID( xTimer );
    // 执行次数加一
    ulExecutionCount++;
    // 把新的执行次数写回 ID
    vTimerSetTimerID( xTimer, ( void * ) ulExecutionCount );
    // 获取当前时间
    xTimeNow = xTaskGetTickCount();
    // 通过比较句柄,判断是哪个定时器触发的
    if( xTimer == xOneShotTimer )
    {
        // 是单次定时器
        printf( "单次定时器回调执行,时间: %lu ticks\n", (unsigned long)xTimeNow );
    }
    else
    {
        // 是自动重载定时器
        printf( "自动重载定时器回调执行,时间: %lu ticks,执行次数: %lu\n",
                (unsigned long)xTimeNow, (unsigned long)ulExecutionCount );
        // 执行 5 次后自动停止
        if( ulExecutionCount == 5 )
        {
            // 回调函数在守护任务中运行,所以 xTicksToWait 必须为 0
            xTimerStop( xTimer, 0 );
        }
    }
}
int main( void )
{
    BaseType_t xTimer1Started, xTimer2Started;
    // 创建单次定时器,ID 初始为 0
    xOneShotTimer = xTimerCreate(
        "OneShot",
        mainONE_SHOT_TIMER_PERIOD,
        pdFALSE,
        0,                      // ID 初始值 = 0(计数从0开始)
        prvTimerCallback        // 和自动重载定时器共用同一个回调
    );
    // 创建自动重载定时器,ID 初始为 0
    xAutoReloadTimer = xTimerCreate(
        "AutoReload",
        mainAUTO_RELOAD_TIMER_PERIOD,
        pdTRUE,
        0,
        prvTimerCallback        // 共用同一个回调
    );
    if( ( xOneShotTimer != NULL ) && ( xAutoReloadTimer != NULL ) )
    {
        xTimer1Started = xTimerStart( xOneShotTimer, 0 );
        xTimer2Started = xTimerStart( xAutoReloadTimer, 0 );
        if( ( xTimer1Started == pdPASS ) && ( xTimer2Started == pdPASS ) )
        {
            vTaskStartScheduler();
        }
    }
    for( ;; );
    return 0;
}

运行结果(示意):

自动重载定时器回调执行,时间: 500 ticks,执行次数: 1
自动重载定时器回调执行,时间: 1000 ticks,执行次数: 2
自动重载定时器回调执行,时间: 1500 ticks,执行次数: 3
自动重载定时器回调执行,时间: 2000 ticks,执行次数: 4
自动重载定时器回调执行,时间: 2500 ticks,执行次数: 5  ← 停止
单次定时器回调执行,时间: 3333 ticks

8. 修改定时器周期

使用 xTimerChangePeriod() 可以在运行时动态修改定时器的周期。

BaseType_t xTimerChangePeriod(
    TimerHandle_t xTimer,               // 要修改的定时器句柄
    TickType_t xNewTimerPeriodInTicks,  // 新的周期(tick数)
    TickType_t xTicksToWait             // 等待命令队列空间的超时时间
);

两种情况:

  • 定时器正在运行:以调用时刻为基准重新计算到期时间
  • 定时器处于休眠:自动启动定时器,并开始按新周期计时
    典型应用:健康检测 + LED 闪烁
正常状态:每 3000ms 慢闪一次 LED
检测到错误:改为每 200ms 快闪一次 LED
#include <stdio.h>
#include "FreeRTOS.h"
#include "task.h"
#include "timers.h"
// 正常周期 3000ms,错误周期 200ms
const TickType_t xHealthyTimerPeriod = pdMS_TO_TICKS( 3000 );
const TickType_t xErrorTimerPeriod   = pdMS_TO_TICKS( 200 );
// 模拟 LED 状态
static int ledState = 0;
// 模拟检查任务是否正常运行
static int CheckTasksAreRunningWithoutError( void )
{
    // 实际项目中这里检查各任务状态
    // 这里仅作示意,假设无错误
    return 1; // 1 = OK, 0 = 有错误
}
static void ToggleLED( void )
{
    ledState = !ledState;
    printf( "LED: %s\n", ledState ? "ON" : "OFF" );
}
// -------------------------------------------------------
// 健康检测定时器的回调函数
// -------------------------------------------------------
static void prvCheckTimerCallbackFunction( TimerHandle_t xTimer )
{
    // 用 static 变量记录是否已经检测到错误
    // static 变量只初始化一次,函数每次被调用都保留上次的值
    static int xErrorDetected = 0; // 0 = pdFALSE
    if( xErrorDetected == 0 )
    {
        // 还未检测到错误,执行检查
        if( CheckTasksAreRunningWithoutError() == 0 ) // 0 = pdFAIL
        {
            // 检测到错误!把定时器周期从 3000ms 改为 200ms
            // 回调函数在守护任务中执行,xTicksToWait 必须为 0
            xTimerChangePeriod(
                xTimer,              // 要修改的定时器
                xErrorTimerPeriod,   // 新周期 200ms
                0                    // 不等待
            );
            xErrorDetected = 1; // 标记已检测到错误
        }
    }
    // 无论有没有错误,都切换 LED
    ToggleLED();
}
int main( void )
{
    TimerHandle_t xCheckTimer;
    // 创建健康检测定时器,初始周期 3000ms
    xCheckTimer = xTimerCreate(
        "CheckTimer",
        xHealthyTimerPeriod,     // 正常周期 3000ms
        pdTRUE,                  // 自动重载
        0,
        prvCheckTimerCallbackFunction
    );
    if( xCheckTimer != NULL )
    {
        xTimerStart( xCheckTimer, 0 );
        vTaskStartScheduler();
    }
    for( ;; );
    return 0;
}

9. 重置定时器

重置的含义:重新开始倒计时,以重置时刻为新的起点计算到期时间。

时序示意(定时器周期 = 6 ticks)

时间轴:  t1  t2  t3  t4  t5  t6  t7  t8  t9  t10 t11 t12 t13 t14 t15
         |   |   |   |   |   |   |   |   |   |   |   |   |   |   |
         ^启动                  ^重置         ^重置              ^执行
计算过程:
  t1 启动 → 预计 t7 到期  ($1 + 6 = 7$)
  t5 重置 → 预计 t11 到期 ($5 + 6 = 11$)
  t9 重置 → 预计 t15 到期 ($9 + 6 = 15$)
  t15 到期 → 回调函数执行

公式表达:每次重置后的新到期时间为
texpire=treset+Tperiodt_{expire} = t_{reset} + T_{period}texpire=treset+Tperiod
其中 tresett_{reset}treset 是重置时刻,TperiodT_{period}Tperiod 是定时器周期。

xTimerReset() 函数

BaseType_t xTimerReset(
    TimerHandle_t xTimer,    // 要重置的定时器句柄
    TickType_t xTicksToWait  // 等待命令队列空间的超时时间
);

返回值 含义
pdPASS 重置命令成功发送到队列
pdFALSE 命令队列满,发送失败

不能在 ISR 中使用 xTimerReset(),应使用 xTimerResetFromISR()

完整应用示例:模拟手机背光

需求:

  • 按键 → 背光亮起
  • 一段时间内再次按键 → 重置计时器(背光继续亮)
  • 超时没有按键 → 背光自动熄灭
#include <stdio.h>
#include <conio.h>      // Windows 下用 _kbhit() 和 _getch()
#include "FreeRTOS.h"
#include "task.h"
#include "timers.h"
// 背光超时时间 5000ms(5秒)
#define mainBACKLIGHT_TIMER_PERIOD  pdMS_TO_TICKS( 5000 )
// 背光状态:pdTRUE = 亮,pdFALSE = 灭
static int xSimulatedBacklightOn = 0; // 初始为关
// 背光定时器全局句柄(需要在任务和回调之间共享)
static TimerHandle_t xBacklightTimer;
// -------------------------------------------------------
// 背光定时器回调函数
// 当定时器到期(超时无按键)时自动关闭背光
// -------------------------------------------------------
static void prvBacklightTimerCallback( TimerHandle_t xTimer )
{
    TickType_t xTimeNow = xTaskGetTickCount();
    // 背光超时 → 关闭
    xSimulatedBacklightOn = 0;
    printf( "定时器到期,背光关闭,时间: %lu ticks\n", (unsigned long)xTimeNow );
}
// -------------------------------------------------------
// 按键轮询任务
// 实际项目中应用中断来处理按键,这里用轮询代替
// -------------------------------------------------------
static void vKeyHitTask( void *pvParameters )
{
    // 每次轮询等待 50ms,避免占满 CPU
    const TickType_t xShortDelay = pdMS_TO_TICKS( 50 );
    TickType_t xTimeNow;
    printf( "请按任意键以打开背光。\n" );
    for( ;; )
    {
        // _kbhit() 检测是否有键被按下(非阻塞)
        if( _kbhit() != 0 )
        {
            xTimeNow = xTaskGetTickCount();
            if( xSimulatedBacklightOn == 0 )
            {
                // 背光原本是灭的 → 打开背光
                xSimulatedBacklightOn = 1;
                printf( "按键按下,背光打开,时间: %lu ticks\n", (unsigned long)xTimeNow );
            }
            else
            {
                // 背光已经是亮的 → 重置定时器(延长亮的时间)
                printf( "按键按下,重置定时器,时间: %lu ticks\n", (unsigned long)xTimeNow );
            }
            // 无论背光原来是开还是关,都重置定时器
            // 如果定时器处于休眠状态,xTimerReset 会同时启动它
            // 如果在 ISR 中,需要改用 xTimerResetFromISR()
            xTimerReset( xBacklightTimer, xShortDelay );
            // 读取并丢弃按键值(本示例不需要具体的键值)
            ( void ) _getch();
        }
        // 短暂延时,让出 CPU
        vTaskDelay( xShortDelay );
    }
}
int main( void )
{
    // 创建背光定时器(单次,5秒超时)
    xBacklightTimer = xTimerCreate(
        "Backlight",
        mainBACKLIGHT_TIMER_PERIOD,   // 5000 ticks
        pdFALSE,                      // 单次模式(超时后不自动重启)
        0,
        prvBacklightTimerCallback
    );
    if( xBacklightTimer != NULL )
    {
        // 创建按键轮询任务
        xTaskCreate(
            vKeyHitTask,     // 任务函数
            "KeyHit",        // 任务名
            1000,            // 栈大小(word数)
            NULL,            // 任务参数
            1,               // 优先级
            NULL             // 任务句柄(不需要)
        );
        vTaskStartScheduler();
    }
    for( ;; );
    return 0;
}

运行结果(示意):

请按任意键以打开背光。
按键按下,背光打开,时间: 812 ticks
按键按下,重置定时器,时间: 1813 ticks
按键按下,重置定时器,时间: 3114 ticks
按键按下,重置定时器,时间: 4015 ticks
按键按下,重置定时器,时间: 5016 ticks
定时器到期,背光关闭,时间: 10016 ticks  ← 5016 + 5000 = 10016

10. 核心 API 速查表


函数 作用 ISR 安全版本
xTimerCreate() 创建定时器 无需ISR版本
xTimerStart() 启动定时器 xTimerStartFromISR()
xTimerStop() 停止定时器 xTimerStopFromISR()
xTimerReset() 重置/重启定时器 xTimerResetFromISR()
xTimerChangePeriod() 修改定时器周期 xTimerChangePeriodFromISR()
pvTimerGetTimerID() 读取定时器 ID 直接访问,无需ISR版本
vTimerSetTimerID() 设置定时器 ID 直接访问,无需ISR版本
xTimerDelete() 删除定时器 xTimerDeleteFromISR()

11. 总体架构流程图

调用 xTimerStart 等API

守护任务读取命令

处理命令

自动重载定时器

单次定时器

应用任务

定时器命令队列
Timer Command Queue

RTOS 守护任务
Daemon Task

定时器到期?

执行回调函数
Callback Function

继续等待

重新进入运行状态

进入休眠状态

12. 关键概念总结


概念 要点
回调函数 不能阻塞,执行要快
守护任务 所有回调都在这里运行,优先级影响响应时机
命令队列 API 通过队列发命令,带时间戳保证精确性
单次 vs 自动重载 单次执行后休眠,自动重载循环执行
定时器 ID void* 类型,可存计数、指针等,直接访问不经队列
重置 从重置时刻重新开始计时:texpire=treset+Tperiodt_{expire} = t_{reset} + T_{period}texpire=treset+Tperiod
修改周期 运行中修改以修改时刻为基准重算;休眠中修改会自动启动
ISR 安全 中断中必须用 FromISR 后缀的版本

FreeRTOS 第六章:中断管理 — 从零理解

1. 为什么需要中断管理?

嵌入式系统需要响应来自外部环境的事件,例如:

  • 以太网收到数据包 → 需要交给 TCP/IP 栈处理
  • UART 收到字符 → 需要缓存并处理
    处理这类事件时,设计者需要回答三个问题:
  1. 怎么检测事件? 通常用中断,也可以用轮询(polling)
  2. 在 ISR 内做多少处理? 原则:ISR 越短越好
  3. 如何把事件传递给主代码? 需要一套通信机制

2. 任务优先级 vs 中断优先级(重要区别!)

这是初学者最容易混淆的地方:

对比点 任务优先级 中断优先级
谁决定 软件(程序员设定) 硬件(处理器决定)
调度者 FreeRTOS 调度器 处理器硬件
能否被抢占 任务可被更高优先级任务抢占 任务不能抢占 ISR
关系 完全无关 最低优先级的中断也能打断最高优先级的任务

核心结论:只要有 ISR 在执行,所有任务都无法运行,无论任务优先级多高。

3. ISR 专用 API(中断安全 API)

3.1 为什么需要专门的 ISR 版本?

普通 FreeRTOS API 函数可能让"调用它的任务"进入阻塞状态。但在 ISR 中根本没有"任务"在运行,所以不能直接调用普通 API。
FreeRTOS 的解决方案:为关键 API 提供两个版本

版本 适用场合 命名规律
普通版 任务中调用 xSemaphoreGive()
ISR 安全版 中断服务程序中调用 xSemaphoreGiveFromISR()

黄金规则:在 ISR 中永远只用带 FromISR 后缀的函数!

3.2 xHigherPriorityTaskWoken 参数

这是 ISR 版本 API 特有的参数,理解它是关键。
问题背景:
在任务中调用 API,如果解除了某个更高优先级任务的阻塞,调度器会立刻切换到那个任务。但在 ISR 中,不会自动切换,而是通过这个参数来通知程序员:有更高优先级的任务被唤醒了,你需要手动请求上下文切换。
使用模式(固定套路):

步骤1:初始化为 pdFALSE
步骤2:调用 FromISR 函数(它可能把变量改为 pdTRUE)
步骤3:调用 portYIELD_FROM_ISR() 决定是否切换

是:有更高优先级任务被唤醒

否:无需切换

ISR 开始

xHigherPriorityTaskWoken = pdFALSE

调用 xSemaphoreGiveFromISR 等 FromISR 函数

xHigherPriorityTaskWoken
被设为 pdTRUE?

portYIELD_FROM_ISR 请求上下文切换

portYIELD_FROM_ISR 无效果

ISR 退出后直接切换到高优先级任务

ISR 退出后返回原来被中断的任务

3.3 为什么 ISR 中不自动切换上下文?

原因有五点:

  1. 避免不必要的切换:UART 每收到一个字符都切换一次太浪费,应该收完整串再处理
  2. 控制执行顺序:程序员有时需要在特定位置避免切换
  3. 可移植性:这是跨所有 FreeRTOS 平台最简单统一的机制
  4. 效率:小型处理器架构只允许在 ISR 末尾切换,这样可以多次调用 API 但只请求一次切换
  5. tick 中断兼容:避免在 tick 中断中切换时产生问题

3.4 portYIELD_FROM_ISR 宏

// 在 ISR 末尾使用,根据参数决定是否请求上下文切换
portYIELD_FROM_ISR( xHigherPriorityTaskWoken );
// 等价写法(老版本端口用这个):
portEND_SWITCHING_ISR( xHigherPriorityTaskWoken );
  • 参数为 pdFALSE:无效果,ISR 正常返回
  • 参数为 pdTRUE:请求上下文切换,ISR 退出后直接跳到最高优先级就绪任务

4. 中断处理延迟到任务(Deferred Interrupt Processing)

4.1 核心思想

ISR 只做最少的事:记录中断原因、清除中断标志,然后通知一个任务去做真正的处理工作。

传统做法:
  中断发生 → ISR 做所有处理(时间长,影响系统响应)
延迟处理做法:
  中断发生 → ISR 快速通知任务 → ISR 立即退出 → 任务负责处理

时间对比(ASCII 时序图):

         t1          t2    t3        t4
          |           |     |         |
ISR       |           |xxxxx|         |        x = ISR 执行时间(很短)
          |           |     |         |
Task2     |           |     |xxxxxxxxx|        x = 真正处理时间(在任务中)
(处理任务) |           |     |         |
          |           |     |         |
Task1     |xxxxxxxxxxx|     |         |xxxxxxx  = 被中断的普通任务
          |           |     |         |

从 t2 到 t4 是完整处理时间,但在 ISR 内只花了 t2 到 t3 这一小段。

4.2 何时适合延迟处理?


适合延迟到任务 不需要延迟(直接在 ISR 中做)
处理时间较长(如软件滤波) 处理极简单(如存储 ADC 结果)
需要调用无法在 ISR 中用的函数(如写控制台) 对实时性要求极高
处理时间不确定 处理时间固定且极短

5. 二值信号量(Binary Semaphore)实现中断同步

5.1 概念类比

把二值信号量想象成一个长度为 1 的队列

队列为空(信号量不可用)→ 任务等待(阻塞)
ISR 往队列放一个 token   → 任务被唤醒
任务取走 token           → 队列重新变空

这就是"给"(give)和"取"(take)的含义:

  • ISR 给信号量:通知任务"有事情发生了"
  • 任务取信号量:任务知道"有事情发生了,开始处理"

5.2 执行流程

中断服务程序 处理任务 中断服务程序 处理任务 信号量不可用,任务进入阻塞状态 信号量可用,任务从阻塞唤醒 信号量再次不可用,重新阻塞等待 xSemaphoreTake() 等待信号量 中断发生,执行 ISR xSemaphoreGiveFromISR() 释放信号量 成功取到信号量,开始处理事件 处理完毕,再次调用 xSemaphoreTake()

5.3 相关 API

创建二值信号量:

SemaphoreHandle_t xSemaphoreCreateBinary( void );
// 返回 NULL = 堆内存不足,创建失败
// 返回非 NULL = 成功,返回句柄

在任务中取信号量:

BaseType_t xSemaphoreTake(
    SemaphoreHandle_t xSemaphore,  // 信号量句柄
    TickType_t xTicksToWait        // 最长等待时间(tick),portMAX_DELAY = 永久等待
);
// 返回 pdPASS = 成功取到
// 返回 pdFALSE = 超时未取到

在 ISR 中给信号量:

BaseType_t xSemaphoreGiveFromISR(
    SemaphoreHandle_t xSemaphore,
    BaseType_t *pxHigherPriorityTaskWoken  // 是否需要切换上下文
);
// 返回 pdPASS = 成功
// 返回 pdFAIL = 信号量已经可用(已经被给过了),无法再给

5.4 完整示例:二值信号量同步中断与任务

#include <stdio.h>
#include "FreeRTOS.h"
#include "task.h"
#include "semphr.h"
// 中断编号(Windows 端口模拟用,0-2 被系统占用,从 3 开始)
#define mainINTERRUPT_NUMBER  3
// 全局信号量句柄(ISR 和任务都要用到)
static SemaphoreHandle_t xBinarySemaphore;
// -------------------------------------------------------
// 处理任务(延迟中断处理任务)
// 优先级设高,确保 ISR 退出后立刻运行
// -------------------------------------------------------
static void vHandlerTask( void *pvParameters )
{
    for( ;; )
    {
        // 等待信号量,portMAX_DELAY 表示永久等待
        // 实际项目中建议用有限超时,便于检测硬件错误
        xSemaphoreTake( xBinarySemaphore, portMAX_DELAY );
        // 能执行到这里,说明信号量已经被 ISR "给"了
        // 在这里完成真正的中断处理工作
        printf( "处理任务 - 正在处理中断事件\n" );
    }
}
// -------------------------------------------------------
// 周期性任务(每 500ms 触发一次软件中断,模拟硬件中断)
// -------------------------------------------------------
static void vPeriodicTask( void *pvParameters )
{
    const TickType_t xDelay500ms = pdMS_TO_TICKS( 500UL );
    for( ;; )
    {
        // 等待 500ms
        vTaskDelay( xDelay500ms );
        // 打印消息,触发中断
        printf( "周期任务 - 即将触发中断\n" );
        vPortGenerateSimulatedInterrupt( mainINTERRUPT_NUMBER );  // 模拟中断
        printf( "周期任务 - 中断已触发\n\n" );
    }
}
// -------------------------------------------------------
// 中断服务程序(ISR)
// 只做一件事:给信号量,通知任务有事件发生
// -------------------------------------------------------
static uint32_t ulExampleInterruptHandler( void )
{
    BaseType_t xHigherPriorityTaskWoken;
    // 第一步:初始化为 pdFALSE
    // 如果 ISR 中唤醒了更高优先级任务,FromISR 函数会把它改为 pdTRUE
    xHigherPriorityTaskWoken = pdFALSE;
    // 第二步:给信号量,唤醒等待的处理任务
    // 传入 &xHigherPriorityTaskWoken,函数内部会根据需要修改它
    xSemaphoreGiveFromISR( xBinarySemaphore, &xHigherPriorityTaskWoken );
    // 第三步:根据 xHigherPriorityTaskWoken 决定是否请求上下文切换
    // 如果 pdTRUE → 请求切换,ISR 退出后直接跳到处理任务
    // 如果 pdFALSE → 不切换,ISR 退出后回到原来被中断的任务
    portYIELD_FROM_ISR( xHigherPriorityTaskWoken );
    // Windows 端口的 portYIELD_FROM_ISR 内部含 return,这里不用显式 return
}
int main( void )
{
    // 创建二值信号量
    xBinarySemaphore = xSemaphoreCreateBinary();
    if( xBinarySemaphore != NULL )
    {
        // 创建处理任务,优先级 3(高优先级,确保 ISR 后立刻运行)
        xTaskCreate( vHandlerTask, "Handler", 1000, NULL, 3, NULL );
        // 创建周期任务,优先级 1(低优先级,处理任务完成后才运行)
        xTaskCreate( vPeriodicTask, "Periodic", 1000, NULL, 1, NULL );
        // 注册中断处理函数(Windows 端口特有语法)
        vPortSetInterruptHandler( mainINTERRUPT_NUMBER, ulExampleInterruptHandler );
        // 启动调度器
        vTaskStartScheduler();
    }
    for( ;; );
    return 0;
}

运行结果(示意):

周期任务 - 即将触发中断
处理任务 - 正在处理中断事件
周期任务 - 中断已触发
周期任务 - 即将触发中断
处理任务 - 正在处理中断事件
周期任务 - 中断已触发

执行时序(ASCII):

         t1           t2  t3    t4
          |            |   |     |
Idle      |xxxxxxxxxxxx|   |     |xxxxxx
          |            |   |     |
ISR       |            |xxx|     |
          |            |   |     |
Handler   |            |   |xxxxx|      ← ISR 退出后立刻运行(优先级最高)
          |            |   |     |
Periodic  |            |   |     |xxxxx

5.5 二值信号量的局限性

问题场景:中断来得太快,任务还没处理完

场景:第1次中断还没处理完,第2次和第3次中断又来了
第2次中断:信号量为空 → ISR 给信号量 → 事件被"锁存",不会丢失
第3次中断:信号量已满(已经有一个了)→ ISR 给不进去 → 第3次事件丢失!

用 ASCII 展示这个问题:

时间线:
Task:   [处理第1次]------------[处理第2次][等待...]
ISR1:          [give OK]
ISR2:                    [give OK,事件被锁存]
ISR3:                             [give FAIL,事件丢失!]

解决方案:计数信号量(Counting Semaphore)

6. 计数信号量(Counting Semaphore)

6.1 与二值信号量的区别

把计数信号量想象成一个长度大于 1 的队列

二值信号量:队列长度 = 1,最多锁存 1 个事件
计数信号量:队列长度 = N,最多锁存 N 个事件

计数值的含义:计数值=已发生的事件数−已处理的事件数\text{计数值} = \text{已发生的事件数} - \text{已处理的事件数}计数值=已发生的事件数已处理的事件数

6.2 两种用途


用途 初始计数值 说明
计数/锁存事件 0 每次中断加1,每次处理减1
管理资源 = 资源总数 获取资源减1,释放资源加1

6.3 创建计数信号量

SemaphoreHandle_t xSemaphoreCreateCounting(
    UBaseType_t uxMaxCount,     // 最大计数值(队列长度)
    UBaseType_t uxInitialCount  // 初始计数值(通常为 0)
);

6.4 完整示例:计数信号量处理多个事件

#include <stdio.h>
#include "FreeRTOS.h"
#include "task.h"
#include "semphr.h"
#define mainINTERRUPT_NUMBER  3
// 使用计数信号量代替二值信号量
static SemaphoreHandle_t xCountingSemaphore;
// -------------------------------------------------------
// 处理任务(同上,代码相同,信号量变成计数型)
// -------------------------------------------------------
static void vHandlerTask( void *pvParameters )
{
    for( ;; )
    {
        // 等待信号量,每次成功 take 代表处理一个事件
        xSemaphoreTake( xCountingSemaphore, portMAX_DELAY );
        printf( "处理任务 - 处理一个事件\n" );
    }
}
// -------------------------------------------------------
// 周期任务(每 500ms 触发一次中断)
// -------------------------------------------------------
static void vPeriodicTask( void *pvParameters )
{
    const TickType_t xDelay500ms = pdMS_TO_TICKS( 500UL );
    for( ;; )
    {
        vTaskDelay( xDelay500ms );
        printf( "周期任务 - 即将触发中断\n" );
        vPortGenerateSimulatedInterrupt( mainINTERRUPT_NUMBER );
        printf( "周期任务 - 中断已触发\n\n" );
    }
}
// -------------------------------------------------------
// ISR:每次触发时给信号量 3 次,模拟 3 个事件同时发生
// -------------------------------------------------------
static uint32_t ulExampleInterruptHandler( void )
{
    BaseType_t xHigherPriorityTaskWoken;
    xHigherPriorityTaskWoken = pdFALSE;
    // 给信号量 3 次,计数值从 0 变为 3
    // 第1次 give → 唤醒处理任务(如果它在等待)
    // 第2、3次 give → 计数值继续增加,事件被锁存
    xSemaphoreGiveFromISR( xCountingSemaphore, &xHigherPriorityTaskWoken );
    xSemaphoreGiveFromISR( xCountingSemaphore, &xHigherPriorityTaskWoken );
    xSemaphoreGiveFromISR( xCountingSemaphore, &xHigherPriorityTaskWoken );
    // 请求上下文切换(如果有更高优先级任务被唤醒)
    portYIELD_FROM_ISR( xHigherPriorityTaskWoken );
}
int main( void )
{
    // 创建计数信号量,最大计数 10,初始值 0
    xCountingSemaphore = xSemaphoreCreateCounting( 10, 0 );
    if( xCountingSemaphore != NULL )
    {
        xTaskCreate( vHandlerTask, "Handler", 1000, NULL, 3, NULL );
        xTaskCreate( vPeriodicTask, "Periodic", 1000, NULL, 1, NULL );
        vPortSetInterruptHandler( mainINTERRUPT_NUMBER, ulExampleInterruptHandler );
        vTaskStartScheduler();
    }
    for( ;; );
    return 0;
}

运行结果(示意,每次中断产生 3 个事件,全部被处理):

周期任务 - 即将触发中断
处理任务 - 处理一个事件     ← 3 次 take,3 个事件全部处理
处理任务 - 处理一个事件
处理任务 - 处理一个事件
周期任务 - 中断已触发

7. 把中断处理延迟到守护任务(集中式延迟处理)

7.1 方法对比

方法一(之前的方法): 为每个中断单独创建一个处理任务

中断A → 任务A(专门处理A)
中断B → 任务B(专门处理B)
中断C → 任务C(专门处理C)

方法二(集中式): 所有中断都延迟到同一个守护任务处理

中断A → 守护任务
中断B → 守护任务  ← 不需要为每个中断创建任务
中断C → 守护任务

7.2 使用 xTimerPendFunctionCallFromISR()

这个函数把一个普通 C 函数"推送"到守护任务的命令队列,守护任务会在合适时机执行它。

BaseType_t xTimerPendFunctionCallFromISR(
    PendedFunction_t xFunctionToPend,      // 要执行的函数指针
    void *pvParameter1,                    // 传给函数的参数1(void*类型)
    uint32_t ulParameter2,                 // 传给函数的参数2(uint32_t类型)
    BaseType_t *pxHigherPriorityTaskWoken  // 是否需要上下文切换
);

被执行函数的固定原型:

void vPendableFunction( void *pvParameter1, uint32_t ulParameter2 );

优点 缺点
不需要为每个中断创建任务,节省资源 所有延迟函数共用守护任务优先级,无法单独调整
延迟函数是普通 C 函数,简单易写 命令加入队列尾部,实时性不如专用任务

7.3 完整示例:集中式延迟处理

#include <stdio.h>
#include "FreeRTOS.h"
#include "task.h"
#include "timers.h"
#define mainINTERRUPT_NUMBER  3
// -------------------------------------------------------
// 真正执行中断处理逻辑的函数(在守护任务中运行)
// 必须符合固定原型:void func(void*, uint32_t)
// -------------------------------------------------------
static void vDeferredHandlingFunction( void *pvParameter1, uint32_t ulParameter2 )
{
    // pvParameter1 本例未使用
    // ulParameter2 是每次中断传入的递增计数值
    printf( "守护任务中执行延迟处理,参数值: %lu\n", (unsigned long)ulParameter2 );
}
// -------------------------------------------------------
// ISR:不再需要信号量,直接把处理函数"推送"给守护任务
// -------------------------------------------------------
static uint32_t ulExampleInterruptHandler( void )
{
    // static 变量,每次 ISR 执行都递增,用来传递参数
    static uint32_t ulParameterValue = 0;
    BaseType_t xHigherPriorityTaskWoken;
    xHigherPriorityTaskWoken = pdFALSE;
    // 把 vDeferredHandlingFunction 和参数一起发送到守护任务
    // 守护任务会调用:vDeferredHandlingFunction(NULL, ulParameterValue)
    xTimerPendFunctionCallFromISR(
        vDeferredHandlingFunction,     // 要执行的函数
        NULL,                          // pvParameter1,本例不用
        ulParameterValue,              // ulParameter2,传递计数值
        &xHigherPriorityTaskWoken
    );
    ulParameterValue++;  // 计数值递增,下次传递
    portYIELD_FROM_ISR( xHigherPriorityTaskWoken );
}
// -------------------------------------------------------
// 周期任务(优先级必须低于守护任务)
// -------------------------------------------------------
static void vPeriodicTask( void *pvParameters )
{
    const TickType_t xDelay500ms = pdMS_TO_TICKS( 500UL );
    for( ;; )
    {
        vTaskDelay( xDelay500ms );
        printf( "周期任务 - 即将触发中断\n" );
        vPortGenerateSimulatedInterrupt( mainINTERRUPT_NUMBER );
        printf( "周期任务 - 中断已触发\n\n" );
    }
}
int main( void )
{
    // 周期任务优先级 = 守护任务优先级 - 1(确保守护任务优先运行)
    const UBaseType_t ulPeriodicTaskPriority = configTIMER_TASK_PRIORITY - 1;
    // 不需要创建信号量,也不需要创建专门的处理任务!
    xTaskCreate( vPeriodicTask, "Periodic", 1000, NULL, ulPeriodicTaskPriority, NULL );
    vPortSetInterruptHandler( mainINTERRUPT_NUMBER, ulExampleInterruptHandler );
    vTaskStartScheduler();
    for( ;; );
    return 0;
}

执行时序(ASCII):

         t1           t2  t3    t4
          |            |   |     |
Idle      |xxxxxxxxxxxx|   |     |xxxxxx
          |            |   |     |
ISR       |            |xxx|     |
          |            |   |     |
Daemon    |            |   |xxxxx|      ← 守护任务执行延迟函数
          |            |   |     |
Periodic  |            |   |     |xxxxx

8. 在 ISR 中使用队列

8.1 ISR 专用队列 API


普通任务版本 ISR 安全版本
xQueueSendToFront() xQueueSendToFrontFromISR()
xQueueSendToBack() xQueueSendToBackFromISR()
xQueueReceive() xQueueReceiveFromISR()

函数原型:

BaseType_t xQueueSendToBackFromISR(
    QueueHandle_t xQueue,
    void *pvItemToQueue,
    BaseType_t *pxHigherPriorityTaskWoken
);
// 返回 pdPASS = 发送成功
// 返回 errQUEUE_FULL = 队列已满,失败

8.2 完整示例:ISR 中读写队列

整体设计:

IntegerGenerator 任务 → 向 xIntegerQueue 写 5 个整数 → 触发中断
ISR:
  - 从 xIntegerQueue 逐个读取整数
  - 把整数的低 2 位作为索引,选择对应字符串
  - 把字符串指针写入 xStringQueue
StringPrinter 任务 → 从 xStringQueue 读取字符串 → 打印

数据流图:

IntegerGenerator任务
写整数到队列

xIntegerQueue
整数队列

ISR
读整数,写字符串

xStringQueue
字符串指针队列

StringPrinter任务
读字符串并打印

#include <stdio.h>
#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"
#define mainINTERRUPT_NUMBER  3
// 两个队列的全局句柄
static QueueHandle_t xIntegerQueue;  // 存放 uint32_t 整数
static QueueHandle_t xStringQueue;   // 存放 char* 字符串指针
// -------------------------------------------------------
// IntegerGenerator 任务
// 每 200ms 向队列发送 5 个整数,然后触发中断
// -------------------------------------------------------
static void vIntegerGenerator( void *pvParameters )
{
    TickType_t xLastExecutionTime;
    uint32_t ulValueToSend = 0;
    int i;
    xLastExecutionTime = xTaskGetTickCount();
    for( ;; )
    {
        // 精确周期延迟,每 200ms 执行一次
        vTaskDelayUntil( &xLastExecutionTime, pdMS_TO_TICKS( 200 ) );
        // 连续发送 5 个整数到队列(0,1,2,3,4 然后 5,6,7,8,9 ……)
        for( i = 0; i < 5; i++ )
        {
            // block time = 0:队列一定有空间(ISR 每次都清空队列)
            xQueueSendToBack( xIntegerQueue, &ulValueToSend, 0 );
            ulValueToSend++;
        }
        printf( "整数生成任务 - 即将触发中断\n" );
        vPortGenerateSimulatedInterrupt( mainINTERRUPT_NUMBER );
        printf( "整数生成任务 - 中断已触发\n\n" );
    }
}
// -------------------------------------------------------
// StringPrinter 任务
// 等待队列中的字符串指针,收到后打印
// -------------------------------------------------------
static void vStringPrinter( void *pvParameters )
{
    char *pcString;
    for( ;; )
    {
        // 阻塞等待字符串队列中有数据
        xQueueReceive( xStringQueue, &pcString, portMAX_DELAY );
        printf( "%s", pcString );
    }
}
// -------------------------------------------------------
// ISR:从整数队列读数据,转换后写入字符串队列
// -------------------------------------------------------
static uint32_t ulExampleInterruptHandler( void )
{
    BaseType_t xHigherPriorityTaskWoken;
    uint32_t ulReceivedNumber;
    // 字符串数组,用 static const 确保不在栈上,ISR 退出后仍然有效
    static const char *pcStrings[] =
    {
        "String 0\n",
        "String 1\n",
        "String 2\n",
        "String 3\n"
    };
    xHigherPriorityTaskWoken = pdFALSE;
    // 循环读取整数队列,直到队列为空
    while( xQueueReceiveFromISR( xIntegerQueue,
                                  &ulReceivedNumber,
                                  &xHigherPriorityTaskWoken ) != errQUEUE_EMPTY )
    {
        // 取整数的低 2 位(0~3),作为字符串数组下标
        // 例如:收到 5(二进制 101),& 0x03 = 1,选 "String 1"
        ulReceivedNumber &= 0x03;
        // 把对应字符串的指针写入字符串队列
        xQueueSendToBackFromISR( xStringQueue,
                                  &pcStrings[ ulReceivedNumber ],
                                  &xHigherPriorityTaskWoken );
    }
    // xHigherPriorityTaskWoken 可能在上面两个 FromISR 调用中都被设置为 pdTRUE
    // 这没问题,因为 pdTRUE 一旦被设置就不会被清回 pdFALSE
    portYIELD_FROM_ISR( xHigherPriorityTaskWoken );
}
int main( void )
{
    // 创建整数队列:最多 10 个 uint32_t
    xIntegerQueue = xQueueCreate( 10, sizeof( uint32_t ) );
    // 创建字符串队列:最多 10 个 char* 指针
    xStringQueue = xQueueCreate( 10, sizeof( char * ) );
    // IntegerGenerator 任务,优先级 1(低)
    xTaskCreate( vIntegerGenerator, "IntGen", 1000, NULL, 1, NULL );
    // StringPrinter 任务,优先级 2(高于 IntegerGenerator)
    // 确保 ISR 退出后 StringPrinter 立刻运行
    xTaskCreate( vStringPrinter, "String", 1000, NULL, 2, NULL );
    vPortSetInterruptHandler( mainINTERRUPT_NUMBER, ulExampleInterruptHandler );
    vTaskStartScheduler();
    for( ;; );
    return 0;
}

执行时序(ASCII):

         t1            t2  t3        t4         t5
          |             |   |          |          |
Idle      |xxxxxxxxxxxxx|   |          |          |xxxxxxx
          |             |   |          |          |
ISR       |             |xxx|          |          |
          |             |   |          |          |
StrPrinter|             |   |xxxxxxxxxx|          |
          |             |   |          |          |
IntGen    |             |   |          |xxxxxxxxxx|

9. 中断嵌套(Interrupt Nesting)

9.1 数字优先级 vs 逻辑优先级

重要提示(ARM Cortex-M 特别注意):
在 ARM Cortex-M 处理器上,数字越小 = 逻辑优先级越高(数字和逻辑是反的!)
数字优先级小⇒逻辑优先级高(先执行)\text{数字优先级小} \Rightarrow \text{逻辑优先级高(先执行)}数字优先级小逻辑优先级高(先执行)

数字优先级 逻辑优先级 说明
0 最高 最先执行
1 第二高
255 最低 最后执行

9.2 两个关键配置常量

FreeRTOSConfig.h 中配置:

// 内核中断优先级:tick 中断使用,必须设为最低优先级(数字最大)
#define configKERNEL_INTERRUPT_PRIORITY          255
// 系统调用中断优先级上限:能调用 FreeRTOS API 的中断不能高于此优先级
#define configMAX_SYSCALL_INTERRUPT_PRIORITY     3
// (ARM Cortex-M 上,数字 3 表示第 4 高的优先级)

9.3 中断嵌套模型图解

以 7 个中断优先级为例(数字越大逻辑优先级越低):

优先级(数字)  逻辑优先级   能调用FreeRTOS API?   能被临界区屏蔽?
-----------   ----------   -----------------   ---------------
    7           最低         是                  是
    6             |          是                  是
    5             |          是                  是         <- configKERNEL (=1在此例中不准确,仅示意)
    4             |          是                  是
    3             |          是,但这是上限         是
   [configMAX_SYSCALL = 3]
    2           较高          否(不能调用API)      否(不受临界区影响)
    1           次高          否                  否
   [不受内核控制的区域,时序要求极严格的中断放这里]

用规范图示意(按文档中的例子,数字越大逻辑越高):

数字优先级  逻辑优先级    区域说明
---------  ----------   --------
    7         最高       不能用 FreeRTOS API,不受临界区影响
    6          |        不能用 FreeRTOS API,不受临界区影响
    5          |        不能用 FreeRTOS API,不受临界区影响
    4          |        不能用 FreeRTOS API,不受临界区影响
              [configMAX_SYSCALL_INTERRUPT_PRIORITY = 3]
    3          |        可以用 FreeRTOS FromISR API,受临界区影响
    2          |        可以用 FreeRTOS FromISR API,受临界区影响
    1        最低        configKERNEL = 1,tick中断在此

9.4 Cortex-M 的优先级位存储(以 4 位实现为例)

Cortex-M 最多用 8 位表示优先级,但很多芯片只实现高 4 位:

8位寄存器:[ b7 | b6 | b5 | b4 | b3 | b2 | b1 | b0 ]
                  已实现(高4位)    未实现(低4位,通常填1)
例如优先级 binary 101(十进制 5)存储为:
[ 1 | 0 | 1 | 0 | 1 | 1 | 1 | 1 ]  = 十进制 175(但一些库用95)
  ^   ^   ^   ^   ^   ^   ^   ^
  b7  b6  b5  b4  b3  b2  b1  b0
       已实现         未实现(填1)

数学表示:
寄存器值=(逻辑优先级≪4) ∣ 0x0F\text{寄存器值} = (\text{逻辑优先级} \ll 4) \, | \, 0x0F寄存器值=(逻辑优先级4)0x0F
例如逻辑优先级 5(二进制 0101):
寄存器值=(01012≪4) ∣ 0x0F=0101 11112=0x5F=9510\text{寄存器值} = (0101_2 \ll 4) \, | \, 0x0F = 0101\,1111_2 = 0x5F = 95_{10}寄存器值=(010124)0x0F=010111112=0x5F=9510
实际使用中的两种规范:

  • 有些库函数期望已移位的值(直接写寄存器):传入 95
  • 有些库函数期望未移位的值(库内部再移位):传入 5
    configMAX_SYSCALL_INTERRUPT_PRIORITYconfigKERNEL_INTERRUPT_PRIORITY 必须传已移位的值(可直接写入寄存器的格式)。
    configKERNEL_INTERRUPT_PRIORITY 永远设为 255(0xFF),无论实现了多少位。

10. 改进版 UART 接收处理任务

这是生产代码中推荐的结构,解决了两个问题:

  1. 一次处理所有待处理事件(而不是每次 take 只处理一个)
  2. 带超时等待(而不是永久等待,便于检测硬件错误)
#include <stdio.h>
#include "FreeRTOS.h"
#include "task.h"
#include "semphr.h"
// 假设这些函数由 UART 驱动提供
extern int UART_RxCount( void );          // 返回 FIFO 中字符数
extern void UART_ProcessNextRxEvent( void ); // 处理一个字符
extern void UART_ClearErrors( void );        // 清除错误状态
static SemaphoreHandle_t xBinarySemaphore;
static void vUARTReceiveHandlerTask( void *pvParameters )
{
    // 最长等待时间 500ms(根据实际波特率和协议调整)
    const TickType_t xMaxExpectedBlockTime = pdMS_TO_TICKS( 500 );
    for( ;; )
    {
        // 等待信号量,带超时
        if( xSemaphoreTake( xBinarySemaphore, xMaxExpectedBlockTime ) == pdPASS )
        {
            // 成功取到信号量(说明有中断发生)
            // 循环处理 FIFO 中所有待处理字符,不是只处理一个!
            while( UART_RxCount() > 0 )
            {
                UART_ProcessNextRxEvent();  // 每次处理一个字符,FIFO 减少 1
            }
            // 处理完所有字符后,回到循环顶部等待下一次中断
            // 在 xSemaphoreTake 调用之前发生的中断会被锁存在信号量中,不会丢失
        }
        else
        {
            // 超时未收到信号量 → 可能是硬件错误导致中断无法产生
            // 执行错误恢复逻辑
            UART_ClearErrors();
        }
    }
}

11. 核心 API 速查表


函数 用途 注意事项
xSemaphoreCreateBinary() 创建二值信号量 初始为不可用
xSemaphoreCreateCounting(max, init) 创建计数信号量 init=0(计数事件)
xSemaphoreTake(sem, wait) 任务中取信号量 不能在 ISR 中用
xSemaphoreGiveFromISR(sem, &woken) ISR 中给信号量 只能在 ISR 中用
xTimerPendFunctionCallFromISR(fn, p1, p2, &woken) ISR 中把函数推给守护任务 集中式延迟处理
xQueueSendToBackFromISR(q, &data, &woken) ISR 中向队列尾部发数据 只能在 ISR 中用
xQueueReceiveFromISR(q, &data, &woken) ISR 中从队列取数据 只能在 ISR 中用
portYIELD_FROM_ISR(woken) ISR 末尾请求上下文切换 传入 woken 变量

12. 总体架构总结

方式1:专用处理任务

方式2:集中到守护任务

方式3:ISR中直接用队列

硬件中断发生

ISR 执行

选择哪种延迟处理方式?

xSemaphoreGiveFromISR
给信号量

处理任务被唤醒

任务处理所有待处理事件

xTimerPendFunctionCallFromISR
推送函数到守护任务

守护任务执行该函数

xQueueSendToBackFromISR
发送处理结果到队列

接收任务处理结果

portYIELD_FROM_ISR
决定是否切换上下文

Logo

免费领 200 小时云算力,进群参与显卡、AI PC 幸运抽奖

更多推荐