前置:已完成 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)

1
2
3
4
5
6
7
8
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
    if (huart->Instance == USART1)
    {
        osThreadFlagsSet(_task_cmd_id, 0x01);
        HAL_UART_Receive_IT(&huart1, &rx_byte, 1);
    }
}

任务接收(替代 osSemaphoreAcquire)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
static void _task_cmd_handler(void *argument)
{
    (void)argument;
    for (;;)
    {
        uint32_t flags = osThreadFlagsWait(0x01, osFlagsWaitAny, osWaitForever);
        if ((flags & 0x01) != 0)
        {
            /* 处理 rx_byte */
        }
    }
}

1.4 与二值信号量的差异

  • 信号量:独立内核对象,可广播给多个等待任务,适合"生产者-多消费者"模型。
  • 任务通知:绑定到特定任务 TCB,一次通知只能被该任务消费,适合"一对一"快速同步。若多个任务等待同一通知,行为未定义。

2. 软件定时器(Software Timer)

2.1 实验目标

创建一个 5 秒周期 的软件定时器,回调函数内打印系统运行时间。验证回调函数不可阻塞的特性,并对比其与"周期性任务 + osDelay"的差异。

2.2 机制说明

软件定时器基于 系统 Tick 实现,由 Timer Service Task(Daemon Task) 统一调度。所有定时器回调均在该任务上下文中串行执行。

1
2
3
4
5
6
7
8
/* 回调函数:不可阻塞 */
static void _timer_stats_callback(void *argument)
{
    (void)argument;
    const char msg[] = "[TIMER] 5s tick\r\n";
    HAL_UART_Transmit(&huart1, (uint8_t *)msg, strlen(msg), 200);
    /* 严禁调用 osDelay、osSemaphoreAcquire、osMessageQueueGet 等阻塞 API */
}
1
2
3
4
5
6
/* 创建并启动 */
static osTimerId_t _timer_stats_id = NULL;

/* 在 MX_FREERTOS_Init 中 */
_timer_stats_id = osTimerNew(_timer_stats_callback, osTimerPeriodic, NULL, NULL);
osTimerStart(_timer_stats_id, 5000);

2.3 与周期性任务的对比

维度软件定时器周期性任务(Task + osDelay)
执行上下文Timer Service Task(共享栈)独立任务(独立栈)
栈空间共享定时器服务任务栈(默认较小)独立分配(如 2 KB)
阻塞限制绝对禁止允许任意阻塞
调度开销到点后回调直接调用,无额外任务切换(若回调内不触发调度)每次 osDelay 到期需完整上下文切换
多实例并发多个定时器串行执行于同一任务多个任务并行(由调度器分时/抢占)
精度基于 Tick(默认 1 ms),精度 ±1 Tick同样基于 Tick,精度一致
适用场景极简、非阻塞的周期性操作(LED 闪烁、状态机 tick、喂狗)复杂、可能阻塞的周期性逻辑(数据采集、网络通信、PID 计算)

2.4 常见问题

在回调中调用 osDelay

1
2
3
4
static void _timer_callback(void *argument)
{
    osDelay(100);  /* 错误 */
}

Timer Service Task 被阻塞,所有软件定时器均停止响应,系统出现"定时器集体无响应"现象。

正确做法:若需阻塞操作,应在回调中设置标志或释放信号量,由独立任务完成阻塞逻辑。


3. 临界区(Critical Section)与调度器控制

3.1 实验目标

对比三种保护机制:临界区(关中断)挂起调度器Mutex,在 10 ms 忙等场景下观察中断响应差异。

3.2 三种保护机制对比

机制API原理中断影响适用时长适用场景
临界区taskENTER_CRITICAL() / taskEXIT_CRITICAL()关中断(提升 BASEPRI 或置位 PRIMASK)全局中断被屏蔽极短(几微秒)极短原子操作、访问中断也参与的全局变量
挂起调度器vTaskSuspendAll() / xTaskResumeAll()停止任务调度,但不关中断中断正常响应,仅禁止任务切换中等(毫秒级)较长临界段,需中断继续工作
MutexosMutexAcquire() / osMutexRelease()调度器层面阻塞等待不影响中断较长(毫秒级)任务间共享资源保护,支持递归/优先级继承

3.3 关键代码

临界区

1
2
3
4
taskENTER_CRITICAL();
/* 极短操作:修改共享计数器、标志位 */
g_shared_counter++;
taskEXIT_CRITICAL();

挂起调度器

1
2
3
4
vTaskSuspendAll();
/* 较长操作:遍历链表、批量内存拷贝 */
/* 期间中断仍响应,但任务调度暂停 */
xTaskResumeAll();

4. 栈溢出检测(Stack Overflow Detection)

4.1 机制说明

FreeRTOS 提供两种栈溢出检测模式:

模式配置值原理检测时机覆盖率
模式 11检查栈指针是否越过栈底任务切换时低(仅检测极端越界)
模式 22在栈底写入固定魔数(0xA5),检查是否被改写任务切换时高(检测渐进式溢出)

4.2 关键代码

FreeRTOSConfig.h

1
#define configCHECK_FOR_STACK_OVERFLOW  2

钩子函数

1
2
3
4
5
6
void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName)
{
    /* 触发断点或点亮错误指示灯 */
    HAL_GPIO_WritePin(LED2_GPIO_Port, LED2_Pin, GPIO_PIN_SET);
    for (;;);  /* 死循环,防止继续运行破坏内存 */
}

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=0Task_L 获取 I2C Mutex,开始执行 I2C 总线扫描(预计耗时 50 ms)
T=10Task_H 就绪(PID 周期到),优先级最高,抢占 CPU
T=10Task_H 尝试获取 I2C Mutex(需读取电机编码器),发现被 Task_L 持有,阻塞等待
T=10调度器选择就绪态中优先级最高的 Task_M(优先级 30)
T=10~60Task_M 执行 50 ms 的 SD 卡日志写入
T=60Task_M 阻塞(等下一次 100 ms 周期)
T=60Task_L 继续执行,释放 I2C Mutex
T=60Task_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=0Task_L 获取 I2C Mutex,开始执行(优先级临时提升前仍为 20)
T=10Task_H 就绪,尝试获取 Mutex,发现被 Task_L 持有
T=10优先级继承触发:Task_L 优先级临时提升至 40
T=10Task_M 就绪,但 Task_L 优先级(40)> Task_M(30),Task_M 无法抢占
T=10~50Task_L 以优先级 40 继续执行,直到释放 Mutex
T=50Task_L 释放 Mutex,优先级恢复为 20
T=50Task_H 获取 Mutex,立即执行 PID 计算
T=50Task_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_ATask_B
T=0获取 Mutex_Motor(成功)就绪
T=1执行电机控制代码获取 Mutex_Sensor(成功)
T=2尝试获取 Mutex_Sensor执行数据打包
T=2Mutex_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,形成循环等待。两个任务永远阻塞,系统"假死"。

四个必要条件

  1. 互斥(Mutual Exclusion):Mutex_Motor 和 Mutex_Sensor 同一时间只能被一个任务持有。
  2. 占有且等待(Hold and Wait):Task_A 持有 Mutex_Motor 的同时等待 Mutex_Sensor;Task_B 持有 Mutex_Sensor 的同时等待 Mutex_Motor。
  3. 不可抢占(No Preemption):Mutex 不能被强制释放,必须由持有者主动释放。
  4. 循环等待(Circular Wait):Task_A → Mutex_Sensor → Task_B → Mutex_Motor → Task_A。

避免方法

方法一:全局固定顺序获取

所有任务按相同顺序获取 Mutex:

1
2
3
4
5
6
/* 所有任务统一顺序:先 Sensor,后 Motor */
osMutexAcquire(Mutex_Sensor, osWaitForever);
osMutexAcquire(Mutex_Motor, osWaitForever);
/* 执行业务 */
osMutexRelease(Mutex_Motor);
osMutexRelease(Mutex_Sensor);

若 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)

1
2
3
4
5
6
7
if (osMutexAcquire(Mutex_Sensor, 100) == osOK) {
    if (osMutexAcquire(Mutex_Motor, 100) == osOK) {
        /* 执行业务 */
        osMutexRelease(Mutex_Motor);
    }
    osMutexRelease(Mutex_Sensor);
}

超时后释放已持有的锁,避免无限等待。

方法三:使用递归 Mutex(Recursive Mutex)

同任务重复获取同一 Mutex 不会阻塞,但此方法仅解决"自锁",不解决"交叉锁"。

5.3 挂起调度器(Scheduler Suspend)

1
2
3
vTaskSuspendAll();
/* 临界段:任务调度暂停,但中断仍响应 */
xTaskResumeAll();

与临界区区别:挂起调度器不关中断,仅阻止任务切换,适合保护较长临界段且需中断继续工作的场景。

5.4 运行时统计(Run Time Stats)

1
2
#define configGENERATE_RUN_TIME_STATS  1
#define configUSE_TRACE_FACILITY       1

通过 vTaskGetRunTimeStats() 获取每个任务的 CPU 占用率,用于性能调优和任务优先级合理性验证。需提供一个 32 位定时器作为时间基准。

5.5 内存管理(heap_1 ~ heap_5)

算法特点适用场景
heap_1只分配不释放,无碎片从不删除任务/队列的静态系统
heap_2最佳匹配算法,允许释放,但不合并相邻空闲块简单场景,但易产生碎片
heap_3直接调用标准库malloc/free,非线程安全不推荐,需外部保证线程安全
heap_4首次适应 +合并相邻空闲块,碎片少通用场景,默认推荐
heap_5heap_4 的扩展,支持跨多个非连续内存区有多个 SRAM 块或外部 RAM 的复杂系统

5.6 静态分配(Static Allocation)

1
2
3
4
5
6
7
/* 动态分配(默认) */
osThreadNew(func, NULL, &attr);  /* 从 heap_4 分配 TCB 和栈 */

/* 静态分配(需开启 configSUPPORT_STATIC_ALLOCATION) */
static StaticTask_t xTaskBuffer;
static StackType_t xStack[512];
TaskHandle_t xHandle = xTaskCreateStatic(func, "task", 512, NULL, prio, xStack, &xTaskBuffer);

优势:无动态内存分配开销,确定性更强,TCB 和栈地址固定,适合汽车电子、航空等安全关键领域。

5.7 任务删除(vTaskDelete)

1
2
vTaskDelete(NULL);       /* 自杀 */
vTaskDelete(xHandle);    /* 他杀 */

任务删除后,其 TCB 和栈内存不会立即释放,需由**空闲任务(Idle Task)**在空闲时回收。因此 vTaskDelete 后应确保空闲任务有机会执行,或手动处理内存回收。

5.8 流缓冲区(Stream Buffer)与消息缓冲区(Message Buffer)

特性Stream BufferMessage Buffer
引入版本FreeRTOS 10+FreeRTOS 10+
数据模型字节流(无边界)消息边界(变长数据包)
读写限制仅支持单读单写仅支持单读单写
开销极低(无队列结构)极低(基于 Stream Buffer)
适用场景单生产者-单消费者字节流(如 DMA 串口)单生产者-单消费者变长消息

多生产者/多消费者场景必须使用 Queue,Stream/Message Buffer 不支持并发保护。

5.9 Tick Hook

1
2
3
4
5
6
#define configUSE_TICK_HOOK  1

void vApplicationTickHook(void)
{
    /* 每个系统 Tick 执行 */
}

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 应急处理。如何设计任务协作方案?

:推荐方案:

  1. 任务 A 与任务 B 之间使用 Queue 传递传感器数据(支持背压,A 速度快时自动缓冲)。
  2. 任务 B 检测到异常时,通过 Event Group 设置异常标志,任务 C 阻塞等待该标志(或直接使用 Task Notification 向任务 C 发送通知)。
  3. 任务 B 的串口输出使用 Mutex 保护,防止与其他任务的串口输出穿插。
  4. 若任务 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()。若该任务在调度锁内被中断打断,且中断中触发了更高优先级任务就绪,恢复调度器时可能因嵌套锁未释放而无法切换。排查方法:

  1. 检查所有 vTaskSuspendAll()xTaskResumeAll() 是否严格配对。
  2. 检查中断服务程序中是否调用了非 ISR 安全的 API(如 xQueueSend 而非 xQueueSendFromISR)。
  3. 使用逻辑分析仪抓取 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。
  • 则使用 FromISR API 的中断(如 UART、EXTI)抢占优先级必须设为 5~15。
  • 不调用 RTOS API 的高紧急中断(如电机过流保护)可设为 0~4,FreeRTOS 关中断时仍响应。

若配置错误(如 UART 中断优先级设为 2 却调用了 xQueueSendFromISR),调用时可能触发 HardFault。

Q8:任务中使用 HAL_UART_Transmit 发送大量数据,系统出现卡顿,如何优化?

HAL_UART_Transmit 是阻塞式 API,发送期间占用 CPU 空等,违背 RTOS"让出 CPU"的设计原则。优化方案:

  1. 启用 UART TX Complete 中断(__HAL_UART_ENABLE_IT(&huart1, UART_IT_TC))。
  2. 中断服务函数中调用 xSemaphoreGiveFromISR() 通知发送完成。
  3. 任务中使用 xSemaphoreTake() 等待发送完成,等待期间 CPU 可执行其他任务。
  4. 或改用 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,传入任务句柄与任务名称。生产环境中可在钩子函数内:

  1. 记录任务名到非易失存储(如 Flash 或 EEPROM)。
  2. 点亮错误指示灯或触发复位。
  3. 通过 uxTaskGetStackHighWaterMark() 在日常运行中监控各任务栈使用水位,提前预警。