FreeRTOS 系统开发指南
编制日期 2026-10-05
面向 Cortex-M 的纯 RTOS 系统层开发规范 · 剥离 BSP 与硬件驱动代码 · 覆盖任务、同步、内存、配置、低功耗、调试与验证
一句话结论:RTOS 系统层设计的本质,是用「优先级 + 阻塞」把 CPU 时间按截止时间分配出去。 绝大多数线上故障不来自内核,而来自三类人为错误:栈 / 堆容量不足、在 ISR 里调了非 FromISR 的 API、多个 Mutex 的加锁顺序不一致。 把这三件事管住,系统稳定性就解决了八成。
这份文档解决什么
本文只讲 FreeRTOS 系统层面:任务如何划分、优先级如何分配、同步原语怎么选型、堆与栈怎么规划、配置项怎么取舍、低功耗与看门狗怎么做、出问题怎么定位。 所有涉及具体外设的驱动实现(SPI / I2C / UART / 定时器 / 电源模式)都归 BSP 文档,本文只在必要处说明「任务侧该如何与它协作」。
使用约定
- 本文所有 API 以 FreeRTOS Kernel V10.x / V11.x 为准,Cortex-M3/M4/M33 为主要目标;M0/M0+ 无 BASEPRI,临界区行为见 10.3。
- 代码示例只保留系统层逻辑,凡涉及具体寄存器的部分统一写成
/* BSP 提供 */注释。 - 标 红线 的条目为强制约束,标 警惕 的为高频踩坑点。
- 涉及具体芯片参数、时钟频率、外设行为的数值,一律以官方 datasheet 与 FreeRTOS 官方参考手册为准。
1 · 系统架构与分层设计
这一章解决「系统应该切成几块、每块放什么」的问题。分层与任务划分一旦定错,后面所有调试都是在补窟窿。
1.1 四层分层模型
纯 RTOS 系统层面的代码只占中间两层。分层的目的不是好看,而是让内核源码保持零修改——一旦在产品里改了内核,后续升级与问题定位都会失控。
- ① 应用任务层:业务逻辑。每个任务只做一件事,通过消息与其他任务交互,不直接读别人的全局变量。
- ② RTOS 同步封装层:把队列 / 信号量 / 事件组的创建、收发、超时、错误处理封装成统一接口,业务侧只见封装不见内核对象。好处是:换 RTOS、加日志、加统计只改一处。
- ③ 内核层:
FreeRTOS/Source官方源码,只通过FreeRTOSConfig.h配置,红线 不修改任何内核源文件。 - ④ 移植层:
portable/[compiler]/[arch]与heap_x.c。选官方现成的,除非做深度定制(MPU / SMP / 非标准编译器)。 - 驱动 / BSP 层:本指南不涉及。它与任务层的接口约定见 4.2 与 12.3。
直接散落调用 xQueueSend 的项目,几年后必然出现:超时参数五花八门、错误码无人处理、想加统计无从下手。
封装层只需提供 4 类接口——msg_post(dst,id,buf,len,timeout)、msg_recv(q,buf,timeout)、sync_give(h)、sync_take(h,timeout)——内部统一记录失败次数、统一 configASSERT、统一超时默认值。
1.2 任务划分原则
任务划分是最重要的一步。经验准则是:任务不宜过多,每个任务单一职责。
- 先按时间特性切:硬周期(采样、控制)与突发(通信、按键)必须分开;周期不同就不是一个任务。
- 再按功能独立性切:两个功能之间没有共享状态、可独立测试,就可以分开。
- 高优先级任务必须短:只做「取数据 → 判条件 → 投递消息 / 置标志」,耗时活交给低优先级任务。
- 不要做成一个大循环:一个 while(1) 里 switch 十几个状态,等于把裸机超级循环搬进 RTOS,抢占优势全部失效。
- 同优先级可合并:时间特性相同、逻辑耦合紧密的功能,合并成一个任务比拆开更省栈、更少同步。
| 任务类型 | 时间特性 | 建议优先级 | 执行时长上限 | 典型错误 |
|---|---|---|---|---|
| 硬实时控制 | 固定周期,抖动敏感 | 最高档 | < 1 ms | 在循环里做浮点滤波,拖垮整个系统 |
| 事件后置处理 | 突发,由 ISR 触发 | 次高档 | < 5 ms | 把处理逻辑写在 ISR 里 |
| 通信协议栈 | 突发 / 半周期 | 中高档 | < 10 ms | 在回调里做阻塞等待 |
| 周期采集 | 固定周期 | 中档 | < 5 ms | 用 vTaskDelay 做周期,误差累积 |
| 业务状态机 | 事件驱动 | 中档 | < 20 ms | 状态机里塞满阻塞调用,无法响应外部事件 |
| 存储 / 日志 | 低频,可延迟 | 中低档 | 可长 | 与通信任务抢同一个锁,顺序不一致 |
| UI / 慢速轮询 | 低频 | 低档 | < 20 ms | 忙等延时,占满 CPU |
| 系统监控 / 喂狗 | 固定周期 | 低档(不最低) | < 1 ms | 优先级设为最高,死锁时照样喂狗,掩盖故障 |
资源受限的 Cortex-M3/M4(几十 KB RAM)上,6~12 个任务是舒适区间。超过 20 个任务时,栈总量(每任务 TCB ≈ 100 B + 栈)与调度开销都会明显上升,且人的理解成本急剧增加——此时应反思是不是按「功能模块」而不是按「时间特性」拆的。
1.3 优先级分配方法论
优先级的本质是截止时间(deadline)的紧急程度,不是功能的重要程度。日志很重要,但它可以晚 100 ms 写,优先级就该低。
两种可落地的分配方法:
- 速率单调(Rate Monotonic, RM):周期越短,优先级越高。适用于一批纯周期任务,是理论最优的静态优先级分配。
- 截止时间优先(Deadline Monotonic):按「从触发到必须完成的时限」排序,比 RM 更通用,适合混合了突发任务的场景。
按这两条规则排完之后,套用图 2 的八档表即可。
- 优先级之间留空档:不要 1、2、3 连排,建议 1、3、5、7 这样留位,后续插入新任务时不必全局重排。
- 优先级数量越少越省 RAM:
configMAX_PRIORITIES每增加一级,就绪链表数组就多一组;8 级对绝大多数项目足够。 - 喂狗任务不能最高,也不能最低:最高会在高优先级死锁时照常喂狗(掩盖故障),最低会被低优先级饿死(误复位)。建议中低档 + 独立硬件看门狗。
1.4 从裸机超级循环迁移
绝大多数项目是从裸机迁移来的,直接照搬结构会导致「RTOS 用了,但没完全用」。迁移路径如下:
| 裸机写法 | 问题 | RTOS 对应做法 |
|---|---|---|
| while(1) 里顺序调用所有模块 | 任一模块阻塞,全部卡住 | 按时间特性拆成多个任务 |
| 全局标志位 + if 判断 | 轮询消耗 CPU,响应不及时 | 用队列 / 信号量 / 事件组做通知 |
| delay_ms() 软件延时 | CPU 100% 占用 | vTaskDelay / vTaskDelayUntil 让出 CPU |
| 中断里处理全部业务 | 关中断时间过长,丢中断 | ISR 只发信号,业务移到延迟处理任务 |
| 全局数组当缓冲区 | 多处并发写,数据错乱 | 队列传递拷贝,或 Mutex 保护 |
| 状态机靠全局变量切换 | 异常后无法复位 | 每个任务内置状态机,异常自恢复 |
警惕 迁移期最常见的失败模式:把裸机的巨型 while(1) 原封不动塞进一个高优先级任务。 结果所有逻辑仍然串行执行,还额外引入了栈溢出与优先级问题。正确顺序是「先拆时间特性,再拆功能」。
2 · 调度机制与任务状态机
理解调度器怎么切任务,才能回答「为什么我的高优先级任务会被卡住 10 ms」这类问题。
2.1 抢占式调度与时间片轮转
- 抢占式(
configUSE_PREEMPTION = 1):只要更高优先级任务进入就绪态,当前任务立刻被抢占。这是实时性的基础。 - 时间片轮转(
configUSE_TIME_SLICING = 1):同优先级的多个就绪任务按 Tick 平分 CPU。注意它只在同优先级之间生效。 - 合作式(
configUSE_PREEMPTION = 0):只在任务主动让出或阻塞时切换。产品项目几乎不用,除非有极端的确定性要求。
警惕 时间片轮转不会让低优先级任务得到公平份额:只要高优先级任务一直就绪,低优先级任务就一直得不到运行( starving )。 因此高优先级任务里绝不能有忙等循环。
2.2 任务状态机与状态迁移
FreeRTOS 任务有四个稳定状态:Running、Ready、Blocked、Suspended。真正影响实时性的是 Blocked——它是任务「让出 CPU 但仍在等待」的方式。
- Blocked 是好事:任务在等队列 / 信号量 / 事件 / 延时,不占 CPU,事件一到立刻回 Ready。
- Suspended 要慎用:它对调度器不可见,
vTaskSuspend(NULL)自己挂起自己后,若没有别的任务来 resume,就是永久卡死。 - Deleted 后的资源回收由空闲任务完成,因此不能饿死 IDLE(不要写优先级高于 IDLE 的无限循环任务)。
2.3 Cortex-M 上下文切换过程
Cortex-M 上的切换由两个异常协作完成:SysTick 负责判定「该不该切」,PendSV 负责真正执行切换。 PendSV 被设为最低优先级,保证所有其他中断处理完后再切换,避免中断响应被拖慢。系统启动时由 SVC 拉起第一个任务。
2.4 系统节拍与上下文切换开销
configTICK_RATE_HZ 决定时间粒度与开销:
| Tick 频率 | 时间粒度 | 每秒 Tick 中断次数 | 适用场景 |
|---|---|---|---|
| 100 Hz | 10 ms | 100 | 通用默认;低速业务、人机交互 |
| 1 kHz | 1 ms | 1000 | 需要 ms 级延时精度、通信超时精细控制 |
| 10 kHz+ | 0.1 ms | 10000+ | 不推荐:Tick 开销显著,抖动敏感任务应改用硬件定时器 + 任务通知 |
经验值:1 kHz 是多数项目的甜点,10 ms 粒度太粗(一次 vTaskDelay(1) 就是 10 ms),10 kHz 又太贵。真正需要亚毫秒确定性的活(高速采样、精确 PWM 相位)不要靠 Tick,用硬件定时器中断 + 直接任务通知唤醒任务。
一次上下文切换 ≈ 保存/恢复 R4-R11 与栈指针操作,Cortex-M3/M4 上约 1~3 µs(与编译优化、栈位置有关)。1 kHz Tick 下,即使每个 Tick 都切换,开销也 < 0.3%。真正的 CPU 杀手从来不是切换,而是过长的临界区与高频不必要的 Tick 唤醒。
3 · 任务管理
任务创建只是一次函数调用,但栈给多大、句柄存不存、静态还是动态,决定了半年后会不会在客户现场死机。
3.1 任务创建:动态与静态
动态创建原型:
BaseType_t xTaskCreate(TaskFunction_t pxTaskCode,
const char * const pcName, /* 任务名,调试用,长度见 configMAX_TASK_NAME_LEN */
const configSTACK_DEPTH_TYPE usStackDepth, /* 单位 word,不是字节 */
void * const pvParameters, /* 传给任务的参数 */
UBaseType_t uxPriority, /* 0 ~ configMAX_PRIORITIES-1 */
TaskHandle_t * const pxCreatedTask);/* 句柄,可为 NULL */
Cortex-M 上 1 word = 4 字节,usStackDepth = 256 表示 1 KB 栈,不是 256 字节。把单位当字节是最常见的栈溢出根因之一——写 128 以为 128 字节,实际只有 512 字节可用空间被当成 128 字节规划时会直接踩穿。
不同移植的 StackType_t 宽度不同(部分 16 位 / 8 位 MCU 上不是 4 字节),换平台必须复核。
静态创建(推荐用于量产产品):
/* 栈与 TCB 由编译器静态分配,不占堆,不产生碎片 */
static StackType_t g_taskCtrlStack[CTRL_STACK_WORDS]; /* 例如 512 → 2 KB */
static StaticTask_t g_taskCtrlTCB;
TaskHandle_t g_hTaskCtrl;
void task_ctrl_create(void)
{
g_hTaskCtrl = xTaskCreateStatic(
task_ctrl_entry, /* 任务函数 */
"ctrl", /* 名字 */
CTRL_STACK_WORDS, /* 栈深度(word) */
NULL, /* 参数 */
TASK_PRIO_CTRL, /* 优先级 */
g_taskCtrlStack, /* 栈缓冲区 */
&g_taskCtrlTCB); /* TCB 缓冲区 */
configASSERT(g_hTaskCtrl != NULL); /* 静态创建只要参数合法就不会失败,仍建议断言 */
}
| 对比项 | xTaskCreate(动态) | xTaskCreateStatic(静态) |
|---|---|---|
| 内存来源 | FreeRTOS 堆(configTOTAL_HEAP_SIZE) | 编译期静态数组 / 链接器段 |
| 失败可能 | 堆不足时返回 pdFAIL | 参数合法即成功 |
| 碎片 | 反复创建删除会碎片化 | 无碎片 |
| RAM 可见性 | 运行时才知道够不够 | 编译期即可从 map 文件看到占用 |
| 删除后回收 | 空闲任务回收 TCB 与栈 | 不回收(本来就静态) |
| 适用 | 原型验证、任务数可变的场景 | 量产产品、功能安全 / 车规项目的默认选择 |
句柄是任务的「身份证」:删除任务、向它发通知、改优先级、挂起恢复都要用。建议用一个全局句柄表集中管理(如 g_hTaskTable[]),配合一次性创建所有任务的做法——系统启动后不再动态创建 / 删除任务,是最省心的架构。
反复 xTaskCreate / vTaskDelete 会反复从堆里切块与回收,heap_4 虽能合并相邻空闲块,但不同大小的块交替申请仍会留下无法合并的碎片。需要「临时干活」的场景,改成常驻任务 + 队列唤醒,或者用一次性静态创建的任务池。
3.2 栈深度单位与栈大小估算
栈必须留余量,因为最坏路径往往不在正常测试里出现(最坏中断嵌套 + 最深调用链 + printf 同时发生)。
一个可套用的估算过程:
- 先给一个宽松值:例如 512 word(2 KB),先跑起来。
- 跑满所有路径:包括异常分支、错误打印、最坏中断并发、参数边界。
- 读水位:
uxTaskGetStackHighWaterMark(NULL),得到历史最小剩余(单位 word)。 - 反推配置值:
已用 = 配置值 − 水位;目标配置值 ≈ 已用 × 1.3,且水位 ≥ 25%。 - 回归锁死:把最终值写成宏(如
#define CTRL_STACK_WORDS 384),并在监控任务里周期性检查水位,低于阈值上报。
| 栈消耗来源 | 典型量级(Cortex-M4) | 说明 |
|---|---|---|
| 任务切换上下文 | 16 ~ 18 word | R4-R11 + R0-R3 + R12 + LR + PC + xPSR,约 64~72 B |
| 函数调用帧 | 每层 4 ~ 40 word | 参数多、局部数组大时更贵 |
| printf / sprintf 家族 | 75 ~ 200 word | 300 ~ 800 B,且依赖 C 库实现,是最贵的单项 |
| 浮点运算(带 FPU) | 额外 17 word | 惰性压栈下,浮点上下文额外占用 |
| 浮点格式化(%f) | 50 ~ 125 word | newlib 的格式化开销显著 |
| 最坏中断嵌套帧 | 8 word × 嵌套层数 | 硬件自动压栈部分,深度越深越贵 |
| strtod / sscanf 等 C 库 | 50 ~ 125 word | 建议改用轻量实现或禁用在任务里用 |
void task(){ uint8_t buf[1024]; ... } 会一次性吃掉 1 KB 栈,若这个函数还被嵌套调用、或同时在中断里跑,栈瞬间见底。规则:大于 64 字节的缓冲一律声明为 static 或全局,或者改用静态分配的池 / 队列存储。
3.3 栈溢出检测与栈水位
| configCHECK_FOR_STACK_OVERFLOW | 检测方式 | 代价 | 说明 |
|---|---|---|---|
| 0 | 不检测 | 无 | 量产出厂可选,但开发期必须开 |
| 1 | 切换时检查 SP 是否越界 | 小 | 能抓到大部分溢出,但可能漏掉「刚好回到界内」的情况 |
| 2 | 切换时额外检查栈尾部若干 word 是否被改写 | 中(多填 16~20 B + 检查开销) | 最完整,开发阶段强烈推荐 |
/* 栈溢出钩子:一旦触发,立刻定位到是哪个任务 */
void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName)
{
/* 1. 点亮故障灯 / 打印任务名 2. 记录到 noinit 区供复位后读取 3. 复位 */
(void)xTask;
fault_record("STACK_OVERFLOW", pcTaskName);
taskDISABLE_INTERRUPTS();
for (;;) { /* 等待看门狗复位 */ }
}
/* 周期性检查水位(建议在监控任务里做) */
void monitor_check_stack(void)
{
UBaseType_t left = uxTaskGetStackHighWaterMark(g_hTaskCtrl);
if (left < CTRL_STACK_WORDS / 4) { /* 余量不足 25% 时告警 */
log_warn("ctrl stack low: %u words", (unsigned)left);
}
}
- 开发期:
configCHECK_FOR_STACK_OVERFLOW = 2+ 钩子必开;量产可降到 1 或 0,但要在测试阶段用 2 跑完整套用例。 - 水位检查放在监控任务里周期执行,而不是只在启动时看一次——运行几周后才出现的路径才是真风险。
- 栈大小写成具名宏并在文档里登记,禁止散落的魔法数字,否则评审时无法核对。
3.4 任务删除与生命周期
- 任务函数不允许返回:必须是
for(;;)结构,或者最后调用vTaskDelete(NULL)。函数返回会跑飞(部分移植会触发断言)。 - 删除前必须自己释放资源:内核只回收 TCB 与栈(动态创建时),不会帮你关文件、释放 malloc 的内存、注销回调。
- 删除其他任务时,确保它没有持有 Mutex / 信号量,否则这些对象永久锁死。
- 动态创建的任务删除后,回收动作由空闲任务完成;如果 IDLE 长期得不到运行,内存不会释放。
- 静态创建的任务删除后,其栈与 TCB 不会自动回收复用,需要自行管理生命周期。
产品设计上,绝大多数「临时任务」都可以改造成常驻任务 + 消息驱动:任务创建后立刻阻塞在队列上,没有消息就不占 CPU(0% 占用)。这样既省去了删除的复杂度,也避免了碎片与资源泄漏。
3.5 常用 API 与规则
| API | 作用 | 规则与注意 |
|---|---|---|
| vTaskDelay(ticks) | 相对延时,任务进入阻塞态 | 参数是 Tick 数不是 ms;延时起点是调用时刻,循环累积会漂移 |
| vTaskDelayUntil(&prev, inc) | 固定周期延时 | 周期任务首选:以「上次唤醒时刻」为基准,不累积误差 |
| xTaskGetTickCount() | 读取系统 Tick 计数 | 中断里用 FromISR 版本;注意 32 位溢出回绕 |
| vTaskSuspend(h) / vTaskResume(h) | 挂起 / 恢复任务 | 慎用:挂起后对调度器不可见,漏掉 resume 就是永久卡死;ISR 中只能用 xTaskResumeFromISR |
| uxTaskPriorityGet / vTaskPrioritySet | 查询 / 改优先级 | 运行期改优先级会打乱原有优先级设计,除非做优先级继承补偿,否则不建议 |
| vTaskDelete(h) | 删除任务 | 见 3.4;删除自身传 NULL |
| xTaskGetHandle(name) | 按名字取句柄 | 依赖 configUSE_TRACE_FACILITY,且是字符串比较,别在高频路径调 |
| xTaskGetSchedulerState() | 查询调度器状态 | 用于在调度器启动前/后的代码分支里判断 |
| ulTaskNotifyTake / xTaskNotifyWait | 任务通知接收 | 见 3.6 |
| vTaskGetInfo() | 取任务完整状态(含栈水位) | 调试用,需开启 trace 功能 |
while(flag == 0) { } 或 while(1){ delay_ms(1); } 会让任务永远占用 CPU:同优先级的任务拿不到时间片,更低优先级的任务彻底饿死,功耗也降不下来。一切等待都必须走阻塞 API:队列、信号量、事件组、任务通知、vTaskDelay。
vTaskDelay 与 vTaskDelayUntil 的正确用法:
void task_sample_entry(void *arg)
{
TickType_t xLastWakeTime = xTaskGetTickCount();
const TickType_t xPeriod = pdMS_TO_TICKS(10); /* 10 ms 周期 */
for (;;) {
/* 1. 先阻塞到下一个周期点(这一步保证周期不漂移) */
vTaskDelayUntil(&xLastWakeTime, xPeriod);
/* 2. 再做本周期的工作 */
sample_once(); /* 必须远短于 xPeriod */
}
}
警惕 若 sample_once() 的执行时间 超过 周期,vTaskDelayUntil 会立刻返回(补不上),
任务变成 100% 占用。监控任务应周期性检测「本周期是否超时」并上报。
3.6 任务通知 Task Notification
任务通知是最轻量的同步手段:每个任务的 TCB 里自带一个 32 位通知值与状态,不需要额外创建内核对象,速度约为二值信号量的 40% 耗时。
/* ====== 发送侧(任务或 ISR) ====== */
/* 当作轻量二值信号量:给一次通知 */
xTaskNotifyGive(g_hTaskCtrl); /* 任务中 */
vTaskNotifyGiveFromISR(g_hTaskCtrl, &xHigherPriorityTaskWoken); /* ISR 中 */
/* 当作轻量事件组:按位传递 32 位值 */
xTaskNotify(g_hTaskCtrl, (1UL << 3), eSetBits);
/* ====== 接收侧 ====== */
uint32_t ulVal = ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(100)); /* 清零后返回 */
if (ulVal == 0) { /* 超时:没有收到通知 */ }
/* 等待指定位 */
uint32_t bits = 0;
xTaskNotifyWait(0, 0xFFFFFFFF, &bits, portMAX_DELAY);
- 一对一:一个通知只能唤醒一个任务。多个任务等同一事件时必须换信号量 / 事件组。
- 接收端会阻塞,发送端不会:发送不会失败(不会因「满」而阻塞),因此无法做背压——高频发送会丢事件(计数模式下是累加,可能溢出)。
- 不能在 ISR 里用带阻塞的版本:ISR 中只能用
xxxFromISR。
适用:单任务事件通知、ISR → 任务的快速唤醒、替代简单二值信号量。不适用:需要广播、需要传数据块、需要背压的场景。
4 · 中断与任务交互
中断与任务的分界线画在哪里,决定了系统的响应延迟与可维护性。本文只讲系统层规则,具体外设 ISR 由 BSP 实现。
4.1 ISR 中的 API 规则
- 只调用带
FromISR后缀的 API:如xQueueSendFromISR、xSemaphoreGiveFromISR、xTaskNotifyFromISR。 - 绝不能在 ISR 里阻塞:任何带超时参数的 API 都不能用,
portMAX_DELAY更不行。 - 绝不能在 ISR 里用 Mutex:Mutex 有所有权与优先级继承语义,ISR 不是任务,无法被提升优先级。
- 处理
pxHigherPriorityTaskWoken参数:被置位时,ISR 出口调用portYIELD_FROM_ISR(x)。 - 禁止在 ISR 里做堆分配 / 释放:
pvPortMalloc/vPortFree不可重入(heap_1/2/4 用临界区保护,但 ISR 中调用会破坏临界区语义)。 - ISR 要短:经验上限是十几微秒到几十微秒;做不完的部分用延迟处理(4.2)。
void UART_IRQHandler(void)
{
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
uint8_t byte;
byte = uart_read_dr(); /* BSP:清中断、读数据寄存器 */
/* 只做搬运 + 投递,不解析、不打印 */
if (xQueueSendFromISR(g_qUartRx, &byte, &xHigherPriorityTaskWoken) != pdPASS) {
g_uart_rx_drop++; /* 队列满:记录丢弃次数,供监控上报 */
}
portYIELD_FROM_ISR(xHigherPriorityTaskWoken); /* 必须:让高优先级任务立刻运行 */
}
系统不会死机,但被唤醒的高优先级任务要等到下一个 Tick 才运行。1 kHz Tick 下就是最多 1 ms 的额外延迟,100 Hz 下是 10 ms。这类「偶发响应慢」极难定位,因为它只在特定中断时序下出现。
4.2 延迟中断处理
把 ISR 里的重活搬到任务里做,是实时系统的标准做法。两种常见形态:
| 形态 | 做法 | 适用 | 注意 |
|---|---|---|---|
| 守护任务(Daemon) | ISR 投递 → 专用高优先级任务处理 | 大多数场景:串口、按键、ADC | 任务优先级要高于业务任务,但低于硬实时控制 |
| 复用业务任务 | ISR 投递到业务任务自己的队列 | 处理量小、与业务逻辑强耦合 | 若业务任务阻塞在别处,响应会变慢 |
| 中央事件分发 | ISR 发事件组 / 通知 → 消息中心路由 | 多中断源、需要统一处理顺序 | 分发逻辑要短,否则成为瓶颈 |
守护任务模板:
void task_uart_daemon_entry(void *arg)
{
uint8_t byte;
for (;;) {
/* 阻塞等待,无数据时占用 0% CPU */
if (xQueueReceive(g_qUartRx, &byte, portMAX_DELAY) == pdPASS) {
proto_feed(&byte, 1); /* 协议解析(耗时活在这里) */
if (proto_frame_ready()) {
msg_post(MSG_DST_APP, MSG_ID_UART_FRAME, proto_buf(), proto_len(), 0);
}
}
}
}
守护任务唯一的职责就是等这个队列,没有别的活要干,超时没有意义,用 portMAX_DELAY 反而最省(不用每次唤醒算超时)。但见 13.1:一个任务等待多个对象时,禁止无限阻塞,否则其中一个出问题就会连带卡死。
4.3 中断优先级分组与 RTOS 阈值
Cortex-M 的中断优先级数值越小优先级越高,且优先级位数由芯片决定(__NVIC_PRIO_BITS,常见 4 位 → 0~15)。
FreeRTOS 用 BASEPRI 寄存器做「选择性屏蔽」:只屏蔽优先级低于阈值的中断,阈值以上(数值更小)的中断照常响应。
/* 典型 Cortex-M4(4 位优先级)配置片段,详见附录 A */
#define configPRIO_BITS 4
#define configLIBRARY_LOWEST_INTERRUPT_PRIORITY 15
#define configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY 5
#define configKERNEL_INTERRUPT_PRIORITY (configLIBRARY_LOWEST_INTERRUPT_PRIORITY << (8 - configPRIO_BITS))
#define configMAX_SYSCALL_INTERRUPT_PRIORITY (configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY << (8 - configPRIO_BITS))
NVIC 的优先级分组(抢占位数 vs 子优先级位数)由 BSP 设置(如 NVIC_PriorityGroupConfig 或 HAL 的 HAL_NVIC_SetPriorityGrouping)。系统层只约束一件事:凡是会调用 FreeRTOS API 的中断,其抢占优先级数值必须 ≥ configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY。常见错误是把 UART 中断设成 0(最高),然后在里面调 xQueueSendFromISR——这会破坏临界区,属于未定义行为。
M0 / M0+ 的特殊性
Cortex-M0 / M0+ 没有 BASEPRI 寄存器,FreeRTOS 的临界区实现是全局关中断(PRIMASK)。后果:
- 临界区期间所有中断都不响应(不像 M3/M4 只屏蔽阈值以下),关中断时长必须更严格。
- 中断里调用 FreeRTOS API 的约束依然存在,而且风险更高。
- 如果产品对中断延迟敏感,M0 上要特别注意临界区与 Tick 频率的取舍。
4.4 中断侧典型错误
| 错误写法 | 后果 | 正确做法 |
|---|---|---|
| ISR 里调 xQueueSend(非 FromISR) | 破坏内核数据结构,随机死机 | 改 xQueueSendFromISR + portYIELD_FROM_ISR |
| ISR 里调 xSemaphoreTake(阻塞) | ISR 阻塞 = 系统挂死 | ISR 只 give,take 放任务里 |
| ISR 里用 Mutex | 无所有权语义,行为未定义 | 资源保护只在任务侧做 |
| ISR 里 pvPortMalloc / vPortFree | 堆管理不可重入,碎片或崩溃 | 预分配静态缓冲,或交给任务处理 |
| ISR 里 printf | 耗时数百微秒,丢中断、栈风险 | 投递到日志队列,由低优先级任务输出 |
| 忘记清中断标志 | 中断连续触发,系统卡死 | BSP 侧必须先清标志再投递 |
| ISR 优先级设为 0 且调 API | 突破 BASEPRI 屏蔽窗口 | 调整到阈值以下(数值更大) |
| ISR 里操作共享链表不带保护 | 数据错乱 | 用临界区 FromISR 版本,或单生产者单消费者环形缓冲 |