前置:已完成 Week 2 Day 1~Day 5(Queue、Semaphore、Mutex、Event Group)
1. 任务通知(Task Notification)
1.1 实验目标
将 Day 3 的"串口中断 + 二值信号量"模型替换为 Task Notification 实现,验证 FreeRTOS 最轻量同步机制的可行性。
1.2 机制说明
每个任务在创建时自带一个 32 位通知值(Notification Value) 与一个 通知状态。无需额外创建队列、信号量或事件组,直接对目标任务发送通知即可。
| 特性 | Task Notification | 二值信号量 | Mutex |
|---|---|---|---|
| 资源开销 | 零(内嵌于任务 TCB) | 需动态分配 TCB + 队列结构 | 需动态分配 TCB + 优先级继承结构 |
| 执行速度 | 最快(约 45% 优于信号量,官方数据) | 中等 | 较慢(含优先级继承逻辑) |
| 功能 | 32 位数值传递、单次通知、覆写/累加 | 仅 0/1 状态 | 互斥 + 优先级继承 |
| 发送方 | 任意任务或中断 | 任意任务或中断 | 仅持有任务(释放) |
| 接收方 | 仅目标任务可读取 | 任意等待任务 | 任意等待任务 |
| 阻塞限制 | 支持超时等待 | 支持超时等待 | 支持超时等待 |
1.3 关键代码(CMSIS-RTOS V2)
中断回调(替代 osSemaphoreRelease):
| |
任务接收(替代 osSemaphoreAcquire):
| |
1.4 与二值信号量的差异
- 信号量:独立内核对象,可广播给多个等待任务,适合"生产者-多消费者"模型。
- 任务通知:绑定到特定任务 TCB,一次通知只能被该任务消费,适合"一对一"快速同步。若多个任务等待同一通知,行为未定义。
2. 软件定时器(Software Timer)
2.1 实验目标
创建一个 5 秒周期 的软件定时器,回调函数内打印系统运行时间。验证回调函数不可阻塞的特性,并对比其与"周期性任务 + osDelay"的差异。
2.2 机制说明
软件定时器基于 系统 Tick 实现,由 Timer Service Task(Daemon Task) 统一调度。所有定时器回调均在该任务上下文中串行执行。
| |
| |
2.3 与周期性任务的对比
| 维度 | 软件定时器 | 周期性任务(Task + osDelay) |
|---|---|---|
| 执行上下文 | Timer Service Task(共享栈) | 独立任务(独立栈) |
| 栈空间 | 共享定时器服务任务栈(默认较小) | 独立分配(如 2 KB) |
| 阻塞限制 | 绝对禁止 | 允许任意阻塞 |
| 调度开销 | 到点后回调直接调用,无额外任务切换(若回调内不触发调度) | 每次 osDelay 到期需完整上下文切换 |
| 多实例并发 | 多个定时器串行执行于同一任务 | 多个任务并行(由调度器分时/抢占) |
| 精度 | 基于 Tick(默认 1 ms),精度 ±1 Tick | 同样基于 Tick,精度一致 |
| 适用场景 | 极简、非阻塞的周期性操作(LED 闪烁、状态机 tick、喂狗) | 复杂、可能阻塞的周期性逻辑(数据采集、网络通信、PID 计算) |
2.4 常见问题
在回调中调用 osDelay:
| |
Timer Service Task 被阻塞,所有软件定时器均停止响应,系统出现"定时器集体无响应"现象。
正确做法:若需阻塞操作,应在回调中设置标志或释放信号量,由独立任务完成阻塞逻辑。
3. 临界区(Critical Section)与调度器控制
3.1 实验目标
对比三种保护机制:临界区(关中断)、挂起调度器、Mutex,在 10 ms 忙等场景下观察中断响应差异。
3.2 三种保护机制对比
| 机制 | API | 原理 | 中断影响 | 适用时长 | 适用场景 |
|---|---|---|---|---|---|
| 临界区 | taskENTER_CRITICAL() / taskEXIT_CRITICAL() | 关中断(提升 BASEPRI 或置位 PRIMASK) | 全局中断被屏蔽 | 极短(几微秒) | 极短原子操作、访问中断也参与的全局变量 |
| 挂起调度器 | vTaskSuspendAll() / xTaskResumeAll() | 停止任务调度,但不关中断 | 中断正常响应,仅禁止任务切换 | 中等(毫秒级) | 较长临界段,需中断继续工作 |
| Mutex | osMutexAcquire() / osMutexRelease() | 调度器层面阻塞等待 | 不影响中断 | 较长(毫秒级) | 任务间共享资源保护,支持递归/优先级继承 |
3.3 关键代码
临界区:
| |
挂起调度器:
| |
4. 栈溢出检测(Stack Overflow Detection)
4.1 机制说明
FreeRTOS 提供两种栈溢出检测模式:
| 模式 | 配置值 | 原理 | 检测时机 | 覆盖率 |
|---|---|---|---|---|
| 模式 1 | 1 | 检查栈指针是否越过栈底 | 任务切换时 | 低(仅检测极端越界) |
| 模式 2 | 2 | 在栈底写入固定魔数(0xA5),检查是否被改写 | 任务切换时 | 高(检测渐进式溢出) |
4.2 关键代码
FreeRTOSConfig.h:
| |
钩子函数:
| |
4.3 验证方法
将某任务栈大小从 512 * 4 缩小至 128 * 4,并在任务内定义大数组或深层嵌套调用,触发钩子函数命中。
5. 实时性问题与高级概念速查
5.1 优先级翻转(Priority Inversion)
定义:高优先级任务因等待低优先级任务持有的 Mutex 而被阻塞,此时中等优先级任务抢占低优先级任务,导致高优先级任务被间接阻塞。
实例:
系统中有三个任务:
- Task_H(High,优先级 40):电机控制任务,每 1 ms 执行一次 PID 计算。
- Task_M(Medium,优先级 30):日志记录任务,每 100 ms 写一次 SD 卡。
- Task_L(Low,优先级 20):传感器初始化任务,上电时执行一次 I2C 总线扫描。
三个任务共享一个 Mutex 保护 I2C 总线访问。
时序:
| 时间点 | 发生的事 |
|---|---|
| T=0 | Task_L 获取 I2C Mutex,开始执行 I2C 总线扫描(预计耗时 50 ms) |
| T=10 | Task_H 就绪(PID 周期到),优先级最高,抢占 CPU |
| T=10 | Task_H 尝试获取 I2C Mutex(需读取电机编码器),发现被 Task_L 持有,阻塞等待 |
| T=10 | 调度器选择就绪态中优先级最高的 Task_M(优先级 30) |
| T=10~60 | Task_M 执行 50 ms 的 SD 卡日志写入 |
| T=60 | Task_M 阻塞(等下一次 100 ms 周期) |
| T=60 | Task_L 继续执行,释放 I2C Mutex |
| T=60 | Task_H 获取 Mutex,开始 PID 计算 |
问题:Task_H 从 T=10 到 T=60 被延迟了 50 ms,但 Task_H 的周期只有 1 ms。Task_M(优先级 30)夹在中间执行了 50 ms,导致高优先级 Task_H 被间接饿死。
根本原因:Task_L 持有 Mutex 时,被 Task_M 抢占,Task_M 执行期间 Task_H 无法获取 Mutex,只能空等。
解决方案——优先级继承:
当 Task_H 在 T=10 尝试获取 Mutex 时,FreeRTOS 检测到 Mutex 被 Task_L 持有,临时将 Task_L 的优先级提升至 40(与 Task_H 相同)。
修正后的时序:
| 时间点 | 发生的事 |
|---|---|
| T=0 | Task_L 获取 I2C Mutex,开始执行(优先级临时提升前仍为 20) |
| T=10 | Task_H 就绪,尝试获取 Mutex,发现被 Task_L 持有 |
| T=10 | 优先级继承触发:Task_L 优先级临时提升至 40 |
| T=10 | Task_M 就绪,但 Task_L 优先级(40)> Task_M(30),Task_M 无法抢占 |
| T=10~50 | Task_L 以优先级 40 继续执行,直到释放 Mutex |
| T=50 | Task_L 释放 Mutex,优先级恢复为 20 |
| T=50 | Task_H 获取 Mutex,立即执行 PID 计算 |
| T=50 | Task_M 开始执行日志写入 |
结果:Task_H 的延迟从 50 ms 缩短至 40 ms,且 Task_M 未在中间插队。优先级继承确保 Mutex 持有者以最高等待者的优先级执行,快速释放资源。
注意:二值信号量无优先级继承机制,不能替代 Mutex 保护共享资源。若上述场景使用二值信号量,Task_H 仍会被延迟 50 ms。
5.2 死锁(Deadlock)
定义:两个或多个任务互相等待对方持有的资源,形成循环等待,所有任务均无法继续执行。
实例:
系统中有两个任务和两个 Mutex:
- Task_A:负责电机控制,需先获取 Mutex_Motor(保护 PWM 寄存器),再获取 Mutex_Sensor(保护编码器 I2C)。
- Task_B:负责数据上传,需先获取 Mutex_Sensor(读取编码器数据),再获取 Mutex_Motor(检查电机状态)。
时序:
| 时间点 | Task_A | Task_B |
|---|---|---|
| T=0 | 获取 Mutex_Motor(成功) | 就绪 |
| T=1 | 执行电机控制代码 | 获取 Mutex_Sensor(成功) |
| T=2 | 尝试获取 Mutex_Sensor | 执行数据打包 |
| T=2 | Mutex_Sensor 被 Task_B 持有,Task_A 阻塞等待 | 尝试获取 Mutex_Motor |
| T=2 | 阻塞 | Mutex_Motor 被 Task_A 持有,Task_B 阻塞等待 |
| T=2~∞ | 等待 Mutex_Sensor | 等待 Mutex_Motor |
结果:Task_A 持有 Mutex_Motor 等 Mutex_Sensor,Task_B 持有 Mutex_Sensor 等 Mutex_Motor,形成循环等待。两个任务永远阻塞,系统"假死"。
四个必要条件:
- 互斥(Mutual Exclusion):Mutex_Motor 和 Mutex_Sensor 同一时间只能被一个任务持有。
- 占有且等待(Hold and Wait):Task_A 持有 Mutex_Motor 的同时等待 Mutex_Sensor;Task_B 持有 Mutex_Sensor 的同时等待 Mutex_Motor。
- 不可抢占(No Preemption):Mutex 不能被强制释放,必须由持有者主动释放。
- 循环等待(Circular Wait):Task_A → Mutex_Sensor → Task_B → Mutex_Motor → Task_A。
避免方法:
方法一:全局固定顺序获取
所有任务按相同顺序获取 Mutex:
| |
若 Task_A 和 Task_B 都按"先 Sensor 后 Motor"的顺序获取,上述死锁场景不会发生:
- Task_A 在 T=0 获取 Mutex_Motor 后,T=2 尝试获取 Mutex_Sensor 时,若 Task_B 已持有 Mutex_Sensor,Task_A 阻塞。
- Task_B 在 T=1 获取 Mutex_Sensor 后,T=2 尝试获取 Mutex_Motor 时,发现被 Task_A 持有,Task_B 阻塞。
- 但 Task_B 不会持有 Mutex_Motor 同时等 Mutex_Sensor(因为顺序是 Sensor→Motor),循环等待被打破。
方法二:设置获取超时(Try-Lock)
| |
超时后释放已持有的锁,避免无限等待。
方法三:使用递归 Mutex(Recursive Mutex)
同任务重复获取同一 Mutex 不会阻塞,但此方法仅解决"自锁",不解决"交叉锁"。
5.3 挂起调度器(Scheduler Suspend)
| |
与临界区区别:挂起调度器不关中断,仅阻止任务切换,适合保护较长临界段且需中断继续工作的场景。
5.4 运行时统计(Run Time Stats)
| |
通过 vTaskGetRunTimeStats() 获取每个任务的 CPU 占用率,用于性能调优和任务优先级合理性验证。需提供一个 32 位定时器作为时间基准。
5.5 内存管理(heap_1 ~ heap_5)
| 算法 | 特点 | 适用场景 |
|---|---|---|
| heap_1 | 只分配不释放,无碎片 | 从不删除任务/队列的静态系统 |
| heap_2 | 最佳匹配算法,允许释放,但不合并相邻空闲块 | 简单场景,但易产生碎片 |
| heap_3 | 直接调用标准库malloc/free,非线程安全 | 不推荐,需外部保证线程安全 |
| heap_4 | 首次适应 +合并相邻空闲块,碎片少 | 通用场景,默认推荐 |
| heap_5 | heap_4 的扩展,支持跨多个非连续内存区 | 有多个 SRAM 块或外部 RAM 的复杂系统 |
5.6 静态分配(Static Allocation)
| |
优势:无动态内存分配开销,确定性更强,TCB 和栈地址固定,适合汽车电子、航空等安全关键领域。
5.7 任务删除(vTaskDelete)
| |
任务删除后,其 TCB 和栈内存不会立即释放,需由**空闲任务(Idle Task)**在空闲时回收。因此 vTaskDelete 后应确保空闲任务有机会执行,或手动处理内存回收。
5.8 流缓冲区(Stream Buffer)与消息缓冲区(Message Buffer)
| 特性 | Stream Buffer | Message Buffer |
|---|---|---|
| 引入版本 | FreeRTOS 10+ | FreeRTOS 10+ |
| 数据模型 | 字节流(无边界) | 消息边界(变长数据包) |
| 读写限制 | 仅支持单读单写 | 仅支持单读单写 |
| 开销 | 极低(无队列结构) | 极低(基于 Stream Buffer) |
| 适用场景 | 单生产者-单消费者字节流(如 DMA 串口) | 单生产者-单消费者变长消息 |
多生产者/多消费者场景必须使用 Queue,Stream/Message Buffer 不支持并发保护。
5.9 Tick Hook
| |
Tick Hook 在中断上下文执行,必须极短(几微秒),不能阻塞。因每个 Tick 都触发,过度使用会导致系统开销激增,实际工程中极少使用。
5.10 协程(Co-routine)
状态:已过时。FreeRTOS 9 以后基本废弃,被任务(Task)全面取代。
若被问及,可答:“协程是 FreeRTOS 早期提供的轻量级协作式调度单元,栈共享,切换开销更小。但功能受限,已过时,现代项目直接使用任务即可。”
6. 面试问答
Q1:任务通知与二值信号量,工程上如何选型?
答:一对一快速同步优先选 Task Notification,零额外开销,速度比信号量快约 45%。多生产者-多消费者或需广播给多个等待任务时,必须选二值信号量。任务通知绑定到特定任务 TCB,多个任务等待同一通知时行为未定义。
Q2:软件定时器回调中执行了 osDelay,系统会出现什么现象?
答:Timer Service Task 被阻塞,所有软件定时器均停止响应,系统出现"定时器集体无响应"现象。软件定时器回调运行于统一的 Timer Service Task 上下文,任何阻塞操作都会冻结全部定时器。若需阻塞逻辑,应在回调中设置事件标志或释放信号量,由独立任务完成阻塞操作。
Q3:临界区、挂起调度器、Mutex,三种保护机制在工程上如何选型?
答:临界区关中断,适合极短原子操作(几微秒),但会延迟全局中断响应。挂起调度器只停任务切换,不关中断,适合较长临界段(毫秒级),中断仍可正常响应。Mutex 在调度器层面阻塞,不影响中断,只阻塞等锁的任务,适合任务间共享资源保护,且支持优先级继承。选型依据:操作时长、是否涉及中断、是否需要递归获取。
Q4:系统中运行三个任务:任务 A 负责温度传感器读取并存入缓冲区,任务 B 负责监测并处理任务 A 的数据,通过串口输出,一旦数据异常需触发任务 C 应急处理。如何设计任务协作方案?
答:推荐方案:
- 任务 A 与任务 B 之间使用 Queue 传递传感器数据(支持背压,A 速度快时自动缓冲)。
- 任务 B 检测到异常时,通过 Event Group 设置异常标志,任务 C 阻塞等待该标志(或直接使用 Task Notification 向任务 C 发送通知)。
- 任务 B 的串口输出使用 Mutex 保护,防止与其他任务的串口输出穿插。
- 若任务 A 的读取由中断触发(如 ADC DMA 完成中断),中断中仅释放信号量或发送任务通知,实际读取与入队操作放在任务 A 中执行,避免中断中执行浮点或复杂逻辑。
Q5:温度传感器数据需要经过"采集 → 滤波 → 校准 → 单位转换 → 发布"五个阶段,每个阶段由独立任务处理。如何设计数据流?
答:使用 多级 Queue 流水线 架构:
- 阶段 1(采集)→ Queue_Raw → 阶段 2(滤波)
- 阶段 2(滤波)→ Queue_Filtered → 阶段 3(校准)
- 阶段 3(校准)→ Queue_Calibrated → 阶段 4(转换)
- 阶段 4(转换)→ Queue_Final → 阶段 5(发布)
每个阶段任务从上游 Queue 阻塞获取数据,处理完成后送入下游 Queue。Queue 长度根据各阶段处理速度差异配置(如采集快于滤波,Queue_Raw 长度设大)。此架构解耦各阶段,单阶段故障不会阻塞其他阶段(只要 Queue 未满)。
Q6:调试器显示所有任务状态均为 eReady,但某任务实际无响应,如何排查?
答:此现象通常由 调度锁嵌套未释放 导致。检查该任务是否调用了 vTaskSuspendAll() 且未配对 xTaskResumeAll()。若该任务在调度锁内被中断打断,且中断中触发了更高优先级任务就绪,恢复调度器时可能因嵌套锁未释放而无法切换。排查方法:
- 检查所有
vTaskSuspendAll()与xTaskResumeAll()是否严格配对。 - 检查中断服务程序中是否调用了非 ISR 安全的 API(如
xQueueSend而非xQueueSendFromISR)。 - 使用逻辑分析仪抓取 NVIC 寄存器快照,确认 PendSV 是否被同级中断抢占。
Q7:STM32 的 NVIC 优先级分组如何与 FreeRTOS 的中断管理配合?
答:FreeRTOS 要求所有可调用 RTOS API 的中断,其抢占优先级数值必须大于或等于 configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY(即优先级更低)。例如:
- NVIC 优先级分组为 Group 4(16 级抢占优先级,0~15)。
configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY设为 5。- 则使用
FromISRAPI 的中断(如 UART、EXTI)抢占优先级必须设为 5~15。 - 不调用 RTOS API 的高紧急中断(如电机过流保护)可设为 0~4,FreeRTOS 关中断时仍响应。
若配置错误(如 UART 中断优先级设为 2 却调用了 xQueueSendFromISR),调用时可能触发 HardFault。
Q8:任务中使用 HAL_UART_Transmit 发送大量数据,系统出现卡顿,如何优化?
答:HAL_UART_Transmit 是阻塞式 API,发送期间占用 CPU 空等,违背 RTOS"让出 CPU"的设计原则。优化方案:
- 启用 UART TX Complete 中断(
__HAL_UART_ENABLE_IT(&huart1, UART_IT_TC))。 - 中断服务函数中调用
xSemaphoreGiveFromISR()通知发送完成。 - 任务中使用
xSemaphoreTake()等待发送完成,等待期间 CPU 可执行其他任务。 - 或改用 DMA + 空闲中断模式,发送完成后由 DMA 中断释放信号量,任务零阻塞等待。
Q9:heap_4 与 heap_2 的工程差异是什么?为什么推荐 heap_4?
答:heap_2 采用最佳匹配算法,释放内存后不合并相邻空闲块,长时间运行后产生大量内存碎片,导致大内存分配失败。heap_4 采用首次适应算法,并合并相邻空闲块,显著减少碎片,适合通用动态分配场景。heap_5 是 heap_4 的扩展,支持跨多个非连续内存区(如内部 SRAM + 外部 SDRAM)。安全关键领域(汽车电子)通常禁用动态分配,改用静态分配(xTaskCreateStatic)。
Q10:如何检测 FreeRTOS 任务栈溢出?生产环境中如何定位溢出任务?
答:配置 configCHECK_FOR_STACK_OVERFLOW = 2,FreeRTOS 在任务栈底写入魔数 0xA5,每次任务切换时检查魔数是否被覆盖。若溢出,调用 vApplicationStackOverflowHook,传入任务句柄与任务名称。生产环境中可在钩子函数内:
- 记录任务名到非易失存储(如 Flash 或 EEPROM)。
- 点亮错误指示灯或触发复位。
- 通过
uxTaskGetStackHighWaterMark()在日常运行中监控各任务栈使用水位,提前预警。