FreeRTOS

文章目录

前言

内容主要是重温FreeRTOS整理的一份笔记,所学来自百问网

一、数据类型与编程规范

1.核心数据类型

定义在 portmacro.h

类型 说明
TickType_t tick 计数值类型。configUSE_16_BIT_TICKS=1 → uint16_t,否则 uint32_t。32位架构建议用 32 位
BaseType_t 该架构最高效的数据类型。32位=uint32_t,16位=uint16_t,8位=uint8_t。常用于返回值、逻辑值

2.变量命名(前缀)

前缀 含义
c char
s int16_t / short
l int32_t / long
x BaseType_t,或自定义类型(结构体、句柄等)
u unsigned
p 指针

组合示例:

变量名 类型
uc uint8_t
pc char 指针

3.函数命名(前缀)

前缀 = 返回值类型 + 所在文件。

复制代码
vTaskPrioritySet  → 返回 void,定义在 task.c
xQueueReceive     → 返回 BaseType_t,定义在 queue.c
pvTimerGetTimerID → 返回 void*,定义在 timer.c

4.宏命名(前缀)

前缀表示宏在哪个文件中定义。

前缀 定义所在文件 示例
port portable.h / portmacro.h portMAX_DELAY
task task.h taskENTER_CRITICAL()
pd projdefs.h pdTRUE, pdPASS
config FreeRTOSConfig.h configUSE_PREEMPTION
err projdefs.h errQUEUE_FULL

通用宏值:

pdTRUE 1
pdFALSE 0
pdPASS 1
pdFAIL 0

二、内存管理

1.五种内存管理机制

文件 优点 缺点
heap_1.c 分配简单,时间确定 只分配、不回收
heap_2.c 动态分配、最佳匹配 碎片、时间不定
heap_3.c 调用标准库函数 速度慢、时间不定
heap_4.c 相邻空闲内存可合并 可解决碎片问题、时间不定
heap_5.c 在 heap_4 基础上支持分隔的内存块 可解决碎片问题、时间不定
  • heap_1.c:只分配不删除,只有pvPortMalloc,没有实现vPortFree;
  • heap_2.c:最佳匹配算法,但不会合并相邻的空闲内存,碎片化严重;
  • heap_3.c:使用标准C库里的malloc、free函数,configTOTAL_HEAP_SIZE不再起作用;
  • heap_4.c:首次适应算法,会把相邻空闲内存合并为一个大的空闲内存,可以较少内存的碎片化;
  • heap_5.c:分配内存、释放内存的算法跟Heap_4是一样的,不局限于管理一个大数组:它可以管理多块、分隔开的内存。

2.相关函数

函数 作用 备注
pvPortMalloc(size) 分配,失败返回 NULL
vPortFree(pv) 释放 heap_1 无此函数
xPortGetFreeHeapSize() 当前剩余空闲字节 用于调小 configTOTAL_HEAP_SIZE,heap_3 不可用
xPortGetMinimumEverFreeHeapSize() 历史最小空闲值 仅 heap_4/5 支持

malloc 失败钩子

c 复制代码
// FreeRTOSConfig.h:configUSE_MALLOC_FAILED_HOOK = 1

void vApplicationMallocFailedHook(void) {
    // pvPortMalloc 失败时被调用,记录日志或重启
}

3.如何避免内存泄露

  1. 选择合适内存方案:heap_1无法动态释放,heap_3会带来碎片化,常用heap_4或者heap_5
  2. 正确释放动态分配的资源
  3. 避免创建未使用的任务及资源,启用时进行内存统计:采用vTaskList()
  4. 启用 configASSERT() 捕获非法访问(如参数非法空句柄、越界等),启用 configCHECK_FOR_STACK_OVERFLOW捕获栈溢出。

4.为什么会有内存碎片

动态内存分配与释放的随机性,导致内存被分割为多个不连续的小块,无法合并满足大块请求,即使总空闲内存足够。

三、什么是任务、任务的创建及删除

任务可以理解为进程/线程,创建一个任务,就会在内存开辟一个空间

可以简单理解任务包含如下三个方面

  • 1.做何事(函数)
  • 2.栈和任务结构体

每个RTOS任务为什么有自己的栈

  1. 不同的任务有不同的调用关系
  2. 不同的任务有不同的局部变量
  3. 任务切换的时候保存现场

这些都保存在自己的栈里

  • 3.优先级

创建任务有两种方式:

  • 1.动态分配
c 复制代码
BaseType_t xTaskCreate(
TaskFunction_t pxTaskCode, // 函数指针, 任务函数
const char * const pcName, // 任务的名字
const configSTACK_DEPTH_TYPE usStackDepth, // 栈大小,单位为word,10表示40字节
void * const pvParameters, // 调用任务函数时传入的参数
UBaseType_t uxPriority, // 优先级
TaskHandle_t * const pxCreatedTask ); // 任务句柄, 以后使用它来操作这个任务
  • 2.静态分配
c 复制代码
TaskHandle_t xTaskCreateStatic (
TaskFunction_t pxTaskCode, // 函数指针, 任务函数
const char * const pcName, // 任务的名字
const uint32_t ulStackDepth, // 栈大小,单位为word,10表示40字节
void * const pvParameters, // 调用任务函数时传入的参数
UBaseType_t uxPriority, // 优先级
StackType_t * const puxStackBuffer, // 静态分配的栈,就是一个buffer
StaticTask_t * const pxTaskBuffer // 静态分配的任务结构体的指针,用它来操作这个任务
);

静态分配与动态分配的区别

创建任务的时候需要给任务指定栈

  • 如果使用的函数 xTaskCreate()创建任务(动态方法 )的话那么任务 就会由函数xTaskCreate()自动分配
  • 如果使用函数 xTaskCreateStatic()创建任务(静态方法 )的话 就需要程序员自行定义任务栈puxStackBuffer 就是这个自定义栈的首地址

任务的删除:

c 复制代码
void vTaskDelete( TaskHandle_t xTaskToDelete );

四、任务的状态

运行态(Running)

  • 定义:当前时刻,这个任务实际占用着CPU,正在执行它的代码。
  • 数量:在单核MCU中,同一时刻有且仅有1个任务处于运行态。
  • 触发:由调度器在"就绪态"任务中挑选优先级最高的那个,切换到运行态。

就绪态(Ready)

  • 定义:任务已经准备就绪(所有资源、条件都满足),随时可以运行,但CPU正被更高优先级或同等级别的其他任务占用。
  • 位置:这些任务被挂在"就绪链表"上。
  • 触发:一旦当前运行态任务结束(阻塞或被抢占),调度器立刻从就绪链表中取出最高优先级的任务投入运行。

阻塞态(Blocked)

  • 定义:任务因为在等待某个外部事件(时间/信号量/队列/中断),暂时无法运行,主动放弃了CPU使用权。

    典型场景:

    调用 vTaskDelay(100)(等待时间到)。

    调用 xQueueReceive() 但队列为空(等待消息)。

    调用 xSemaphoreTake() 但信号量被占用(等待释放)。

  • 触发:当等待的事件发生时(如延时结束、队列收到数据),任务会被自动"唤醒",从阻塞态迁移到就绪态,等待调度器调度

挂起态(Suspended)

  • 定义:任务被软件强制暂停,不参与任何调度,也不响应任何事件。
  • 特点:即使延时结束或信号量可用,只要不调用 vTaskResume() 恢复它,它就永远"冻结"在原地。
  • 触发:调用 vTaskSuspend(任务句柄) 进入;调用 vTaskResume() 退出回到就绪态。
  • 用途:常用于调试暂停任务,或者临时禁用某个功能模块的任务。

仅就绪态可转变成运行态,其他状态的任务想运行必须先转变成就绪态

任务状态 存储链表
就绪态 pxReadyTasksLists优先级
阻塞态(等待时间) pxDelayedTaskList
挂起态 xSuspendedTaskList

五、任务的管理与调度

1.核心概念

任务是 FreeRTOS 中独立执行的基本单元,每个任务拥有独立的栈空间和任务控制块(TCB, Task Control Block),由调度器统一管理其执行权分配。

TCB 中记录的关键信息包括

  • 任务栈顶指针(用于上下文切换时恢复寄存器)
  • 任务优先级(0 为最低,数值越大优先级越高)
  • 任务状态(就绪/运行/阻塞/挂起)
  • 任务链表节点(用于接入各类状态列表)

调度是指内核根据既定策略,从所有处于就绪状态的任务中选取一个获得 CPU 执行权的决策过程。

FreeRTOS 采用基于优先级的抢占式调度策略

  • 相同优先级的任务轮流执行
  • 最高优先的任务先执行
  1. 最高优先的任务未执行,低优先级的任务无法运行
  2. 一旦高优先级任务就绪,马上运行
  3. 最高优先的任务有多个,它们就轮流运行

2.FreeRTOS中的任务调度器

FreeRTOS 是一个实时操作系统,它所奉行的调度规则:

  • 抢占式调度器 :抢占式调度器是一种优先级基础的调度器,高优先级抢占低优先级任务,系统永远执行最高优先级的任务。

  • 时间片调度器时间片轮转调度器是一种抢占式调度器,它将CPU时间划分成小的时间片段,每个任务在一个时间片段内运行。

  • 协程式调度器:协作式调度器依赖于任务自行释放CPU,没有明确的任务优先级。任务必须自愿放弃CPU控制权,以便其他任务能够运行。(但官方已明确表示不更新,主要是用在小容量的芯片上,用得也不多)

FreeRTOS中开启任务调度的函数是vTaskStartScheduler() ,但在CubeMX被封装为osKernelStart()

时间片轮转调度在以下情况会发生任务切换

  • 进程使用互斥锁(被动切换),互斥锁不可用时
  • 进程主动休眠(主动切换)
  • 进程被撤销(强制切换)
  • 进程当前时间片使用完(强制切换)

2.任务优先级

每个任务都可以分配一个从0~(configMAX_PRIORITIES-1) 的优先级, configMAX_PRIORITIES 在文件FreeRTOSConfig.h 中有定义,一共设置了32个优先级。

注意:数字越大,优先级约高,0为最低优先

3.调度器的决策机制(创建多个任务时,任务执行顺序)

调度策略

调度器的核心原则:在可抢占配置 下,调度器始终选择就绪态中优先级最高的任务运行。

每次需要做出调度决策时,调度器执行以下逻辑:

  1. 遍历所有处于就绪态的任务
  2. 找出其中优先级最高的
  3. 将该任务切换为运行态

就绪列表的数据结构

FreeRTOS 内部使用 pxReadyTasksLists\[\] 数组管理就绪任务,每个优先级对应一个双向链表:

复制代码
优先级 31: [任务A] → [任务E]
优先级 30: [空]
优先级 29: [空]
...
优先级 2:  [任务C]
优先级 1:  [任务B] → [任务D]
优先级 0:  [空闲任务](启动调度器的时候,系统自动创建)

补充说明

调度器启动前 ,在代码的内部,这个就绪链表会从上到下(高优先级到低优先级)不断遍历,寻找最高优先级的任务的TCB 结构体并执行(会有一个pxCurrentTCB指针来判断当前那个任务优先级最高 ),如果创建的都是相同优先级的任务,pxCurrent指针最后会指向最后一个任务,启动调度器后就会执行这个任务 ,(例如分别创建相同优先级的Task 1、Task2、Task3,创建Task1时,pxCurrent会指向Task1,当Task2创建时,此时pxCurrent就会指向Task2,同理创建Task3也是如此),这也是为什么创建相同优先级的多个任务时,最后创建的那个任务会先执行

4. 哪些事件会触发任务调度

  • 任务挂起或删除:当前任务调用vTaskSuspend()或vTaskDelete()后,会立即让出CPU,调度器选择其他就绪任务执行

  • 任务优先级变化:当一个高优先级任务变为就绪状态,当前任务可能会被抢占。

  • 系统节拍中断: 每次系统节拍(tick)中断都会触发调度器,检查是否有就绪任务需要切换

  • 延时函数调用: 当任务调用vTaskDelay()或vTaskDelayUntil()时,任务进入阻塞状态,调度器会选择其他任务

5.SysTick

1.SysTick中断

每个 SysTick 中断(频率由 configTICK_RATE_HZ 决定,典型值 1000Hz 即 1ms)中,内核调用 xTaskIncrementTick() 执行以下逻辑:

复制代码
xTaskIncrementTick()
    │
    ├── xTickCount++
    │
    ├── 遍历 pxDelayedTaskList
    │   └── 若某任务的唤醒 tick ≤ xTickCount
    │       ├── 将该任务从延时列表移除
    │       ├── 将该任务重新加入就绪列表
    │       └── 若该任务优先级 > 当前任务
    │           └── xSwitchRequired = pdTRUE
    │
    └── 若 configUSE_TIME_SLICING = 1 且同优先级有其他就绪任务
        └── xSwitchRequired = pdTRUE

注解:

心跳计时(xTickCount++)

  • 动作:系统节拍计数器加 1。
  • 通俗理解:就像钟表秒针"嘀嗒"走了一格。这个全局变量记录着系统从开机到现在总共经历了多少个 Tick(节拍)。
  • 意义:它是所有"延时"和"超时"判断的绝对时间基准。

唤醒延时到期的任务(遍历延时列表)

  • 例如:当任务调用 vTaskDelay(10) 时,它会被从就绪列表移除,并放入延时列表(pxDelayedTaskList),同时记录下"应该在 当前Tick + 10 时刻醒来"。
  • 遍历检查:系统遍历这个延时列表,查看每个任务的"计划唤醒时刻"。
  • 判断条件:如果 计划唤醒时刻 ≤ xTickCount(当前时刻),说明时间到了。

执行唤醒:

  1. 将该任务从延时列表中移除
  2. 将该任务重新加入就绪列表
  3. 抢占标记(关键!):如果这个被唤醒的任务的优先级 > 当前正在运行的任务,则把 xSwitchRequired 设为 pdTRUE(表明"立刻需要切换")。

时间片轮转(同优先级公平性)

  • 如果第二步没有发生抢占,这段逻辑负责处理"平级同事的换班"。
  • 条件:系统开启了 configUSE_TIME_SLICING = 1(默认开启),且当前任务的时间片耗尽了
  • 动作:检查当前优先级下,是否还有其他同级别的就绪任务在等着。
  • 标记:如果有,即使没有更高优先级任务出现,也把 xSwitchRequired 设为 pdTRUE(表明"该换人了")

紧跟在 xSwitchRequired 判断后面的,是这行代码

c 复制代码
if( xSwitchRequired != pdFALSE )
{
    portYIELD();  // 发起调度
}

2. 使用Cubemx配置FreeRTOS时,Timebase Source 为什么不能设置为SysTick?

裸机的时钟源默认是 SysTick,但是开启 FreeRTOS 后,FreeRTOS会占用 SysTick (用来生成1ms 定时,用于任务调度),所以需要为其他总线提供另外的时钟源。

6.PendSV 与 FreeRTOS 任务切换

1.PendSV 是什么

PendSV = Pended Service Call(可挂起服务调用) ,ARM Cortex-M 内核为 RTOS 专门设计的一个异常。一句话:任务切换的完美执行者


2.三大核心特性

特性 含义
软件可触发 随时悬起(Pending),不等硬件事件
优先级最低 RTOS 惯例设为所有中断中最低
缓期执行 被触发后自动排队,等高优先级中断全处理完再执行

3.为什么不用 SysTick 直接切?

复制代码
外设 ISR 正在跑 → SysTick 到来,发现需要切任务
   ↓ 如果在 SysTick 里直接切
外设 ISR 被中断!!!→ 实时性没了

PendSV 的做法:

复制代码
SysTick 到来 → 不管切不切,只"悬起 PendSV" → SysTick 走人
          ↓
外设 ISR 继续跑完
          ↓
所有中断都处理完了 → PendSV 轮到 → 执行任务切换

核心思想:把耗时的任务切换,推迟到所有着急的中断都处理完再说。


4.工作流程

复制代码
1. 初始化
   xPortStartScheduler() 将 PendSV 优先级设为最低

2. 触发(三种来源)
   ┌─ 任务中调 API → 内部 taskYIELD → 悬起 PendSV
   │   比如写队列唤醒了更高优先级任务,API 内部直接触发切换
   │
   ├─ SysTick → 发现时间片到/高优先级任务就绪 → 悬起 PendSV
   │
   └─ 其他 ISR → portYIELD_FROM_ISR → 悬起 PendSV
        比如 UART ISR 中写队列,发现唤醒高优先级任务,退出前统一切

   ↓ 殊途同归:不管谁发起的,最终执行切换的永远是 PendSV

3. 执行(PendSV 终于拿到 CPU)
   xPortPendSVHandler() ─ 汇编函数
        │
        ├─ ① 保存现场:R4~R11 等压入当前任务栈
        ├─ ② 选任务:调 vTaskSwitchContext(),pxCurrentTCB 指向最高优先级任务
        └─ ③ 恢复现场:从新任务栈出栈 R4~R11

   三步走完 → CPU 已经在跑新任务了

5.总结

复制代码
PendSV = 优先级最低 + 软件可触发 + 自动排队等

作用 = 在所有中断之后、择机执行任务切换
      保证实时性(高中断不受影响)
      保证完整性(切换操作不被打断)

7.空闲任务及其钩子函数

一个良好的程序,它的任务都是事件驱动的:平时大部分时间处于阻塞状态 。有可能我们自己创建的所有任务都无法执行,但是调度器必须能找到一个可以运行的任务(确保系统中始终至少有一个任务可以运行) :所以 ,我们要提供空闲任务 。在使用vTaskStartScheduler()函数来创建、启动调度器时,这个函数内部会创建空闲任务:

  • 空闲任务优先级为 0:它不能阻碍用户任务运行
  • 空闲任务要么处于就绪态,要么处于运行态,永远不会阻塞

空闲任务的优先级为 0,这是系统中最小的优先级。这意味着它只有在没有任何更高优先级的任务处于就绪态时才会获得CPU的使用权,一旦某个用户的任务(优先级更高)变为就绪态,那么空任务马上被切换出去,让这个用户任务运行。在这种情况下,我们说用户任务"抢占"(pre-empt)了空闲任务,这是由调度器实现的。

要注意的是:当一个任务被删除(vTaskDelete())时,其占用的任务控制块(TCB)和堆栈内存不会立即释放。空闲任务负责在后台清理这些已终止任务的资源。因此,如果应用程序频繁地创建和删除任务,必须确保空闲任务有机会执行,即能获得足够的CPU时间来完成清理工作。否则就无法释放被删除任务的内存。

作用

  • 1)防止处理器空转:空闲任务被看做是一个后备任务,当其他任务没有工作要执行时,就会执行空闲任务,以防止处理器空转。因为RTOS要对外部事件作出快速响应,通过空闲任务就可以保证系统的连续性和响应性(空闲任务通常不只是"空转",它会执行 WFI(等待中断) 或 WFE(等待事件) 指令)
  • 2)释放内存 :当任务中调用vTaskDelete()函数删除自身任务时,该任务不会立刻被删除,而是先将这个任务添加到待删除列表中,之后由空闲任务来对任务的内存资源进行回收 。所以在删除任务后若想要立即分配资源,应该稍微延时一下,给空闲任务一些回收资源的时间,否则可能加重资源碎片的风险。

注意

  1. 如果任务中删除的不是自身任务,而是其他任务的话,那么被删除任务的删除工作可以由函数vTaskDelete()的调用者完成,就不需要空闲任务来释放了。
  2. 被删除的任务如果是静态创建的,那内存资源就需要用户自己来释放了。
  • 3)执行钩子函数 : 可通过配置FreeRTOSConfig.h
    文件中的相关宏,来使能空闲任务中相应的钩子函数实现一些功能,比如统计系统信息、进入低功耗模式等,这些功能需要用户自己来实现。如下所示,如果要启用相应的钩子函数,只需将对应的配置项配置为1 即可,当然也不要忘了编写相应的钩子函数。
钩子函数 启用宏 (FreeRTOSConfig.h) 调用上下文 核心约束
空闲任务钩子 configUSE_IDLE_HOOK 空闲任务(任务级) 严禁阻塞(如调 vTaskDelay)
时钟节拍钩子 configUSE_TICK_HOOK SysTick 中断(中断级) 极短小,仅限 FromISR API(普通 API(不带 FromISR):内部会尝试"阻塞",FromISR API:绝不阻塞)
内存分配失败钩子 configUSE_MALLOC_FAILED_HOOK pvPortMalloc 内部(任务/中断均可) 不可调用可能阻塞的API
堆栈溢出钩子 configCHECK_FOR_STACK_OVERFLOW 内核调度器(任务切换时) 仅用于调试定位问题,定位哪个任务溢出了
守护任务启动钩子 configUSE_DAEMON_TASK_STARTUP_HOOK 定时器服务任务(首次启动) 一次性初始化 ,启动定时器服务前的环境准备

注意堆栈溢出钩子的宏是 configCHECK_FOR_STACK_OVERFLOW,而不是configUSE_STACK_OVERFLOW_HOOK,这一点在配置时需要留意

注意

  1. 不论在任何时候,都要保证系统有任务在被执行,所以不能在钩子函数中调用使空闲任务阻塞或挂起的函数,比如不能调用延时函数vTaskDelay()。
  2. 在空闲任务中可以进入低功耗模式(在空闲任务钩子函数中执行 WFI 或 WFE 指令),也就是每次进入空闲任务时,进入相应的低功耗模式,在每次 SysTick 中断发生的时候就会被唤醒,这是比较常用的低功耗方式,所有RTOS都可以使用此方式实现低功耗。
  3. 同时FreeRTOS也提供了一种低功耗 Tickless 模式,相比之下,低功耗 Tickless 模式的低功耗效果更好一点。

六、两个delay函数(相对延时、绝对延时)

vTaskDelay() 和 vTaskDelayUntil() 的核心区别在于:一个是"相对延时 ",一个是"绝对延时"。

  • vTaskDelay:至少等待指定个数的 Tick Interrupt 才能变为就绪状态
  • vTaskDelayUntil:等待到指定的绝对时刻,才能变为就绪态

TaskDelay():相对延时

c 复制代码
void vTaskDelay( const TickType_t xTicksToDelay ); /* xTicksToDelay: 等待多少给Tick */

进入、退出 vTaskDelay 的时间 间隔至少是 n 个 Tick中断,任务的执行周期 并不等于你设定的延时时间,而是"延时时间 + 任务自身执行时间 "

vTaskDelayUntil():绝对延时

c 复制代码
BaseType_t xTaskDelayUntil( TickType_t * const pxPreviousWakeTime,
const TickType_t xTimeIncrement );
复制代码
pxPreviousWakeTime: 上一次被唤醒的时间,首次使用前必须初始化为当前时间。
* xTimeIncrement:任务希望保持的固定周期, 要阻塞到(pxPreviousWakeTime + xTimeIncrement),
* 单位都是Tick Count

它的工作流程是

  • 初始化:pxPreviousWakeTime 记录了当前时刻 T0。
  • 第一次调用:系统计算绝对唤醒时刻为 T0 + xTimeIncrement,任务开始阻塞。
  • 唤醒与更新:时间到达,任务被唤醒,pxPreviousWakeTime 自动更新为本次唤醒的绝对时刻。
  • 循环往复:下一次调用时,唤醒时刻变为"上次唤醒时刻 + xTimeIncrement"。

七、同步与互斥

1.核心概念(互斥、同步、临界资源)

互斥(Mutex)

  • 定义:确保同一时刻,只有一个任务能访问某个共享资源(如全局变量、硬件外设)

同步(Synchronization):

  • 定义:确保任务A必须在任务B的某个条件满足之后才能继续执行,用于协调动作的先后顺序

临界资源

  • 同一时间只能有一个人使用的资源

互斥:解决 "争抢" 问题(保护公共资源,防止多个人同时用同一间厕所)

同步:解决 "依赖" 问题(协调先后顺序,比如等红灯亮了再走)

2.利用全局变量实现同步的缺陷

A与B优先级相同 ,A任务实现计算、并记录计算耗费的时间,B任务在A计算完成之后,打印这个计算值和时间,两个任务利用g_calc_end这个全局变量进行同步(即进行协调,g_calc_end初始化为0,当A任务计算完成之后,将其置1,任务B在其置1之后,进行打印),最后通过打印可见A完成计算大约花费2.5ms,但实际上A任务完成计算不需要这么多时间,我们在B任务while(g_calc_end==0);这个死循环之前加一个vTaskDelay(3000);(3s),之后通过打印发现A完成计算的时间大约花费1.2ms

为什么会这样?

任务在调度时,1ms进行任务切换,A执行1ms,轮到B执行1ms,如此往复,而当B在执行任务时,这1ms的大部分时间都在执行while(g_calc_end==0);这个死循环 ,虽然这个死循环没有什么意思、没做什么实际的事情,但是它也耗费了CPU的资源

所以使用简单的全局变量来实现同步的话,它会带来效率的问题 ,while(g_calc_end==0);这个判断/死循环完全可以用其他的方法来做,例如:等任务A计算完成之后,再由任务A区唤醒任务B,我们使用同步的时候,需要考虑怎样提高处理器的性能,让哪些等待的任务阻塞,不让它们参与CPU的调度

3.利用全局变量对临界变量进行互斥保护的缺陷

为什么需要互斥保护?

以这个函数为例,这个函数需要打印一些信息,就要用到屏幕,这个屏幕救赎临界资源,它内部会调用I2C去访问硬件,I2C操作比较耗时间,它会发出起始信号、设备地址、读写数据、停止信号。如果不提供互斥的保护措施的话 ,假设,任务A刚发完起始信号,就被切换出去了,切换成了任务B,任务B发出起始信号、设备地址之后又被切换出去了,此时的I2C时序不就已被被打乱了,所以必须互斥的访问,A任务没有执行完,B任务不能执行。

这个例子利用全局变量进行互斥保护,为什么是有缺陷的?

还是以这个函数为例,假设任务A和任务B都去执行这个函数,当任务A执行到108行时被切换 ,此时全局变量g_LCDCanUSE还是1,还没有置0,此时任务B仍然可以进入,可以使用LCD,现在假设任务B使用到一半的时候被切换,此时任务A接的上次执行到的地方继续执行,仍然可以使用LCD,在场景里面如果LCD发生切换的时候是发现在这个点上的话,A和B它们都可以使用LCD ,当然,任务A从判断全局变量到修改全局变量,在108行这个时刻被切换的概率非常非常低 ,在这两条指令执行的过程中,发生Tick中断的几率是非常低的 ,所以大部分情况下使用这个全局变量来保护这个临界资源是没有问题的 ,但是在理论上它是有漏洞的,当这个程序运行成千上万次之后的时候,很有可能会出现这样的问题,这个就是缺陷

引入这个缺陷的问题在于,判断这个变量和设置这个变量被打断了

包括这个例子它也是有缺陷的

以这个例子为例,假设A和B这两个任务都要想去使用这个LCD,A先来执行,一进来的时候就把bCanUse这个变量减1,初始值为1,减1后为0,只要A能够执行完这个自减语句,B再来执行的话,它都没有办法使用LCD,B来执行发现bCanUSE为0再进行减减为-1,那么bCanUSE==0条件不成立,它就没有办法使用LCD。

但是把bCanUSE--;把这个指令拆分一下(用汇编的角度看)

  1. 第一步, 读进来,比如说,把这个数值读进R0里面,把这个变量从内存里面读入CPU的某一个寄存器
  2. 第二步,然后CPU的这个寄存器数值-1,比如,R0--;
  3. 第三步,再把CPU这个寄存器的值写到变量里面去

如果任务在这个指令拆解的第一步被切换的话 ,假设A先执行,A执行到这里被切换,bCanUSE 这个变量初始值为1,A执行到这里时 R0就是1,然后被切换出去B来执行,此时A没有来的及把bCanUSE 的值减1,A只是把它的值读进来而已,B来执行,bCanUSE=1,bCanUSE--;bCanUSE==0条件成立,于是B就使用LCD,它在使用LCD的过程中又被切换出去了,现在轮到A来执行,A从上次执行到的地方继续执行,R0仍是之前被切换出去时的值(也就是1,因为要保存现场),然后R0--;就是0,把0写进来(即bCanUSE为0),条件判断成立,于是A也可以使用LCD,所以在某些条件下A和B都可以使用LCD原因在于读变量、减这个值、写变量(指令拆解的那三步)这个过程是可以被打断的 ,在这个过程里面,随时都可能发生切换,当然这三条指令执行的时间非常非常的短,发生切换的概率非常非常的小,但是从理论上说,不可能杜绝这种情况发生,当程序运行的时间非常非常长的时候,有可能会出现这样的问题

总结:这两种情况之所以不可以,它们都是判断、修改全局变量的时候可能会被打断

怎么解决这个问题?

  • 1.先把中断关掉(把整个中断关掉,那么Tick中断也就没办法产生,也就没办法产生任务的切换 )
  • 2.判断、修改全局变量
  • 3.再开中断

对于第一个例子的改进示例

补充说明:

如果关中断之后,发现这个LCD被使用了,就开中断返回个失败

对于第二例子的改进如下

这个方法也存在一定的不足

现在任务A和任务B都使用同一个函数(第一个例子的改进函数)去打印信息,任务A抢到了先机可以去打印,任务B进来的时候发现LCD还在使用,它就是返回-1,每次调用这个函数都会返回-1,但是它也不断的尝试(因为while(1)这个死循环),不断地判断不断地失败,它也没做什么有意思的事情,从而耗费了CPU的资源(原因和之前同步缺陷是一样的)

能不能让B无法使用时就阻塞,等A用完之后就把它唤醒?

用FreeRTOS提供的互斥量、信号量来实现这一点,之后会有介绍

八、通讯

1.消息队列

1.理解-本质

数据个数 互斥措施 阻塞-唤醒 使用场景
全局变量 1 一读一写
环形缓冲区 多个 一读一写
队列 多个 多读多写

队列中,数据的读写本质就是环形缓冲区,在这个基础上增加了互斥措施、阻塞-唤醒机制

如果这个队列不传输数据,只调整"数据个数",它就是信号量(semaphore)。

如果信号量中,限定"数据个数"最大值为1,它就是互斥量(mutex)

消息队列(Message Queue)的内部实现 ,可以看作是一个由"环形缓冲区 "和"任务等待列表(双向等待列表,一个接收端、一个发送端,且提供阻塞机制)"组成的复合体

任务等待列表:

这是消息队列区别于普通环形缓冲区的本质所在------它让任务具备"阻塞等待"的能力。

  • xTasksWaitingToSend(发送等待列表,写):当队列已满时,试图发送消息的任务会被挂入此表,进入阻塞态,等待"出队空位"。
  • xTasksWaitingToReceive(接收等待列表,读):当队列为空时,试图接收消息的任务会被挂入此表,等待"入队新数据"。

高优先级优先 :xTasksWaitingToSend和xTasksWaitingToReceive这两个列表中的任务是按照优先级排序的。当队列有空间或有数据时,等待列表中优先级最高的任务将优先被唤醒,这确保了系统调度的实时性

发送消息(写)

  • 唤醒:如果此时有任务因等待接收而阻塞在xTasksWaitingToReceive列表上,该任务将被唤醒并移入就绪态。
  • 阻塞:如果队列已满,当前任务会被加入到xTasksWaitingToSend列表,并进入阻塞态

接收消息(读)

  • 唤醒:如果此时有任务因等待发送而阻塞在xTasksWaitingToSend列表上,该任务将被唤醒。
  • 阻塞:如果队列为空,当前任务会被加入到xTasksWaitingToReceive列表,并进入阻塞态。

理解说明:

  1. 发送端阻塞由接收端唤醒,发送端之所以阻塞是因为要写数据,但是此时环形缓冲区已满,就没有空位可以写数据,然而当接收端读出数据时,也就意味着会有空位可以写,所以由接收端唤醒
  2. 接收端阻塞由发送端唤醒,接收端之所以阻塞是因为要读数据,但是此时的环形缓冲区为空,就没有数据可以读,然后发送端写入数据时,也就意味着有数据可读,所以由发送端唤醒
  3. 唤醒与阻塞,唤醒即从相应的等待列表中移出,然后移入就绪态,相反阻塞就是从就绪态移出,然后移入等待列表中
  4. 延时阻塞 ,如果阻塞设置可超时时间,那么除了移入队列的等待列表,还是移入一个全局的延时列表 (pxDelayedTaskList),如果超时了还没有被唤醒,那么此时就会因超时唤醒从队列等待列表、延时列表中移除,移入就绪态 ),超时唤醒后,API 函数(如 xQueueSend / xQueueReceive)会立即退出,并返回一个特定的错误码,而这个错误码本身就直接指明了失败原因

2.数据存储、传输方式

1.数据的存储

  • 队列可以包含若干个数据:队列中有若干项,这被称为"长度"(length)
  • 每个数据大小固定
  • 创建队列时就要指定长度、数据大小
  • 数据的操作采用先进先出的方法(FIFO,First In First Out):写数据时放到尾部,读数据时从头部读
  • 也可以强制写队列头部:覆盖头部数据

2.传输方式

使用队列传输数据时有两种方法:

  • 拷贝:把数据、把变量的值复制进队列里
  • 引用:把数据、把变量的地址复制进队列里

3.相关函数(使用队列的流程:创建队列、写队列、读队列、删除队列)

1.创建队列

  • 动态创建
c 复制代码
QueueHandle_t xQueueCreate( UBaseType_t uxQueueLength, UBaseType_t uxItemSize );
参数 说明
uxQueueLength 队列长度,最多能存放多少个数据(item)
uxItemSize 每个数据(item)的大小:以字节为单位
返回值 非 0:成功,返回句柄,以后使用句柄来操作队列,NULL:失败,因为内存不足
  • 静态创建
c 复制代码
QueueHandle_t xQueueCreateStatic(
		 UBaseType_t uxQueueLength,
		 UBaseType_t uxItemSize,
		 uint8_t *pucQueueStorageBuffer,
		 StaticQueue_t *pxQueueBuffer
);
参数 说明
uxQueueLength 队列长度,最多能存放多少个数据(item)
uxItemSize 每个数据(item)的大小:以字节为单位
pucQueueStorageBuffer 如果 uxItemSize 非 0,pucQueueStorageBuffer 必须向一个uint8_t 数组,此数组大小至少为"uxQueueLength * uxItemSize"
pxQueueBuffer 必须执行一个 StaticQueue_t 结构体,用来保存队列的数据结构
返回值 非 0:成功,返回句柄,以后使用句柄来操作队列,NULL:失败,因为 pxQueueBuffer 为 NULL

示例(静态创建)

c 复制代码
#define QUEUE_LENGTH 10
#define ITEM_SIZE sizeof( uint32_t )
// xQueueBuffer 用来保存队列结构体
StaticQueue_t xQueueBuffer;

//  ucQueueStorage 用来保存队列的数据,大小为:队列长度 * 数据大小
uint8_t ucQueueStorage[ QUEUE_LENGTH * ITEM_SIZE ];
void vATask( void *pvParameters )
{
	QueueHandle_t xQueue1;
	// 创建队列: 可以容纳 QUEUE_LENGTH 个数据,每个数据大小是 ITEM_SIZE
	xQueue1 = xQueueCreateStatic( QUEUE_LENGTH, ITEM_SIZE,
	ucQueueStorage, &xQueueBuffer );
}

2.写队列

  • 往队列尾部写入数据,如果没有空间,阻塞时间为xTicksToWait
c 复制代码
BaseType_t xQueueSend(
	QueueHandle_t xQueue,
	const void *pvItemToQueue,
	TickType_t xTicksToWait
);
  • 往队列尾部写入数据,此函数可以在中断函数中使用,不可阻塞
c 复制代码
BaseType_t xQueueSendToBackFromISR(
	QueueHandle_t xQueue,
	const void *pvItemToQueue,
	BaseType_t *pxHigherPriorityTaskWoken
);
  • 往队列头部写入数据,如果没有空间,阻塞时间为xTicksToWait
c 复制代码
BaseType_t xQueueSendToFront(
	QueueHandle_t xQueue,
	const void *pvItemToQueue,
	TickType_t xTicksToWait
);
  • 往队列头部写入数据,此函数可以在中断函数中使用,不可阻塞
c 复制代码
BaseType_t xQueueSendToFrontFromISR(
	QueueHandle_t xQueue,
	const void *pvItemToQueue,
	BaseType_t *pxHigherPriorityTaskWoken
);
参数 说明
xQueue 队列句柄,要写哪个队列
pvItemToQueue 待发送数据的指针
xTicksToWait 如果队列满则无法写入新数据,可以让任务进入阻塞状态 xTicksToWait 表示阻塞的最大时间(Tick Count)。 如果被设为 0,无法写入数据时函数会立刻返回; 如果被设为 portMAX_DELAY,则会一直阻塞直到有空间可写
返回值 pdPASS:数据成功写入了队列,errQUEUE_FULL:写入失败,因为队列满了。

3.读队列

使用 xQueueReceive()函数读队列,读到一个数据后,队列中该数据会被移除

  • 在任务中用
c 复制代码
BaseType_t xQueueReceive( QueueHandle_t xQueue,
void * const pvBuffer,
TickType_t xTicksToWait );
  • 在中断中用
c 复制代码
BaseType_t xQueueReceiveFromISR(
QueueHandle_t xQueue,
void *pvBuffer,
BaseType_t *pxTaskWoken
);

4.删除队列

删除队列的函数为 vQueueDelete(),只能删除使用动态方法创建的队列,它会释放内存。

c 复制代码
void vQueueDelete( QueueHandle_t xQueue );

4.队列集

以下面这个情景为例,了解为什么需要队列集?

  • 对于图中所框的框架,我们创建一个队列,硬件相关就去写这个队列,再创建一个任务平时去读这个硬件相关的队列,处理数据(将硬件数据转换为和业务相关的数据,比如这里编码器的正转还是反转,转换为挡球板左移还是右移),然后写入这个业务相关的队列(这里就是挡球板这个队列),业务任务只要读这个业务队列就行。
  • 这个框架 与图中红外遥感器这种框架相比,硬件只和硬件相关,后面换个业务,这个代码只需修改任务代码就能进行适配,可移植性强,而红外遥感器这个框架,硬件数据(红外遥控器的键值)被直接转为业务相关(挡球板左移还是右移)写入了业务队列,那么下次换个业务这个代码就用不了(因为在硬件数理处理时,键值被转换成挡球板的控制,而此时换业务了之后不再是挡球板,此时的键值显然不适配),因此需要大规模修改,可移植性差
  • 这个框架也有不足每当有一个设备 ,就要创建一个任务 去读这个硬件相关的队列,然而创建任务是需要用到栈 的,会消耗很多内存,对系统的资源有极大的浪费

既然有缺陷那我们应该怎么做? ,只创建一个任务去读各个硬件的队列,再把数据处理成业务相关的数据,那又怎么确保任务能够及时读这个队列呢?因此引入队列集

如下

当队列加入队列集后,当硬件有数据,在写入队列的同时也把该队列的句柄写入队列集,任务只需要读队列集,即可获得该句柄得到数据。当读队列集没有数据时阻塞,有数据时将其唤醒

队列集的核心:

  • 阻塞:任务统一阻塞在队列集本身,而非分散在各个成员队列上。
  • 通知:成员队列收到数据时,,自动将自己的句柄转发给队列集。
  • 返回:队列集唤醒任务,直接返回该句柄,任务据此精准取数

2.信号量

1.概述

  • 信号:起通知作用
  • 量:还可以用来表示资源的数量
  • 当"量"没有限制时,它就是"计数型信号量"
  • 当"量"只有 0、1 两个取值时,它就是"二进制信号量"

二进制信号量跟计数型的唯一差别,就是计数值的最大值被限定为1。

  • 支持的动作:"give"给出资源,计数值加 1;"take"获得资源,计数值减 1
  • 创建计数信号量时,系统会为创建的计数信号量分配内存
二进制信号量 计数型信号量
动态创建 xSemaphoreCreateBinary 计数值初始值为 0 vSemaphoreCreateBinary(过时了) 计数值初始值为 1 xSemaphoreCreateCounting
静态创建 xSemaphoreCreateBinaryStatic xSemaphoreCreateCountingStatic

2.与队列的区别

计数信号量是一种长度大于1,消息大小为0的特殊消息队列

  • 普通队列:创建时告诉系统"我要存5个温度值"(长度=5,消息大小=4字节)。系统老老实实挖了一块内存给你放数据。
  • 计数信号量:创建时告诉系统"我要模拟一个能停10辆车的停车场"(长度=10,消息大小=0字节)。系统说:"既然消息大小是0,那我不用挖内存放数据了,我只管记个数(有空位就加1,被占了就减1)。"

"不用挖内存放数据" 是指:省掉了 长度 × 消息大小 的那块大内存(比如存10个温度值需要40字节)。

"只管记个数" 是指:CPU 操控的是 控制块 里自带的那个 uxMessagesWaiting 变量(一个 UBaseType_t,通常占 4 字节)。

队列 信号量
可以容纳多个数据 创建队列时有 2 部分内存: 队列结构体、存储数据的空间 只有计数值,无法容纳其他数据。 创建信号量时,只需要分配信号量结构
生产者:没有空间存入数据时可以阻塞 生产者:用于不阻塞,计数值已经达到,最大时返回失败
消费者:没有数据时可以阻塞 消费者:没有资源时可以阻塞

信号量不设计数据传输,所以环形缓冲区肯定没有,读、写位置不需要,发送者链表也不需要,有一个最大计数值(表示计数值最大是多少),对应着队列的深度(表示最多可以放多少个数据)

3.使用

信号量创建、释放、获取(也可以获取计数信号量的值)

4.优先级反转

什么是优先级反转?

  • 正常情况下(也就是优先级不反转),高优先级的任务先运行,低优先级的任务后运行
  • 优先级反转,低优先级的任务先运行,高优先级的任务反而不能运行

什么情况造成了优先级反转?

  • 优先级反转是指低优先级任务持有高优先级任务所需的资源,导致高优先级任务被阻塞,而中优先级任务抢占 CPU,形成"反转"。

以下面这个情景为例来理解

  • ① 任务1优先级最低,任务2优先级为中,任务3优先级最高
  • ② 任务1被创建,任务1先运行,获得的信号量(信号量只有一个)
  • ③ 此时任务2被创建,任务2接着运行,它不需要获得信号量,它的优先级高于任务1,所以它可以运行
  • ④ 此时任务3被创建,任务3最后运行,但它需要获得信号量:但是任务1占用了信号量,所以最后任务3阻塞

任务2的优先级高于任务1,任务2没放弃运行的话,任务1无法运行。任务1无法运行,就无法释放信号量,导致优先级最高的任务3无法运行(被任务2中优先级任务打断),这就是优先级反转

怎么解决优先级反转?

  • 互斥量,优先级继承,后面有讲

3.互斥量

1.临界区与临界资源

  • 临界区:指访问共享资源的代码段,必须保证一次只允许一个任务进入,以防止数据竞争。
  • 临界资源:被多个任务共享且可能引发竞争的资源,访问它时需要保护机制(如互斥锁)

2.概述

互斥量是管理临界资源的一种有效手段, 因为互斥量是独占的, 所以在一个时刻只允许一个线程占有互斥量 ,利用这个性质来实现共享资源的互斥量保护 ,任何时刻只允许一个线程获得互斥量对象,未能够获得互斥量对象的线程被挂起在该互斥量的等待线程队列上,这一点和信号量是相同的, 但互斥量有所有者 的概念,高优先级的任务可以在获取互斥量时通过对比所有者的优先级是否高于自己来决定是否提升所有者的优先级所以互斥量可以有效对付优先级反转的问题,即实现优先级继承。

互斥量也被称为互斥锁,使用过程如下:

  • 1.在创建一个互斥量时,初始值为1
  • 2.当任务A想访问临界资源,先获得并占有互斥量,然后开始访问
  • 3.如果任务B也想访问临界资源,也要先获得互斥量。但已经被别人占有了,于是只能进行阻塞
  • 4.当任务A使用完毕,释放互斥量。这时任务B被唤醒、得到并占有互斥 量,然后开始访问
  • 5.任务B使用完毕,释放互斥量

若要使用互斥量,需要在配置文件FreeRTOSConfig.h中定义:

c 复制代码
#define configUSE_MUTEXES 1

3.与二值信号量的区别

二值信号量 互斥量
应用场景 同步 保护共享资源
所有权 any 谁申请谁释放
优先级继承 不支持 支持(解决优先级反转问题)
初始状态 通常初始化为 0(未被获取) 初始化为 1(未被获取)

补充说明,理解所有权

这个"所有权"指的是 "谁有资格释放(Give)这个信号量/互斥量"。

互斥量 (Mutex):任务A调用 xSemaphoreTake(Mutex) 成功获取后,只有任务A可以调用 xSemaphoreGive(Mutex) 释放它。

二值信号量(Binary Semaphore):任务A拿走了信号量,但任务B(甚至中断ISR) 可以直接调用 xSemaphoreGive 释放它。

4.优先级继承

当一个互斥信号量正在被一个低优先级的任务持有时, 如果此时有个高优先级的任务也尝试获取这个互斥信号量,那么这个高优先级的任务就会被阻塞。不过这个高优先级的任务会将低优先级任务的优先级提升到与自己相同的优先级。

优先级继承并不能完全的消除优先级翻转的问题,它只是尽可能的降低优先级翻转带来的影响。

这样做的目的是:

  1. 减少阻塞时间:确保低优先级任务可以快速完成对共享资源的使用,并释放互斥量。
  2. 让高优先级任务尽快执行:一旦低优先级任务释放了互斥量,高优先级任务就可以立即执行
  1. 任务1,低优先级任务占用互斥量(超算)
  2. 任务2创建(中优先级),此时任务2运行
  3. 任务3创建(高优先级),此时任务3运行,但是这个高优先级任务也想占用这个互斥量,但任务1已占用,所以只能阻塞,但是它会把低优先任务临时提升到和自己优先级一致
  4. 低优先级任务被临时提升到高优先级任务一致,所以任务1得以运行
  5. 低优先级任务执行结束,释放互斥量,此时高任务就能接着占用互斥量,得以运行,不会被中优先级任务打断(与前面信号量中优先级反转的情况不同)
  6. 等高优先级任务执行结束,中优先级任务才会执行

在这个情景中,就是学生(低)在用超算,此时主任(中)带人参考,觉得太吵了,让学生别用,然后校(高)长来了,校长也想要超算,但是超算被学生占用,学生因为主任带人参观无法使用超算,此时校长就会临时提拔学生,学生就能接着用超算,用完之后校长就能用了,主任没有资格让校长别用,所以只能等校长用完了,之后才能带人继续参观

5.死锁

死锁(也称死锁)是多任务或多线程环境中一个常见的问题,尤其是在实时操作系统(RTOS)中,如果处理不当,会导致整个系统停止响应。死锁发生时,两个或多个任务互相等待对方持有的资源,从而形成了一个僵局,这些任务都无法继续执行下去。

死锁的经典场景:你等我,我等你

假设有2个互斥量M1、M2,2个任务A、B:

  • A获得了互斥量M1
  • B获得了互斥量M2
  • A还要获得互斥量M2才能运行,结果A阻塞
  • B还要获得互斥量M1才能运行,结果B阻塞
  • A、B都阻塞,再无法释放它们持有的互斥量
  • 死锁发生!

互锁产生的四个必要条件(缺一不可)

只要同时满足以下四个条件,死锁必然发生:

  • 互斥:资源不能被共享,只能被一个任务独占。
  • 持有并等待:任务持有资源的同时,还要等待其他资源,任务手里握着一个锁,还要去请求另一个被占用的锁。
  • 不可抢占:资源必须由持有者主动释放,锁只能由持有者主动释放。
  • 循环等待:多个任务形成互相等待的环路,任务1等任务2,任务2等任务1,形成环路。

避免死锁的核心策略

  • 策略一:按固定顺序获取锁

    • 问题:任务A持有锁1等待锁2,任务B持有锁2等待锁1
    • 解决做法:在代码规范里写明:"所有任务,如果想拿这两把锁,必须先拿A,后拿B。",这一铁律
    • 运行流程
      • 任务2原本想先拿B,但因为有这条铁律,它必须先去拿A。
      • 可是此时A被任务1拿着,所以任务2只能老老实实在A门口排队。
      • 任务1拿着A,然后顺利地去拿B(因为此时B是空闲的)。
      • 任务1用完后,释放A和B。
      • 任务2终于拿到了A,紧接着去拿B(B此时也是空闲的),然后执行任务。
    • 为什么有效? 因为打破了"循环等待"。任务2不可能在拿着B的时候去等A,因为它在拿B之前,就被卡在A的门口了。这样就变成了"任务1先过,任务2后过",有序排队,不会像死锁那样"你等我,我等你"。
  • 策略二:使用超时机制

    • 问题:任务无限期等待资源,导致系统僵死。

    • 解决:使用带超时的 xSemaphoreTake() ,超时后回退并释放已有资源,例如:把 xSemaphoreTake 的等待时间从 portMAX_DELAY(死等)改成 pdMS_TO_TICKS(100)

    • 运行流程

      • 任务1 拿着A,去申请B。结果发现B被任务2拿着,任务1开始等待。
      • 过了 0.1 秒(100ms),B还是没拿到。
      • 超时触发:xSemaphoreTake 返回 pdFALSE,可以设置 else 分支,在 else 分支里,任务1主动释放自己手里的钥匙A(xSemaphoreGive(A))
      • 任务1 重试,或者干脆报错返回。此时A被释放了,任务2就能拿到A去干活,打破了"你等我,我等你的僵局"。
    • 为什么有效? 它把"永久互锁"变成了"暂时的握手失败"。虽然这次操作因为超时没成功,但系统没有卡死,下次循环或者重试时,锁可能就空了,系统能继续跑。
  • 策略三:避免在持有锁时调用不可控的外部函数

    • 问题:如果你手里已经拿着锁A,再调用一个可能会去拿锁B的复杂函数

    • 具体做法:手里拿着钥匙A的时候,绝对不去调用一个复杂的、可能会去拿钥匙B的函数

  • 常见失误:例如,你在任务里为了保护一个全局变量,拿了互斥量A,然后顺手调用了一个 sprintf 字符串格式化函数,这个函数内部可能为了安全也去拿了一个系统打印锁(即B)。这就等于你在不知情的情况下,制造了"拿着A等B"的潜在死锁条件。

  • 策略四:尽量缩小临界区

    • 问题

      拿锁A。

      算了一堆复杂的数学,花了 5ms。

      又去读了个传感器,花了 10ms。

      最后把共享变量赋值一下。 (注:这15ms里,锁一直被占用着,谁也别想拿锁B,极大概率引发碰撞)

    • 解决:只在修改共享变量(如 g_Count = 10;)的那一行代码周围加锁,而不是把整个函数的代码都包在锁里面。

    • 运行流程(正确写法):

      先把复杂的数学、读传感器花 15ms 做完(此时不拿锁)。

      拿锁A。

      立即把共享变量赋值(只需几十纳秒)。

      立即释放锁A。

    • 为什么有效? 你把持有钥匙A的时间从15ms缩短到了纳秒级。在这个极短的瞬间,任务2想拿着B来申请A的几率几乎降到了零。虽然它没有从算法上杜绝死锁,但在高并发系统中,缩短持锁时间是避免双方"碰头"最有效的物理手段。

自我锁死的情况

  • 场景示例

  • 任务 A 获得了互斥锁 M

    • 它调用一个库函数
    • 库函数要去获取同一个互斥锁 M,于是它阻塞:任务 A 休眠,等待任务 A 来释放互斥锁!
    • 死锁发生!
  • 问题:同任务多次获取同一锁,导致自锁

  • 触发条件:普通互斥量 + 函数嵌套调用

  • 解决方案:使用递归互斥量

    • 增加 "嵌套计数"。

    • 同一任务重复 Take 时:不阻塞,只让计数 +1。

    • 每次 Give 时:计数 -1,直到归零才真正释放锁。

6.使用

互斥量创建、释放、获取

7.线程同步与互斥

线程互斥:防止多个线程同时访问共享资源(如全局变量、硬件设备等),确保同一时刻只有一个线程能操作该资源,避免数据混乱或不一致。

  • 互斥锁(Mutex):线程访问资源前先获取锁,操作完成后释放锁。其他线程若想访问,必须等待锁被释放
  • 信号量(Semaphore,初始值为 1 时):也可实现互斥,此时称为 "二值信号量"

线程同步:协调多个线程的执行顺序,使它们按预期的先后次序协作完成任务,通常涉及线程间的 "等待 - 通知" 机制。

  • 信号量(Semaphore):通过计数器控制线程执行顺序(如生产者 - 消费者模型中控制缓冲区满 / 空)。

  • 条件变量(Condition Variable):线程可等待某个条件成立,其他线程满足条件后唤醒等待线程。

  • 事件(Event):通过 "触发 - 等待" 机制实现线程间通知。

8. 并发、并行、同步、异步、互斥、阻塞、非阻塞的理解

并发与并行

  • 并发

    • 多个任务在同一时间段内交替执行,宏观上同时、微观交替执行。

    • 单核 CPU 通过时间片轮转调度,在多个进程/线程间快速切换上下文,使各任务看起来都在推进。,核心在于"交替执行"

  • 并行

    • 多个任务在同一时刻、在不同计算单元上真正同时执行。

    • 依赖多核 CPU 或多处理器硬件,各任务分配到独立核心运行,互不抢占。,核心在于"同时执行"。

  • 对比

并发 并行
执行方式 交替执行 同时执行
依赖硬件 单核即可 必须多核
  • 示例
    • 并发:"单核" 干多件事。比如你一个人,一边吃面包,一边用脚踩缝纫机(通过时间片切换)。核心词:交替执行。

    • 并行:"多核" 干多件事。比如你有两个大脑,左手画圆,右手画方,真正在同一时刻做两件不同的事。核心词:同时执行

互斥与同步

  • 互斥

    • 互斥:当多个进程/线程访问同一共享资源时,保证任意时刻最多只有一个进入临界区,其余必须等待------即对同一资源的访问是"互斥"的
  • 同步

    • 同步:多个并发任务在执行过程中,某个任务到达特定阶段后,必须等待另一任务完成某个操作或到达某个状态,才能继续往下执行------本质是任务间的协调与依赖。

互斥与同步的关系

  • 互斥解决资源冲突:同一时刻只能一个人进去。
  • 同步解决顺序依赖:你必须等我到了某个点,才能往下走。
  • 互斥可看作一种特殊的同步------同步的"条件"就是"对方离开临界区"

同步与异步

"同步"一词日常在两个场景中出现,不能混为一谈。


场景一:OS 层面的同步(协作同步)

OS = 操作系统(Operating System),即 Windows、Linux、macOS 等。OS 层面的同步",指的是操作系统管理的多个进程/线程之间的协调同步

多个并发任务之间互相协调。任务 A 到达某个阶段后,必须等任务 B 完成某件事或到达某个状态,才能继续往下走。

复制代码
A 负责下载,B 负责解析
B 必须等 A 下载完才能开始解析  ← 这就是同步(在关键时机等对方)
A 下载的过程中,B 可以干别的,不是全程死等

核心:任务间的协作依赖------你走太快了,等一下我。

OS 层的异步 ------ "各跑各的"是默认状态

多个进程/线程被 OS 调度起来后,天然就是异步的------谁先跑、跑多快,都不确定,彼此独立。

复制代码
A 负责下载,B 负责解析
A 和 B 同时被启动,各跑各的,A 还没下载完 B 可能就已经开始解析了
结果就是 B 解析了个空的、或者读到一半的文件 → 出错了

正因为默认是异步(各跑各的),才需要同步机制(锁、信号量、条件变量)去加约束------让 B 在关键时机等一下 A。

复制代码
OS 层   →  异步是默认(各跑各的),同步是人为加上的约束
调用层   →  同步是默认(调了就等),异步是人为改变的方式

场景二:调用层面的同步(同步调用)

调用方等被调用方返回结果

就像打电话:你打给客服查询,客服查了 5 分钟,你这 5 分钟一直举着手机等着,啥也干不了。

对应的异步调用 就像发微信:你发完放下手机去干别的,客服查完会回你消息通知你。

核心:调用方等不等结果


两个"同步"对比

OS 同步(协作同步) 同步调用
场景 多个并发任务互相协调 调用方等着拿结果
谁等谁 任务 A 等任务 B 到某个状态 调用方等被调用方返回
等什么 一个"时机" 一个"结果"
关系 对等的合作关系 不对等的主从关系
类比 接力赛交棒 打电话等回复

共同点只有一个字:。但等的对象、关系、场景都不同。


对比:同步调用 vs 异步调用

同步调用 异步调用
执行方式 顺序执行,等待完成 彼此独立,不等待
调用方行为 等着结果回来 不等,继续往下走
结果获取 直接拿返回值 回调/通知/polling
类比 打电话(不挂,一直等) 发微信(发完干别的)

异步与多线程的关系:异步是目的,多线程只是手段

你想实现"不等"(异步),有好几种办法:

  • 方法1:开一个新线程去读文件,主线程继续跑

  • 方法2:用操作系统提供的异步 IO,不用自己开线程

所以异步是目的 (我就是不想等),多线程只是手段之一,不是唯一手段。

阻塞与非阻塞

阻塞与非阻塞描述的是调用方在等待数据时的行为,常见于 IO 操作,也适用于锁等场景:

  • 阻塞:调用方进入等待状态,让出 CPU,直到数据就绪/锁可用才被唤醒继续执行。
  • 非阻塞:调用方不等待,立刻返回一个状态,调用方可以先去干别的事,之后再回来尝试。
阻塞 非阻塞
行为 等着,不返回 立刻返回状态
CPU 让出,不浪费 继续占用

四种组合模式

I/O 四种模型对比 ------ 老张烧水

出场人物:老张,水壶两把(普通水壶、会响的水壶)

同步阻塞

  • 老张把水壶放到火上,站那立等水开。水不开他就不走,啥也不干,就盯着壶

同步非阻塞

  • 老张把水壶放到火上,去客厅看电视,时不时去厨房看一眼水开了没。没开就回去接着看,过会儿再来,反复确认

异步阻塞

  • 老张买了把响水壶(水开会叫),放到火上,站那立等壶响。壶明明会自己叫,他偏要站那等------工具是异步的,用法是阻塞的,现实中没人这么干

异步非阻塞

  • 老张把响水壶放到火上,去客厅看电视,响了再去拿壶,中间不再去看

四种模型总结

模型 老张的做法 特点
同步阻塞 站着死等水开 啥也不干,干等
同步非阻塞 看电视,反复去看 主动轮询检查
异步阻塞 站着等壶响 工具异步, 用法阻塞(实际不应用)
异步非阻塞 看电视,响了再拿 等待通知,不主动查看(效率最高)

4.事件组

1.概述

事件组可以简单地认为就是一个整数:

  • 除高八位之外的每一位都表示一个事件,至于它表示什么事件是由应用程序来决定的
  • 这些位,值为 1 表示事件发生了,值为 0 表示事件没发生
  • 会用到高八位的某些位,来表示等待的事件是或的关系还是与的关系
  • 它还有一个等待链表

2.等待链表 的运作机制

等待链表 (xTasksWaitingForBits)是事件组实现"事件驱动任务唤醒"的核心,它精确记录了哪些任务正在等待哪些特定的事件组合

其工作流程如下:

  • 1.任务因等待而阻塞:当一个任务调用 xEventGroupWaitBits() 函数等待某事件时,如果当前条件不满足,该任务就会被挂载(插入)到这个事件组的等待链表上。任务会进入阻塞状态,直到等待的事件发生或超时。
  • 2.事件发生触发检查:当另一个任务或中断服务程序调用 xEventGroupSetBits() 函数来设置事件位(即"发生事件")时,FreeRTOS内核会遍历(检查) 这个等待链表。
  • 3.唤醒满足条件的任务 :在遍历过程中,内核会检查链表上每个等待任务的条件是否已经满足。
    • 如果某个任务等待的条件(如"事件A和事件B都发生了")已经达成,内核就会将该任务从等待链表中移除 ,并将其从阻塞态移入就绪态,使其得以继续执行

事件组用一个整数来表示,其中的高8位留给内核使用,只能用其他的位来表示事件。那么这个整数是多少位的?

  • 如果configUSE_16_BIT_TICKS是1,那么这个整数就是16位的,低8位用来表示事件
  • 如果configUSE_16_BIT_TICKS是0,那么这个整数就是32位的,低24位用来表示事件

3.与信号量对比

维度 信号量 事件组
管理事件数 1 个 最多 24 个
等待逻辑 仅单一事件 支持 OR / AND 组合
唤醒方式 通常唤醒 1 个任务 可同时唤醒多个任务

4.使用

事件组创建、删除、设置、等待

5.任务通知

1.概述

任务通知(Task Notification)是 一种轻量级任务间通信机制 。它允许一个任务或中断服务程序(ISR) 直接向另一个指定的任务发送事件通知或 32 位数据。

与需要创建队列、信号量等中间对象的传统通信方式不同,任务通知直接操作接收任务控制块(TCB)中的内置成员

  • 使用队列、信号量、事件组时,我们都要事先创建对应的结构体,双方通过中间的结构体(中间对象)通信:
  • 使用任务通知时,任务结构体TCB中就包含了内部对象,可以直接接收别人发过来的" 通知":

    示例
  • 情景1,A给B发通知,但是B不关心这个通知的话,那么这个通知对B不会产生什么影响,只是修改B的通知值和通知状态,不会修改B的运行状态,这里B阻塞不是因为等待通知,A发来通知也不会唤醒B
  • 而如果,B收到通知,此时它回心转意又想等待这个通知,它会马上等到这个通知,不会陷入阻塞等待
  • 注意这里通知状态的变化
  • 情景2,B因为等待通知而阻塞,此时A发来阻塞,会唤醒B
  • 注意通知状态的变化

2.核心机制

任务通知的高效源自其数据结构直接内嵌在任务控制块(TCB)中。每个任务都拥有以下两个核心成员:

  • ulNotifiedValue (32位通知值):用于存储传递的数据、计数值或事件标志位。
  • ucNotifyState(8位通知状态 ):标识当前是否有通知等待处理。状态包括:未在等待通知 (taskNOT_WAITING_NOTIFICATION)、正在等待通知 (taskWAITING_NOTIFICATION)、已收到通知 (taskNOTIFICATION_RECEIVED)。

因为TCB在任务创建时就已经存在,所以任务通知无需额外创建和销毁,可直接使用。

3.主要API函数

FreeRTOS 提供了两类核心API来操作任务通知。

简化版 专业版
发出通知 xTaskNotifyGive vTaskNotifyGiveFromISR xTaskNotify xTaskNotifyFromISR
取出通知 ulTaskNotifyTake xTaskNotifyWait

发送通知

  • xTaskNotifyGive() / vTaskNotifyGiveFromISR():用于简单的信号量式通知,将接收任务的通知值递增

  • xTaskNotify() / xTaskNotifyFromISR():功能更全面的通知函数,通过eAction参数支持多种操作(怎么使用通知值):

    • eNoAction:仅发送通知,不修改通知值。

    • eSetBits:将通知值的指定位进行按位或操作。

    • eIncrement:通知值递增。

    • eSetValueWithOverwrite:无条件覆盖通知值。

    • eSetValueWithoutOverwrite:仅在接收任务且没有待处理通知时才不覆盖通知值。

  • 接收通知

  • ulTaskNotifyTake():用于等待并获取通知,通常配合 xTaskNotifyGive() 使用,可模拟信号量行为

  • xTaskNotifyWait():更灵活的等待函数,可以让任务等待(可以加上超时时间),还可以在函数进入、退出时,清除通知值的指定位

4.优势与劣势

核心优势:极致轻量与高效

  • 速度更快:由于是直接内存操作,使用任务通知替代信号量,解除任务阻塞的速度更快(因等待而阻塞)
  • 内存更省:无需为队列、信号量等创建额外的结构体,直接使用TCB中已存在的成员,显著节省RAM空间

重要限制

  • 一对一通信:通知必须指定接收任务,无法像事件组那样同时通知多个任务。
  • 无数据缓冲:每个任务只有一个通知值,新通知会覆盖旧数据(取决于具体API),无法像队列那样缓冲多个数据。
  • 发送方不可阻塞:若发送通知失败(如目标任务已有通知且不允许覆盖),发送方会立即返回错误,无法阻塞等待。
  • ISR只能发送,不能接收:中断服务程序没有TCB,因此无法接收任务通知,但可以向任务发送通知

5.使用

发送通知、等待通知

6.软件定时器

1.概述

简单可以理解为闹钟,到达指定一段时间后,就会响铃。

STM32 芯片自带硬件定时器,精度较高,达到定时时间后会触发中断,也可以生成 PWM 、输入 捕获、输出比较,等等,功能强大,但是由于硬件的限制,个数有限

软件定时器也可以实现定时功能达到定时时间后可用回调函数,可以在回调函数里处理信息(不占用硬件定时器外设,纯靠内核 tick 实现的定时功能。)

2.核心结构体 Timer_t(控制块)

  • xTimerListItem:链表节点,用于挂入内核调度链表,实现超时管理
  • xTimerPeriodInTicks:定时周期(Tick 单位)。
  • pxCallbackFunction:到期执行的回调函数指针。
  • pvTimerID:用户自定义指针,用于多定时器复用回调时区分来源。
  • ucStatus(状态标志):决定单次还是周期------其内部的 tmrSTATUS_IS_AUTORELOAD 位,由
    xTimerCreate 的 uxAutoReload 参数设置:pdTRUE 为周期,pdFALSE 为单次。

3.原理

定时器是一个可选的、不属于 FreeRTOS 内核的功能,它是由定时器服务任务来提供的。

在调用函数 vTaskStartScheduler() 开启任务调度器的时候,会创建一个用于管理软件定时器的任务,这个任务就叫做软件定时器服务任务(守护任务)。

1.负责软件定时器超时的逻辑判断

  • 判断哪些定时器超时了,Timer Task 醒来后,遍历定时器列表,检查每个定时器:

    复制代码
       当前 tick = 1000
       定时器 A 到期 tick = 1000  → 超时了
       定时器 B 到期 tick = 1005  → 还没
       定时器 C 到期 tick = 1000  → 超时了
  • 因为定时器列表按到期时间排序,实际上只需要从列表头开始取,到期时间 <= 当前 tick 的就是超时了,遇到第一个没到的就停。

2.调用超时软件定时器的超时回调函数;

  • 超时的找出来了,挨个调它们的回调函数:

    复制代码
      定时器 A 超时 → 调 A 的回调函数
      定时器 C 超时 → 调 C 的回调函数
  • 回调是这个 Timer Task 自己在执行,所以你的回调代码本质是跑在Timer Task 的任务上下文里。

3.处理软件定时器命令队列;

  • 其他任务(或中断)可能发了操作指令,比如 xTimerStart(),这些指令不是直接操作定时器的,而是往 TimerTask的命令队列里塞一条消息(写队列,比如:启动、停止、改周期。),Timer Task 在这一步取出并执行:

    复制代码
      命令队列:
         "启动定时器 D"

每次醒来循环:

找出到期的 → 跑回调 → 处理外部命令 → 算下次几点醒 → 睡

4.相关配置

软件定时器有一个定时器服务任务和定时器命令队列,这两个东西肯定是要配置的,相关的配置 也是放到文件FreeRTOSConfig.h中的,涉及到的配置如下:

  • configUSE_TIMERS

  • 如果要使用软件定时器的话宏configUSE_TIMERS一定要设置为1,当设置为1的话定时器服务任务就会在启动FreeRTOS调度器的时候自动创建。

  • configTIMER_TASK_PRIORITY

    • 设置软件定时器服务任务的任务优先级 ,可以为0~(configMAX_PRIORITIES-1)。优先级一定要根据实际的应用要求来设置。如果定时器服务任务的优先级设置的高的话,定时器命令队列中的命令和定时器回调函数就会及时的得到处理。
  • configTIMER_QUEUE_LENGTH

    • 此宏用来设置定时器命令队列的队列长度
  • configTIMER_TASK_STACK_DEPTH

    • 此宏用来设置定时器服务任务的任务堆栈大小

4.优缺点

优点

  • 简单、成本低;
  • 只要内存足够,可创建多个;

缺点:

  • 精度较低,容易受中断影响。在大多数情况下够用,但对于精度要求比较高的场合不建议使用。

5.函数

函数 描述
xTimerCreate() 动态方式创建软件定时器
xTimerCreateStatic() 静态方式创建软件定时器
xTimerStart() 开启软件定时器定时
xTimerStop() 停止软件定时器定时
xTimerReset() 复位软件定时器定时
xTimerChangePeriod() 更改软件定时器的定时超时时间
xTimerStartFromISR() 在中断中开启软件定时器定时
xTimerStopFromISR() 在中断中停止软件定时器定时
xTimerResetFromISR() 在中断中复位软件定时器定时
xTimerChangePeriodFromISR() 在中断中更改定时超时时间

回调函数

定时器的回调函数的原型如下:

c 复制代码
void ATimerCallback( TimerHandle_t xTimer );

定时器的回调函数是在守护任务中被调用的,守护任务不是专为某个定时器服务的,它还要处理其他定时器。所以,定时器的回调函数不要影响其他人:

  • 回调函数要尽快实行,不能进入阻塞状态
  • 要调用会导致阻塞的 API 函数,比如 vTaskDelay()
  • 可以调用 xQueueReceive()之类的函数,但是超时时间要设为 0:即刻返回,不可阻塞

7.中断管理

1.概述

中断是指在程序执行的过程中,突然发生了某种事件,需要立即停止当前正在执行的程序,并转而处理这个事件,处理完后再回到原来的程序执行点继续执行的过程。

中断可以是硬件中断(由硬件设备触发)或软件中断(由程序执行中断指令触发)。

复制代码
**中断处理流程**
CPU 收到中断 → 硬件跳到中断向量 → 保存 Task1 现场 → 调 ISR → 恢复现场 → 继续跑(Task1 或更高优先级任务)

ISR 在内核中执行,用户任务此时无法运行 ,所以 ISR 必须尽量快,否则:

  • 低优先级中断得不到处理
  • 用户任务卡住,系统响应慢

2.优先级

任何中断的优先级都大于任务!

在我们的操作系统,中断同样是具有优先级的,并且我们也可以设置它的优先级,但是他的优先 级并不是从 0~15 ,默认情况下它是从 5~15 ,0~4 这 5 个中断优先级不是 FreeRTOS 控制的 (5是 取决于configMAX_SYSCALL_INTERRUPT_PRIORITY,一般默认为5)。

临界区的保护机制:BASEPRI 寄存器

  • 在 ARM Cortex-M 这类处理器上,FreeRTOS 通过设置一个名为 BASEPRI 的寄存器来实现临界区保护。BASEPRI的工作原理是:屏蔽所有优先级 数值 大于或等于 BASEPRI 设置值的中断。
  • configMAX_SYSCALL_INTERRUPT_PRIORITY 的值会被写入 BASEPRI 寄存器。
  • 当 FreeRTOS 进入临界区时,会将 BASEPRI 设置为configMAX_SYSCALL_INTERRUPT_PRIORITY的值。

这意味着,所有优先级数值大于等于 该值的中断都会被暂时屏蔽。只有优先级数值小于该值(即逻辑优先级更高)的中断可以继续响应。

STM32 的中断优先级可以分为抢占优先级和子优先级:

  • 抢占优先级:抢占优先级高的中断可以打断正在执行但是抢占优先级低的中断
  • 子优先级:当同时发生具体相同抢占优先级的两个中断时,子优先级数值小的优先执行
优先级分组 抢占优先级范围 子优先级范围 优先级配置寄存器的高 4 位分配
NVIC_PriorityGroup_1 0 - 1 级 0 - 7 级 1bit 用于抢占优先级 3bit 用于子优先级
NVIC_PriorityGroup_2 0 - 3 级 0 - 3 级 2bit 用于抢占优先级 2bit 用于子优先级
NVIC_PriorityGroup_3 0 - 7 级 0 - 1 级 3bit 用于抢占优先级 1bit 用于子优先级
NVIC_PriorityGroup_4 0 - 15 级 0 级 4bit 用于抢占优先级 0bit 用于子优先级

如果要调用 FreeRTOS 的 API 函数里使用中断,就要注意:

  • 低于 configMAX_SYSCALL_INTERRUPT_PRIORITY 优先级的中断里才允许调用 FreeRTOS的 API 函数

这类中断在临界区中不会被屏蔽,如果此时调用 API 可能破坏内核数据,导致系统崩溃

  • 建议将所有优先级位指定为抢占优先级位,方便 FreeRTOS管理(调用函数HAL_NVIC_SetPriorityGrouping(NVIC_PRIORITYGROUP_4) )
  • 中断优先级数值越小越优先,任务优先级数值越大越优先

3.为什么需要两套API?

本质原因:ISR 不是任务,不能阻塞。

任务中调 API,不成功可以睡一会儿等结果:

c 复制代码
// 任务中:队列满了就等 100ms
xQueueSendToBack(queue, data, pdMS_TO_TICKS(100));
// ↑ 有 timeout 参数,函数可能很久才返回

ISR 中没有"等"的概念------它运行在内核中,抢占的是硬件时间,必须立刻返回:

c 复制代码
// ISR 中:不管成不成,即刻返回
xQueueSendToBackFromISR(queue, data, &woken);
// ↑ 没有 timeout 参数,塞不进去就拉倒

两套函数处理上有啥不同?

任务 API ISR API(FromISR)
能不能阻塞 能,传 timeout 进去等 不能,即刻返回
timeout 参数 没有
任务切换 函数内部自动切了 不切,只标记 woken
调用者 任务 ISR

任务切换这块展开说一下:

c 复制代码
// 任务 API:函数内部直接切
xQueueSendToBack(queue, data, 0);
// 如果这次写入唤醒了一个更高优先级任务,函数内部直接就切过去了
// 你调用完这行,可能已经在另一个任务里了

// ISR API:只标记,不在函数里切
xQueueSendToBackFromISR(queue, data, &woken);
// 即使唤醒了更高优先级任务,也只是把 woken 设成 pdTRUE
// 切不切、什么时候切,由你在 ISR 最后决定
portYIELD_FROM_ISR(woken);

xHigherPriorityTaskWoken

FromISR 函数内部不切换任务,只是标记一声"刚才的操作把更高优先级的任务唤醒了"。

复制代码
xQueueSendToBackFromISR(queue, data, &xHigherPriorityTaskWoken);
// xHigherPriorityTaskWoken = pdTRUE  → 需要切换
//                            = pdFALSE → 不需要

怎么切换

c 复制代码
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);  // pdTRUE 才切,pdFALSE 不切

不想管这个参数就传 NULL。

一开始设置为pdFALSE,ISR API 函数进行任务切换的标志,再手动调用任务切换,从而提高实时性

如果中断没有手动切换会如何?

以这个场景为例,运行任务A的时候,触发中断,在中断里出现了唤醒更高优先级任务的情况(会将其移入就绪链表,但是没有发起调度,即没有手动切换任务,只是标志有更高优先级的任务被唤醒,中断结束后,这个高优先级不会运行,而是运行优先级更低的任务A,等下次Tick中断时,才能运行任务B,导致高优先级任务被延时

8.资源管理------互斥操作的本质

1.概述

实现互斥操作的本质:屏蔽/使能中断、暂停/恢复调度器

要独占式地访问临界资源,有3种方法:

  • 公平竞争:比如使用互斥量,谁先获得互斥量谁就访问临界资源,这部分内容前面讲过

  • 谁要跟我抢,我就屏蔽谁:

    • 中断要跟我抢?我屏蔽中断
    • 其他任务要跟我抢?我禁止调度器,不运行任务切换

2.屏蔽中断

1.在任务中屏蔽中断

示例

c 复制代码
/* 在任务中,当前时刻中断是使能的
* 执行这句代码后,屏蔽中断
*/
taskENTER_CRITICAL();
/* 访问临界资源 */

/* 重新使能中断 */
taskEXIT_CRITICAL();

在 taskENTER_CRITICA()/taskEXIT_CRITICAL()之间:

  • 低优先级的中断被屏蔽了:优先级低于、等于configMAX_SYSCALL_INTERRUPT_PRIORITY
  • 高优先级的中断可以产生:优先级高于 configMAX_SYSCALL_INTERRUPT_PRIORITY
  • 但是,这些中断 ISR 里,不允许使用 FreeRTOS 的 API 函数
  • 任务调度依赖于中断、依赖于 API 函数,所以:这两段代码之间,不会有任务调度产生

API 函数,比如写队列,会唤醒更高优先级的任务

这套taskENTER_CRITICA()/taskEXIT_CRITICAL()宏,是可以递归使用的,它的内部会记录嵌套的深度,只有嵌套深度变为0时,调用taskEXIT_CRITICAL()才会重新使能中断。

使用taskENTER_CRITICA()/taskEXIT_CRITICAL()来访问临界资源是很粗鲁的方法:

  • 中断无法正常运行
  • 任务调度无法进行

所以,之间的代码要尽可能快速地执行

2.在 ISR 中屏蔽中断

要使用含有"FROM_ISR"后缀的宏,示例代码如下

c 复制代码
void vAnInterruptServiceRoutine( void )
{
/* 用来记录当前中断是否使能 */
UBaseType_t uxSavedInterruptStatus;
/* 在 ISR 中,当前时刻中断可能是使能的,也可能是禁止的百问网
* 所以要记录当前状态, 后面要恢复为原先的状态
* 执行这句代码后,屏蔽中断
*/
uxSavedInterruptStatus = taskENTER_CRITICAL_FROM_ISR();
/* 访问临界资源 */
/* 恢复中断状态 */
taskEXIT_CRITICAL_FROM_ISR( uxSavedInterruptStatus );
/* 现在,当前 ISR 可以被更高优先级的中断打断了 */
}

在 taskENTER_CRITICA()/taskEXIT_CRITICAL()之间:

  • 低优先级的中断被屏蔽了:优先级低于、等于configMAX_SYSCALL_INTERRUPT_PRIORITY
  • 高优先级的中断可以产生:优先级高于 configMAX_SYSCALL_INTERRUPT_PRIORITY
  • 但是,这些中断 ISR 里,不允许使用 FreeRTOS 的 API 函数
  • 任务调度依赖于中断、依赖于 API 函数,所以:这两段代码之间,不会有任务调度产生

3.暂停任务调度器

如果有别的任务来跟你竞争临界资源,你可以把中断关掉:这当然可以禁止别的任务运行,但是这代价太大了。它会影响到中断的处理。

如果只是禁止别的任务来跟你竞争,不需要关中断,暂停调度器就可以了:在这期间,中断还是可以发生、处理。

使用这2个函数来暂停、恢复调度器:

  • void vTaskSuspendAll( void )

    • 暂停调度器
  • BaseType_t xTaskResumeAll( void )

    • 恢复调度器
    • 返回值: pdTRUE 表示在暂定期间有更高优先级的任务就绪了
      可以不理会这个返回值

示例

c 复制代码
vTaskSuspendScheduler();
/* 访问临界资源 */
xTaskResumeScheduler();

这套vTaskSuspendScheduler()/xTaskResumeScheduler()宏,是可以递归使用的,它的内部会记录嵌套的深度,只有嵌套深度变为0时,调用taskEXIT_CRITICAL()才会重新使能中断。

九、系统优化

1.栈使用情况

在创建任务时分配了栈,可以填入固定的数值比如 0xa5,以后可以使用以下函数查看" 栈的高水位",也就是还有多少空余的栈空间:

c 复制代码
UBaseType_t uxTaskGetStackHighWaterMark( TaskHandle_t xTask );

原理是:从栈底往栈顶逐个字节地判断,它们的值持续是 0xa5 就表示它是空闲的。

函数说明:

参数/返回值 说明
xTask 哪个任务
返回值 任务运行时、任务被切换时,都会用到栈。栈里原来值(0xa5)就会被覆盖。 逐个函数从栈的尾部判断栈的值连续为 0xa5 的个数,它就是任务运行过程中空闲内存容量的最小值。 注意:假设从栈尾开始连续为 0xa5 的栈空间是 N 字节,返回值是N/4。

用途:根据返回值调整任务栈大小,避免浪费内存或栈溢出。

2.打印所以任务的栈信息

要打印所有任务的栈信息,最直接的方法是使用 vTaskList()。它会生成一个表格形式的字符串,包含任务名、状态、优先级、栈剩余量(Stack High Watermark)和任务编号

效果示例

复制代码
	任务名      状态  优先级  剩余栈(字)  任务号
	----------------------------------------
	IDLE         R     0       117         4
	Task1        B     2       45          1
	Task2        R     3       32          2
	MonTask      R     1       200         3
	----------------------------------------

函数原型

c 复制代码
void vTaskList( signed char *pcWriteBuffer );

注意 :vTaskList() 需要提供足够大的缓冲区(pcWriteBuffer)。

配置宏

c 复制代码
#define configUSE_TRACE_FACILITY           1
#define configUSE_STATS_FORMATTING_FUNCTIONS 1

3.任务运行时间统计

(1)为什么需要更快的时钟?

  • Tick 周期(如 1ms)不够精确:高优先级任务抢占可能导致低优先级任务在一个 Tick 内只运行了一小部分就被切出。
  • 解决方案 :使用一个比 Tick 更快的定时器(如周期 0.1ms)来精确测量任务的实际运行时间。

2)实现步骤

  • 配置宏:
c 复制代码
#define configGENERATE_RUN_TIME_STATS    1
#define configUSE_TRACE_FACILITY         1
#define configUSE_STATS_FORMATTING_FUNCTIONS 1
  • 实现两个宏:
  • portCONFIGURE_TIMER_FOR_RUN_TIME_STATS():初始化高精度定时器。
  • portGET_RUN_TIME_COUNTER_VALUE() 或 portALT_GET_RUN_TIME_COUNTER_VALUE(Time):返回当前定时器计数值。
  • 统计流程:
    • 1.任务切入时记录起始时间(T1)。
    • 2.任务切出时记录结束时间(T4)。
    • 3.累加差值(T4 - T1)到该任务的运行总时间。
    • 4.核心操作在 vTaskSwitchContext() 中完成,使用更快的定时器统计运行时间

(3)获取统计信息的 API

函数 输出形式 说明
uxTaskGetSystemState() TaskStatus_t 结构体数组 获取所有任务的详细信息(栈高水位、运行时间、任务号等)
vTaskGetRunTimeStats() 可读字符串 输出每个任务的运行时间及占总运行时间的百分比
  • uxTaskGetSystemState:获得任务的统计信息
c 复制代码
UBaseType_t uxTaskGetSystemState( TaskStatus_t * const pxTaskStatusArray, 
const UBaseType_t uxArraySize, 
uint32_t * const pulTotalRunTime );
参数 描述
pxTaskStatusArray 指向一个 TaskStatus_t 结构体数组,用来保存任务的统计信息。 有多少个任务?可以用 uxTaskGetNumberOfTasks()来获得。
uxArraySize 数 组 大 小 、 数 组 项 个 数 , 必 须 大 于 或 等 于uxTaskGetNumberOfTasks()
pulTotalRunTime 用来保存当前总的运行时间(更快的定时器),可以传入 NULL
返回值 传入的 pxTaskStatusArray 数组,被设置了几个数组项。 注 意 : 如 果 传 入 的 uxArraySize 小 于uxTaskGetNumberOfTasks(),返回值就是 0
  • vTaskGetRunTimeStats:获得任务的运行信息,形式为可读的字符串。
c 复制代码
void vTaskGetRunTimeStats( signed char *pcWriteBuffer );

注意 : vTaskGetRunTimeStats() 需要提供足够大的缓冲区(pcWriteBuffer)。

示例

复制代码
任务名    任务运行时间   运行时间百分比
 ----------------------------------------
IDLE        12345         82.3
Task1       2000          13.4
Task2       600           4.3