Mastering the FreeRTOS Real Time Kernel学习:FreeRTOS 第一章:FreeRTOS 发行包
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/Source/),所有 demo 工程都共用这份源码。不要移动或重命名这个目录,否则 demo 工程会找不到源文件。
4. 内核源文件详解
4.1 所有端口都需要的公共源文件
各文件说明:
| 文件 | 是否必须 | 作用 |
|---|---|---|
tasks.c |
必须 | 任务创建、删除、调度,是内核核心 |
list.c |
必须 | 内核内部链表,tasks.c 依赖它 |
queue.c |
几乎必须 | 队列和信号量(两者共用这个文件) |
timers.c |
可选 | 软件定时器,用到定时器才加 |
event_groups.c |
可选 | 事件组,用到才加 |
croutine.c |
可选 | 协程(极少使用,已不维护,不推荐) |
4.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/ 目录下。
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位最大=216−1=65535 tick≈65 秒
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位最大=232−1≈4.29×109 tick≈49.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.h 或 portmacro.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. 从零理解整个项目结构
下面用一张图把整章内容串联起来:
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(;;) {
// 死循环,等待看门狗复位
}
}
内存申请失败的完整流程:
四、如何选择合适的方案?
五、完整示例代码(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 完整版(四个状态)
| 状态 | 说明 | 类比 |
|---|---|---|
| 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=100⇒100×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_PRIORITIES−1)
数字越大=优先级越高=越先被调度器选中\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=100⇒Ttick=10 ms
每次 Tick 中断触发时:
- 调度器检查有没有更高优先级的任务就绪
- 检查有没有阻塞中的任务到期了(延时到了)
- 决定下一个运行的任务
毫秒转换为 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)
任务不会被强制打断,只有在以下情况才切换:
- 任务调用阻塞函数(进入 Blocked 状态)
- 任务主动调用
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 抢占式:
| 对比项 | 抢占式 | 协作式 |
|---|---|---|
| 响应速度 | 快(高优先级立刻运行) | 慢(等当前任务主动让出) |
| 资源共享安全性 | 需要互斥锁保护 | 相对安全(不会随意打断) |
| 编程难度 | 需要考虑并发 | 相对简单 |
| 适用场景 | 实时性要求高 | 实时性要求低、资源受限 |
十三、综合流程图
十四、关键 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)
读队列时,如果队列为空:
多个任务等同一个队列时:优先级最高的任务先被唤醒;优先级相同时,等待最久的先唤醒。
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=1∑n队列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 结尾的版本!
十、设计选型总结
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 普通数据传递 | 队列(值拷贝) | 简单安全 |
| 大块数据 | 队列(指针) | 效率高,注意所有权 |
| 多来源数据 | 队列(结构体) | 一个队列搞定,最优 |
| 被迫用多队列 | 队列集 | 避免轮询 |
| 共享最新状态 | 邮箱(Peek) | 多任务可同时读 |
FreeRTOS 第五章:软件定时器管理 — 从零理解
1. 什么是软件定时器?
软件定时器就像一个"闹钟":
- 你设定好时间(period,周期)
- 时间到了,它自动调用一个函数(callback,回调函数)
- 整个过程完全由 FreeRTOS 内核管理,不需要任何硬件定时器支持
关键优点:如果没有回调函数在执行,软件定时器不占用任何 CPU 时间。
启用软件定时器的两个步骤
- 把
FreeRTOS/Source/timers.c加入你的工程 - 在
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) | 定时器在倒计时,到期后执行回调 |
状态转换图
自动重载定时器:
单次定时器:
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. 总体架构流程图
12. 关键概念总结
| 概念 | 要点 |
|---|---|
| 回调函数 | 不能阻塞,执行要快 |
| 守护任务 | 所有回调都在这里运行,优先级影响响应时机 |
| 命令队列 | API 通过队列发命令,带时间戳保证精确性 |
| 单次 vs 自动重载 | 单次执行后休眠,自动重载循环执行 |
| 定时器 ID | void* 类型,可存计数、指针等,直接访问不经队列 |
| 重置 | 从重置时刻重新开始计时:texpire=treset+Tperiodt_{expire} = t_{reset} + T_{period}texpire=treset+Tperiod |
| 修改周期 | 运行中修改以修改时刻为基准重算;休眠中修改会自动启动 |
| ISR 安全 | 中断中必须用 FromISR 后缀的版本 |
FreeRTOS 第六章:中断管理 — 从零理解
1. 为什么需要中断管理?
嵌入式系统需要响应来自外部环境的事件,例如:
- 以太网收到数据包 → 需要交给 TCP/IP 栈处理
- UART 收到字符 → 需要缓存并处理
处理这类事件时,设计者需要回答三个问题:
- 怎么检测事件? 通常用中断,也可以用轮询(polling)
- 在 ISR 内做多少处理? 原则:ISR 越短越好
- 如何把事件传递给主代码? 需要一套通信机制
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() 决定是否切换
3.3 为什么 ISR 中不自动切换上下文?
原因有五点:
- 避免不必要的切换:UART 每收到一个字符都切换一次太浪费,应该收完整串再处理
- 控制执行顺序:程序员有时需要在特定位置避免切换
- 可移植性:这是跨所有 FreeRTOS 平台最简单统一的机制
- 效率:小型处理器架构只允许在 ISR 末尾切换,这样可以多次调用 API 但只请求一次切换
- 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 执行流程
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 读取字符串 → 打印
数据流图:
#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}寄存器值=(01012≪4)∣0x0F=010111112=0x5F=9510
实际使用中的两种规范:
- 有些库函数期望已移位的值(直接写寄存器):传入 95
- 有些库函数期望未移位的值(库内部再移位):传入 5
configMAX_SYSCALL_INTERRUPT_PRIORITY和configKERNEL_INTERRUPT_PRIORITY必须传已移位的值(可直接写入寄存器的格式)。configKERNEL_INTERRUPT_PRIORITY永远设为 255(0xFF),无论实现了多少位。
10. 改进版 UART 接收处理任务
这是生产代码中推荐的结构,解决了两个问题:
- 一次处理所有待处理事件(而不是每次 take 只处理一个)
- 带超时等待(而不是永久等待,便于检测硬件错误)
#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. 总体架构总结
更多推荐

所有评论(0)