FreeRTOS学习(11)——信号量

文章目录

信号量的引入

在前面学习 FreeRTOS 的任务通信机制时,我们已经学习了 消息队列(Queue)。

消息队列主要用于 任务之间传递数据,也是任务间通信最常用的手段之一。

例如:

任务A采集数据,将数据发送到队列:

c 复制代码
xQueueSend(QueueHandle, &Value, 0);

任务B从队列中读取数据:

c 复制代码
xQueueReceive(QueueHandle, &Value, portMAX_DELAY);

这种方式解决的是:

任务之间如何传递数据的问题。

但在实际系统中,还有另外一种非常常见的需求:任务同步。

这里有一个小问题:什么叫做任务同步?

很多同学第一次听到这个词时,往往会觉得有点抽象。

其实所谓的 任务同步,本质上就是一句话:

让多个任务按照某种顺序或条件进行执行。

换句话说就是:

某个任务必须等待某个事件发生,才能继续执行。

这种"等待条件满足再继续执行"的行为,就称为 任务同步。

当然如果两个任务之间没有任何关系,它们就可以各自独立运行,这种互不依赖的执行方式称为任务异步。

举一个非常简单的例子,假设系统中有两个任务:

任务A:读取传感器数据

任务B:处理传感器数据

显然:

任务B必须等待任务A采集完成之后,才能开始处理。

如果任务B提前执行,就会出现问题:此时任务B得到的数据可能是 错误的、旧的或者空的。

所以我们需要一种机制,让任务B:

等待任务A完成后再继续执行。

这就是一个典型的 任务同步问题。

在这种场景下:我们并不需要传递数据。只需要告诉对方:某个事件发生了。

为了解决这种问题,RTOS操作系统通常会提供一种机制:信号量(Semaphore)。


在FreeRTOS中,若想使用信号量,首先需要包含一个头文件:

c 复制代码
#include "semphr.h"

否则相关的声明定义、函数API等都是无法使用的。

信号 VS 信号量

在讲信号量之前,我们先简单区分两个非常容易混淆的概念:信号(Signal)信号量(Semaphore)

这两个名字非常像,但实际上是 两种完全不同的机制

很多初学者在第一次接触操作系统时,经常会把这两个概念混淆。


信号(Signal)

信号主要出现在 Linux / Unix 操作系统 中。

信号是一种 异步通知机制,用于通知进程某个事件发生。

例如:

  1. 程序按下 Ctrl + C
  2. 子进程退出
  3. 定时器到期
  4. 程序非法访问内存

这些事件都会触发一个 Signal 信号

程序可以通过 信号处理函数(Signal Handler) 来处理这些事件。

简单来说:

Signal 本质是一种简单的事件通知机制,只用于通知某个事件已经发生。

需要注意的是:

FreeRTOS 中并没有 Signal 这种机制。


信号量(Semaphore)

信号量是一种 用于任务同步和资源管理的机制

它主要用于解决:多个任务之间的协作问题。

例如:

任务A等待某个事件发生:

等待信号量

任务B完成某项工作后:

释放信号量

这样任务A就会被唤醒继续执行。

可以理解为:

信号量是一种任务之间的事件通知,实现任务同步的工具。

从这个角度来看:

Signal 和 Semaphore 都可以用于事件通知。

但除此之外,信号量还兼具资源计数管理、共享资源保护等功能,这些能力是 Signal 机制所不具备的

简单对比一下:

机制 主要用途 使用场景
Signal(信号) 事件通知 Linux / Unix 通用操作系统
Semaphore(信号量) 事件通知 / 资源管理 / 资源保护 RTOS 实时操作系统

接下来我们将学习 FreeRTOS 中的三种信号量,它们的作用是完全不同的:

  1. 二值信号量(Binary Semaphore) ,用于 事件通知,任务同步
  2. 计数信号量(Counting Semaphore) ,用于 资源计数管理。
  3. 互斥信号量(Mutex) ,用于 保护共享资源 ,并解决 优先级反转问题

接下来我们先来看最简单的一种信号量:二值信号量(Binary Semaphore)。

当然,在具体讲解这些信号量之前,我们先要明确一个大前提:

所有信号量的本质都是消息队列,所有信号量的底层实现都是一个Queue_t类型的结构体。

二值信号量

在 FreeRTOS 中,最简单的一种信号量类型是:二值信号量(Binary Semaphore)

从名字就可以看出,它有两个关键词:二值(Binary)信号量(Semaphore)

所谓 二值 ,意思就是,这个信号量的值 只有两种状态

0

1

可以理解为:

0 表示没有信号

1 表示有信号

也可以理解成:

0 表示资源不可用

1 表示资源可用

或者更直观一点,它就像一个开关:

0 关

1 开

从本质上理解二值信号量

从具体讲解二值信号量的操作 API,以及如何使用它之前,我们不妨先从 源码层面 来理解一下:

二值信号量到底是什么东西?

实际上,FreeRTOS当中所有的信号量,包括二值信号量,本质上都是基于消息队列来实现的。

换句话说:信号量,实际上是一种特殊的消息队列。

就二值信号量来说,它本质上是一个 长度为 1 的消息队列,也就是说:

创建二值信号量的函数是:xSemaphoreCreateBinary()

它本质上是一个宏函数,源码如下:

c 复制代码
// 源码截取自 semphr.h 的第166行
#if ( configSUPPORT_DYNAMIC_ALLOCATION == 1 )
 #define xSemaphoreCreateBinary()    \
    xQueueGenericCreate( ( UBaseType_t ) 1, semSEMAPHORE_QUEUE_ITEM_LENGTH, queueQUEUE_TYPE_BINARY_SEMAPHORE )
#endif

....
// 源码截取自 semphr.h 的第41行
#define semSEMAPHORE_QUEUE_ITEM_LENGTH      ( ( uint8_t ) 0U )

可以看到,创建二值信号量和创建消息队列时,最终调用的都是同一个函数:

c 复制代码
xQueueGenericCreate()

也就是说:

二值信号量的底层实现,其实就是创建了一个消息队列。

不过,这个队列和普通队列有两个非常明显的区别:

  1. 元素的个数只有1个,队列的长度是1
  2. 每个元素的大小是0

这代表什么意思呢?

很简单:

二值信号量确实是一个消息队列,但实际上不能存储任何元素,不能存储任何有效数据。

那么问题来了:

既然这个消息队列 不存储任何数据 ,那么它又是如何表示 信号存在(1) / 信号不存在(0) 这两种状态的呢?

答案其实就藏在 消息队列控制结构体 当中。

FreeRTOS 中消息队列的核心控制结构体定义如下(前面已经学过了):

c 复制代码
typedef struct QueueDefinition{

    // 第一部分四个成员
    int8_t *pcHead;
    int8_t *pcTail;
    int8_t *pcWriteTo;
    int8_t *pcReadFrom;

    // 第二部分两个成员
    List_t xTasksWaitingToSend;
    List_t xTasksWaitingToReceive;

    // 第三部分三个成员
    volatile UBaseType_t uxMessagesWaiting;
    UBaseType_t uxLength;
    UBaseType_t uxItemSize;

} xQUEUE;

关键就在 xQUEUE 结构体里的这个成员:

c 复制代码
uxMessagesWaiting

这个成员表示:当前消息队列中有多少个元素,实际存储元素的个数

对于二值信号量来说:

  1. uxMessagesWaiting取值是0,消息队列为空,信号不存在,即二值信号量的0。
  2. uxMessagesWaiting取值是1,消息队列中有1个长度为0的元素,信号存在,即二值信号量的1。

理解了二值信号量的本质后,再来学习二值信号量的一系列操作函数,就会变得 非常简单、非常清晰

二值信号量的操作API

首先,二值信号量机制在FreeRTOS中是可裁剪配置的,它的使用基于下列宏的开启:

c 复制代码
#define configSUPPORT_DYNAMIC_ALLOCATION    1

这个宏我们很熟悉了,它的默认值就是1。

所以二值信号量是FreeRTOS默认开启使用的任务间通信机制。

从 API 使用角度 来看,二值信号量常用的函数其实并不多,核心操作API还是只需要学习 4个:

  1. 创建二值信号量
  2. 获取信号量(Take)
  3. 释放信号量(Give)
  4. 在中断环境下释放信号量(GiveFromISR)

下面,逐一介绍一下这些函数。

创建二值信号量:xSemaphoreCreateBinary()

创建一个二值信号量,其函数原型如下:

复制代码
SemaphoreHandle_t xSemaphoreCreateBinary(void);

函数的作用是:用于 创建一个二值信号量对象。

函数的返回值含义是:

  1. 如果创建成功,返回二值信号量句柄。创建完成后,就可以通过这个句柄操作二值信号量。
  2. 如果FreeRTOS内核堆空间不足创建失败,函数返回NULL**(很显然,这是一种动态创建手段,依赖FreeRTOS内核堆空间)**。

代码示例:

c 复制代码
SemaphoreHandle_t BinarySem;
BinarySem = xSemaphoreCreateBinary();

需要注意的是:

刚创建出来的二值信号量 默认是空的,默认是0,或者说默认事件未发生。

也就说,如果创建完二值信号量后,立刻去获取信号量(Take),是无法获取信号量的。

如果使用二值信号量来表示 资源的可用状态 ,那么有些场景下我们希望:系统启动时资源就是可用的。

此时通常的做法是:

在创建二值信号量后,手动释放一次信号量。

当然,是否需要在创建后立即释放信号量,取决于具体的使用场景和设计需求。

如果二值信号量用于 事件通知(例如中断通知任务),那么通常 不需要提前释放信号量,而是等待事件真正发生时再释放信号量。

创建二值信号量的本质就是:

  1. 创建一个长度为1,元素大小为0的消息队列
  2. 在创建之初,队列控制结构体中的成员uxMessagesWaiting取值是0,表示没有信号。

释放二值信号量:xSemaphoreGive()

该函数用于 释放信号量

也可以理解为:发送信号量、发送通知。

该函数原型如下:

c 复制代码
BaseType_t xSemaphoreGive(
    SemaphoreHandle_t xSemaphore    // 要操作的二值信号量句柄
);

当任务调用该函数时:信号量的值变为 1,从无到有,从不满足条件到满足条件

如果此时有任务正在 等待该信号量:

那么系统会:唤醒等待的任务。

其返回值,表示释放信号的两种结果:

  1. pdTRUE,释放成功
  2. pdFALSE,释放失败

什么时候会释放成功,什么时候会释放失败呢?

  1. 当二值信号量已经为 1 时,再次调用 xSemaphoreGive() 会失败并返回 pdFALSE,因为信号量已经处于满状态。
  2. 如果二值信号量还是0,调用此函数,就会释放信号成功,并返回值pdTRUE

为什么这个函数不需要设置阻塞时间?

释放信号不会阻塞任务,因为释放信号本身不需要等待条件成立,没有信号时就释放成功,有信号时直接释放失败。

释放二值信号量的本质可以理解为:改变队列中"元素数量"的状态。

在调用 xSemaphoreGive() 释放信号量时:

  1. 如果uxMessagesWaiting的取值是0,表示当前没有信号,此时函数会将其设置为 1 ,表示信号产生,并返回 pdTRUE
  2. 如果uxMessagesWaiting的取值已经是1,表示信号已经存在,此时函数不会阻塞,也不会改变状态,直接返回 pdFALSE

获取二值信号量:xSemaphoreTake()

该函数用于 获取信号量

也可以理解为:等待接收信号量、等待事件发生、等待通知。

函数原型如下:

c 复制代码
BaseType_t xSemaphoreTake(
    SemaphoreHandle_t xSemaphore,   // 要操作的二值信号量句柄
    TickType_t xTicksToWait // 等待的最大阻塞时间
);

其函数中的参数,和消息队列中xQueueReceive函数的参数类似:

  1. xSemaphore,表示要操作的 信号量句柄。
  2. xTicksToWait,表示等待信号量的最大阻塞时间。其取值分为三种:
    1. 等于0,不等待,立刻返回获取结果,即有信号或无信号。
    2. 大于0,表示最多阻塞等待xTicksToWait个Tick,如果超时后仍无信号,则会返回获取失败的结果。
    3. portMAX_DELAY,表示无限期阻塞等待信号,不会因超时返回获取失败。

其返回值表示获取信号的两种结果:

  1. pdTRUE,获取成功
  2. pdFALSE,获取失败

看到这里,你应该已经很熟悉了,这不是和消息队列差不多?不着急,我们继续看。

获取二值信号量的本质,也可以理解为:改变队列中"元素数量"的状态。

在调用 xSemaphoreTake() 获取信号量时:

  1. 如果uxMessagesWaiting的取值是0,表示当前没有信号,此时函数会根据 xTicksToWait 的取值决定是否进入阻塞等待,或者直接返回失败。
  2. 如果uxMessagesWaiting的取值是1,表示信号存在,此时函数会 获取该信号 ,并将 uxMessagesWaiting 的值 设置为 0 ,然后返回 pdTRUE

在中断环境释放信号量:xSemaphoreGiveFromISR()

xSemaphoreGive() 只能在任务环境中调用,如果在 中断服务函数(ISR) 中调用该函数,是不允许的。

FreeRTOS 专门提供了一个 中断版本的释放函数

c 复制代码
BaseType_t xSemaphoreGiveFromISR(
 SemaphoreHandle_t xSemaphore,  // 要操作的二值信号量句柄
 BaseType_t *pxHigherPriorityTaskWoken  // 是否需要在ISR结束后,触发高优先级任务切换
);

这个函数的作用是:

在中断服务函数中释放信号量。

该函数和在中断中发送数据到消息队列函数,使用起来完全是一致的。

如果需要要在ISR结束后,立刻触发高优先级任务切换,则需要按照下列方式进行函数调用:

c 复制代码
// 某个中断的ISR
void XXXX_IRQHandler(void){
 BaseType_t xFlag = pdFALSE;
 xSemaphoreGiveFromISR(BinarySem, &xFlag);
 portYIELD_FROM_ISR(xFlag);
}

采用上面的调用方式时,如果随着信号释放,某个高优先级的任务被唤醒,那么:

ISR结束后,系统会 立即切换到该任务执行。

这么做可以尽早让信号接收者任务响应,提升系统的实时性。

简单代码示例

先给出一个简单的代码示例,演示一下上述二值信号量API函数的使用。

如下:

c 复制代码
#include "stm32f10x.h"

#include "FreeRTOS.h"
#include "task.h"
#include "semphr.h"

#include "DebugUSART1.h"

static SemaphoreHandle_t BinarySem = NULL;

/* -------------------- 任务1:释放信号量任务 -------------------- */
void TaskGive(void *Argument) {
 while (1) {
        // 间隔1s 释放一次信号量
     vTaskDelay(1000);

     BaseType_t Ret  = xSemaphoreGive(BinarySem);
     if (Ret == pdTRUE) {
         printf1("[Give] Binary Semaphore Give Success \n");
     } else {
         printf1("[Give] Binary Semaphore Give Failed \n");
     }
 }
}

/* -------------------- 任务2:等待信号量任务 -------------------- */
void TaskTake(void *Argument) {
 while (1) {
     printf1("[Take] Waiting Semaphore... \n");

     BaseType_t Ret  = xSemaphoreTake(BinarySem, portMAX_DELAY);
     if (Ret == pdTRUE) {
         printf1("[Take] Got Semaphore! \n");
     }else{
            printf1("[Take] Got Semaphore failed! \n");
        }
     // 获取信号处理后,延时5s
     vTaskDelay(5000);
 }
}

int main(void) {
 DebugUSART1_Init();

 /* 创建二值信号量 */
 BinarySem = xSemaphoreCreateBinary();

 if (BinarySem == NULL) {
     printf1("Binary Semaphore Create Failed! \n");
     while (1) {}
 }

 /*
     * 刚创建出来的二值信号量默认是空的,Take 会阻塞等待
     * 释放信号量的任务优先级更高,只要不阻塞,会获取CPU执行权释放信号
     */

    xTaskCreate(TaskGive,
                "TaskGive",
                configMINIMAL_STACK_SIZE,
                NULL,
                tskIDLE_PRIORITY + 2,
                NULL);

    xTaskCreate(TaskTake,
                "TaskTake",
                configMINIMAL_STACK_SIZE,
                NULL,
                tskIDLE_PRIORITY + 1,
                NULL);

    vTaskStartScheduler();

    while (1) {}
}

通过该示例,可以观察到二值信号量具有以下几个特点:

  1. 二值信号量只有两种状态:可用(1)或不可用(0)。
  2. 刚创建出来的二值信号量默认是空的(不可用状态)。因此如果任务立即执行 xSemaphoreTake(),任务会进入阻塞等待。
  3. 当信号量为空时,执行 xSemaphoreTake() 的任务会被阻塞。任务会从就绪态进入阻塞态,等待信号量被释放。
  4. 当其他任务执行 xSemaphoreGive() 释放信号量后,等待该信号量的任务会被唤醒继续运行。
  5. 二值信号量最多只能保存一个"信号"。如果信号量已经处于可用状态,再次执行 xSemaphoreGive() 会失败。
  6. 二值信号量常用于实现任务同步或事件通知,而不是用于传递数据。

串口接收数据的优化

在前面使用 FreeRTOS 消息队列 的例子中,我们已经实现了这样一个功能:

通过 USART1 串口接收数据,再由任务从消息队列中取出数据进行解析,最终控制 LED 的点亮与熄灭。

这个程序在正常情况下当然是没有问题的。

但是现在,我们还需要进一步思考一个问题:如果系统运行过程中出现了异常情况怎么办?

比如说:

  1. USART1 接收中断持续不断地收到数据,发送到消息队列的速度太快
  2. 负责接收队列数据的任务,因为某些原因没有及时运行
  3. 消息队列中的数据越积越多,最终把队列塞满
  4. 此时中断中继续向队列发送数据,就会发送失败,导致数据丢失

这就是一种很典型的 系统运行时异常

如果出现这些异常情况,怎么办呢?

这里要注意:

我们现在暂时先不去讨论:

出现异常以后,到底该怎么恢复、怎么补救、怎么重试。

因为在真正处理异常之前,还有一个更基础、更重要的问题:

程序必须先知道异常已经发生了。

如果程序连异常发生了都不知道,后面自然也就谈不上做任何处理。

所以这里的第一步,不是"处理异常",而是:

先把异常上报出来,通知系统:现在出问题了。

而在 FreeRTOS 中,二值信号量 就非常适合完成这样的工作。

因为二值信号量本身并不负责传递具体数据, 它更适合表达这样一种含义:

某个事件发生了,请通知另外一个任务立即去处理。

这里的"事件",就可以是:

消息队列已满,串口中断发送队列失败,系统发生了异常。

因此,我们完全可以这样设计:

  1. USART1 中断 仍然负责接收串口数据,并尝试把数据发送到消息队列中
  2. 如果发送成功,说明一切正常,程序继续运行
  3. 如果发送失败,说明消息队列可能已经满了,也就是系统发生了异常情况
  4. 此时中断中不直接做复杂处理,而是 释放一个二值信号量
  5. 随后,由一个 更高优先级的异常处理任务 去等待这个二值信号量
  6. 一旦信号量被释放,说明系统出现了异常,该任务就会被立即唤醒,进行后续处理

这样一来,程序的结构就会变得比较清晰:

消息队列负责传递正常数据,二值信号量负责上报异常事件。

也就是说:

消息队列传数据,信号量传异常通知。

下面给出代码示例:

USART.h头文件:

c 复制代码
#ifndef __USART1_H__
#define __USART1_H__

#include "stm32f10x.h"
#include <stdio.h>  
#include <stdarg.h>
#include <string.h>
#include "FreeRTOS.h"
#include "queue.h"
#include "semphr.h"

/* main.c 里创建的队列句柄 */
extern QueueHandle_t UartRxQueue;
extern SemaphoreHandle_t UartErrSem;

/**
 * @brief  初始化串口 USART1
 */
void USART1_Init(void);

/**
 * @brief  调试打印函数(串口版 printf)
 */
void printf1(const char *format, ...);

#endif

USART.c源文件:

c 复制代码
#include "USART1.h"

/* ================= 内部函数(不对外暴露) ================= */

/**
 * @brief  USART1 发送单字节(阻塞式)
 * @note   仅供本文件内部使用
 */
static void DebugUSART1_SendByte(uint8_t Byte) {
    /* 等待发送数据寄存器为空 */
    while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);

    USART_SendData(USART1, Byte);
}

/**
 * @brief  初始化 USART1 串口
 */
void USART1_Init(void) {
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE);
    GPIO_InitTypeDef GPIO_InitStructure;

    /* PA9 -> USART1_TX:复用推挽输出 */
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOA, &GPIO_InitStructure);

    /* PA10 -> USART1_RX:上拉输入 */
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOA, &GPIO_InitStructure);

    /* USART 参数配置:115200, 8N1 */
    USART_InitTypeDef USART_InitStructure;
    USART_InitStructure.USART_BaudRate = 115200;
    USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;
    USART_InitStructure.USART_Mode = USART_Mode_Tx | USART_Mode_Rx;
    USART_InitStructure.USART_Parity = USART_Parity_No;
    USART_InitStructure.USART_StopBits = USART_StopBits_1;
    USART_InitStructure.USART_WordLength = USART_WordLength_8b;
    USART_Init(USART1, &USART_InitStructure);
    USART_Cmd(USART1, ENABLE);

    // 以下为USART1全局中断配置
    USART_ITConfig(USART1, USART_IT_RXNE, ENABLE);

    NVIC_InitTypeDef NVIC_InitStructure;
    NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn;
    // 使用FreeRTOS时
    // 中断优先级一律建议采用分组4,即4位优先级寄存器全部配置给抢占优先级
    // 抢占优先级设置为12,不建议高于11(可以设置为11)
    // 原因请看文档或者询问老师
    NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 12;
    NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0;
    NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
    NVIC_Init(&NVIC_InitStructure);

    USART_Cmd(USART1, ENABLE);
}

/**
 * @brief  调试打印函数(串口版 printf)
 * @param  format: 格式化字符串,和printf函数的第一个参数完全一致
 * @note
 *         1. 用法与 printf 类似
 *         2. 实际输出通过 USART1 完成
 *         3. 用于 FreeRTOS 任务调试与运行观测
 */
void printf1(const char *format, ...) {
    char buffer[100];
    va_list list;
    va_start(list, format);
    vsprintf(buffer, format, list);
    va_end(list);

    for (uint16_t i = 0; buffer[i] != '\0'; i++) {
        if (buffer[i] == '\n') {
            DebugUSART1_SendByte('\r');
        }

        DebugUSART1_SendByte((uint8_t)buffer[i]);
    }
}

/**
  * @brief USART1中断:收到 1 字节就塞进 FreeRTOS 队列
  */
void USART1_IRQHandler(void) {
    if (USART_GetITStatus(USART1, USART_IT_RXNE) == SET) {
        char ch = (char)USART_ReceiveData(USART1);

        BaseType_t xFlag = pdFALSE;
        BaseType_t Ret = xQueueSendFromISR(UartRxQueue, &ch, &xFlag);

        if (Ret != pdPASS) {
            /* 通知高优先级异常处理任务 */
            if (UartErrSem != NULL) {
                xSemaphoreGiveFromISR(UartErrSem, &xFlag);
            }
        }

        // 如果xFlag在上述函数中被设置为pdTRUE
        // 则下面的宏函数会触发PendSV,使得ISR结束后触发任务切换
        portYIELD_FROM_ISR(xFlag);

    }
}

main.c源文件:

c 复制代码
#include "stm32f10x.h"
#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"
#include "semphr.h"
#include "LED.h"
#include "USART1.h"

#define BUFFER_SIZE  20

QueueHandle_t UartRxQueue;
SemaphoreHandle_t UartErrSem = NULL;

/* -------------------- 任务:接收并解析串口命令 -------------------- */
void TaskRecv(void *Argument) {
    char Buffer[BUFFER_SIZE] = { 0 };   // 局部变量必须手动初始化0值
    uint8_t BufferIndex = 0;

    while (1) {
        char ch;

        /* 无限阻塞等待接收队列数据,也就是等待串口发数据 */
        if (xQueueReceive(UartRxQueue, &ch, portMAX_DELAY) == pdPASS) {
            /* 如果接收到的是换行符相关的,直接丢弃 */
            if (ch == '\r' || ch == '\n') {
                continue;
            }

            /* 拼接字符串,永远保证 '\0' 结尾 */
            if (BufferIndex < BUFFER_SIZE - 1) {
                Buffer[BufferIndex++] = ch;
                Buffer[BufferIndex] = '\0';
            } else {
                // 如果接收过长的数据导致Buffer溢出
                // 那么多余数据直接用空字符拼在Buffer末尾
                Buffer[BUFFER_SIZE - 1] = 0;
            }

            if (strcmp(Buffer, "OK") == 0) {
                printf1("Light On \r\n");
                LED_On();
                memset(Buffer, 0, sizeof(Buffer));
                BufferIndex = 0;
            } else if (strcmp(Buffer, "ERROR") == 0) {
                printf1("Light Off \r\n");
                LED_Off();
                memset(Buffer, 0, sizeof(Buffer));
                BufferIndex = 0;
            }

            /* 非法指令输入,清空Buffer */
            if (BufferIndex > 0 && Buffer[0] != 'O' && Buffer[0] != 'E') {
                memset(Buffer, 0, sizeof(Buffer));
                BufferIndex = 0;
            }

            /* 长度超过 ERROR(5) 就清空Buffer */
            if (BufferIndex > 5) {
                memset(Buffer, 0, sizeof(Buffer));
                BufferIndex = 0;
            }
        }
    }
}

/* -------------------- 任务2:异常处理任务 -------------------- */
void TaskUartError(void *Argument) {
    while (1) {
        /* 阻塞等待异常事件通知 */
        if (xSemaphoreTake(UartErrSem, portMAX_DELAY) == pdTRUE) {
            printf1("Error: USART RX Queue Overflow! \n");

            /*
             * 这里可以继续扩展异常处理逻辑,例如:
             * 1. 点亮告警灯
             * 2. 让蜂鸣器工作
             * 其他处理....
             */
        }
    }
}

int main(void) {
    NVIC_PriorityGroupConfig(NVIC_PriorityGroup_4);
    LED_Init();
    USART1_Init();

    /* 创建消息队列:长度 20,每个元素 1 字节 */
    UartRxQueue = xQueueCreate(20, sizeof(char));

    if (UartRxQueue == NULL) {
        printf1("Error: UartRxQueue create failed. \n");

        while (1) {}
    }

    /* 创建二值信号量 */
    UartErrSem = xSemaphoreCreateBinary();

    if (UartErrSem == NULL) {
        printf1("Error: UartErrSem create failed. \n");

        while (1) {}
    }

    xTaskCreate(TaskRecv,
                "TaskRecv",
                configMINIMAL_STACK_SIZE + 128,
                NULL,
                tskIDLE_PRIORITY + 1,
                NULL);

    /* 高优先级异常处理任务 */
    xTaskCreate(TaskUartError,
                "TaskUartError",
                configMINIMAL_STACK_SIZE + 128,
                NULL,
                tskIDLE_PRIORITY + 2,
                NULL);

    vTaskStartScheduler();

    while (1) {}
}

如此,我们就在原本 "串口中断 + 消息队列 + 接收任务解析" 的基础上,又补充了一层 异常事件通知机制。

整个程序的运行逻辑,就可以概括为下面这样:

  1. USART1 接收到 1 个字节数据,进入接收中断
  2. 中断中尝试将该字节发送到 UartRxQueue 消息队列
  3. 如果发送成功,说明系统当前处理能力正常,程序继续按照原来的逻辑运行
  4. 如果发送失败,说明消息队列可能已经满了,也就是系统当前出现了异常情况
  5. 此时中断中不去直接做复杂处理,而是释放二值信号量 UartErrSem
  6. 高优先级任务 TaskUartError 一直阻塞等待该信号量
  7. 一旦信号量被释放,说明异常发生,TaskUartError 会被立即唤醒并执行相应处理逻辑

这样写有一个非常明显的好处:

中断中只负责"发现异常并上报异常",而不负责"详细处理异常"。

计数信号量(了解)

除了二值信号量,FreeRTOS 还提供 计数信号量(Counting Semaphore) 作为一种任务间通信的手段。

计数信号量在实际项目中 使用频率并不算高,一般了解其原理和基本使用方式即可。

从功能上来说,计数信号量大体可以实现两类用途:

  1. 计数功能,释放一次信号,计数器的值就会 + 1:
    1. 比如某个中断每触发一次,就释放一次信号量,那么信号量的计数值就可以用来表示"事件发生了多少次"。
    2. 但这种用途完全可以用消息队列来实现,而且消息队列还能携带实际数据,比计数信号量好用。
    3. 因此,在实际工程中,如果只是为了统计事件次数,通常直接使用消息队列即可。
  2. 计数信号量真正比较适合的场景,是:系统中存在多个相同资源,而且存在多个任务同时竞争,此时需要进行资源数量管理。

例如:

系统中有多个缓冲区Buffer,此时多个任务都可能申请这些资源。

如果资源已经被全部占用,那么新的任务就需要 等待资源释放

在这种场景下,就可以使用计数信号量来管理资源数量。

因此可以总结为一句话:

计数信号量的核心作用是:管理"多个相同资源"的数量。

理解这一点之后,计数信号量的使用方式其实就非常简单了。

而且:

计数信号量对于 "多个相同资源" 的管理问题,具有非常独特的作用,可以很好地表示 当前系统中还剩多少可用资源。

FreeRTOS 的其他任务间通信机制,一般很难直接实现类似的功能。(多任务竞争多个资源)

但可惜的是:

FreeRTOS更适用于资源紧张的嵌入式环境中,这种多资源竞争管理的场景,确实很少见。

连带着,计数信号量也就不太常用了。

但如果真的涉及到这种场景,还是可以考虑使用计数信号量。

计数信号量的本质

计数信号量和二值信号量,本质上是同一种东西。

甚至可以说:

计数信号量只是"容量更大的二值信号量"。

在 FreeRTOS 中:

所有的信号量,本质上都是基于消息队列实现的,计数信号量当然也不例外。

计数信号量在创建时,最终依然会调用xQueueGenericCreate()函数。

如下图所示:

这意味着:

  1. 计数信号量在底层仍然是一个消息队列结构体(Queue_t / xQUEUE)。
  2. 队列长度不再是 1,而是 uxMaxCount,元素大小仍然是 0

举个例子:

在创建计数信号量时,我们指定uxMaxCount的值是5,那么实际上等价于:

创建一个长度为 5 的消息队列,每个元素大小为 0 字节。

由于元素大小为 0 字节 ,因此这个队列仍然 不存储任何实际数据

但是:

队列中"元素的个数",就可以表示当前信号量的计数值。

这个数量,仍然保存在 Queue 控制结构体 中的成员:

c 复制代码
uxMessagesWaiting

因此:

计数信号量的计数值,其实就是uxMessagesWaiting的当前取值。

从底层结构来看,可以用一句话总结二值信号量和计数信号量:

  1. 二值信号量,本质是一个长度为1的"0字节消息队列",用uxMessagesWaiting的1和0表示两种不同的状态。
  2. 计数信号量,本质是一个长度为N的"0字节消息队列",把uxMessagesWaiting的具体值作为一个计数器。

理解了这一点之后,下面来学习计数信号量的操作API就非常简单了。

计数信号量的操作API

首先,计数信号量机制在 FreeRTOS 中是 可裁剪配置的功能

它的使用依赖于下面这个宏是否开启:

c 复制代码
#define configUSE_COUNTING_SEMAPHORES 1

如果该宏被设置为 1,则表示 开启计数信号量功能

如果该宏为 0,则表示 关闭计数信号量功能

需要注意的是:

这个宏的默认值是 0,也就是说:计数信号量在 FreeRTOS 中默认是关闭的任务间通信机制。

如果需要在工程中使用计数信号量,就需要手动在 FreeRTOSConfig.h 文件中显式开启该宏。


现在,你已经知道计数信号量和二值信号量的区别:

计数信号量无非就是消息队列元素数量是N,uxMessagesWaiting的最大值是N的二值信号量。

所以计数信号量的操作 API 与二值信号量基本一致,只是 创建函数不同。

创建计数信号量xSemaphoreCreateCounting()

创建计数信号量的函数:xSemaphoreCreateCounting()

其函数原型如下:

c 复制代码
SemaphoreHandle_t xSemaphoreCreateCounting(
 UBaseType_t uxMaxCount,        // 底层消息队列的元素数量,也就是信号量允许的最大计数值,也就是uxMessagesWaiting的最大值
 UBaseType_t uxInitialCount // 计数值的初始值,也就是uxMessagesWaiting的初始值
);

举一个例子:

c 复制代码
SemaphoreHandle_t Sem;
Sem = xSemaphoreCreateCounting(5, 5);

函数的返回值和二值信号量并无不同,都是返回信号量句柄。

表示:

  1. uxMessagesWaiting成员的最大值是5
  2. uxMessagesWaiting成员的初始值是5

如果用计数信号量来表示可用资源数量:

  1. 当前最大可用资源数量为 5
  2. 当前可用资源数量为 5

获取信号量:xSemaphoreTake()

用于 获取信号量(表示获取消耗一个资源)。

该函数和二值信号量的获取信号是同一个函数:

c 复制代码
BaseType_t xSemaphoreTake(
    SemaphoreHandle_t xSemaphore,   // 要操作的信号量句柄
    TickType_t xTicksToWait         // 最大等待阻塞时间
);

返回值:

  1. pdTRUE,获取信号成功(资源获取成功)
  2. pdFALSE,获取信号失败(在规定时间内,获取资源失败)

对于计数信号量来说,xSemaphoreTake()函数调用一次,本质上是:uxMessagesWaiting--

释放信号量:xSemaphoreGive()

用于 释放信号量(表示释放,结束占用一个资源)。

该函数和二值信号量的释放信号是同一个函数:

c 复制代码
BaseType_t xSemaphoreGive(
    SemaphoreHandle_t xSemaphore    // 要操作的信号量句柄
);

返回值:

  1. pdTRUE,释放信号成功(资源释放成功,解除占用)
  2. pdFALSE,释放信号失败(对于资源管理来说,只要正确编写代码不存在消息队列满,导致资源释放失败的情况,因为只要总是先占用再释放)

对于计数信号量来说,xSemaphoreGive()函数调用一次,本质上是:uxMessagesWaiting++

在中断环境释放信号量:xSemaphoreGiveFromISR()

用于 在中断中,释放信号量(表示释放,结束占用一个资源)。

该函数和二值信号量的在中断中释放信号是同一个函数:

c 复制代码
BaseType_t xSemaphoreGiveFromISR(
    SemaphoreHandle_t xSemaphore,   // 要操作的二值信号量句柄
    BaseType_t *pxHigherPriorityTaskWoken   // 是否需要在ISR结束后,触发高优先级任务切换
);

返回值:

  1. pdTRUE,释放成功(资源释放成功,解除占用)
  2. pdFALSE,释放失败(对于资源管理来说,只要正确编写代码不存在消息队列满,导致资源释放失败的情况)

对于计数信号量来说,xSemaphoreGiveFromISR()函数调用一次,本质上是:uxMessagesWaiting++

只不过是在中断环境下完成这个事情。

计数信号量在资源管理上的约定

在使用计数信号量进行资源管理时,有一个非常重要的约定:

计数信号量的数值表示的是"当前可用资源数量"。

而不是:当前已使用资源数量。

因此在资源管理场景中,应当遵循如下规则:

  1. xSemaphoreCreateCounting(3, 3) → 共有3个可用资源,且一开始都处于可用状态。uxMessagesWaiting的初始值是3
  2. xSemaphoreTake() → 获取信号量 → 获取占用一个资源 → uxMessagesWaiting--
  3. xSemaphoreTake() → 释放信号量 → 释放,解除占用一个资源 → uxMessagesWaiting++

一旦计数变为:0

即说明:系统已经没有可用资源

新的任务调用 xSemaphoreTake() 想要获取资源时,就会 阻塞等待资源释放,如此就实现了对资源的管理。

代码示例

如果系统中有些资源需要多任务竞争使用,那么我们就可以使用 计数信号量 来实现这种资源管理场景。

参考代码示例如下:

c 复制代码
#include "stm32f10x.h"

#include "FreeRTOS.h"
#include "task.h"
#include "semphr.h"

#include "DebugUSART1.h"

/* 计数信号量:表示可用资源个数 */
SemaphoreHandle_t ResourceSem;

/* -------------------- 通用任务函数 -------------------- */
void TaskResource(void *argument) {
    char *taskName = (char *)argument;

    while (1) {
        /* 循环尝试获取资源 */
        while (xSemaphoreTake(ResourceSem, 500) != pdTRUE) {
            printf1("%s waiting resource\r\n", taskName);
        }

        printf1("%s get resource\r\n", taskName);

        /* 模拟占用资源 */
        vTaskDelay(10000);

        printf1("%s release resource\r\n", taskName);

        /* 释放资源 */
        xSemaphoreGive(ResourceSem);

        /* 稍微延时一下,方便观察现象 */
        vTaskDelay(1000);
    }
}

/* -------------------- main -------------------- */
int main(void) {
    DebugUSART1_Init();

    /*
        * 创建计数信号量
        * 最大计数值 = 2
        * 初始计数值 = 2
        * 表示系统当前有 2 个可用资源
        */
    ResourceSem = xSemaphoreCreateCounting(2, 2);

    if (ResourceSem == NULL) {
        while (1);
    }

    xTaskCreate(TaskResource,
                "TaskA",
                configMINIMAL_STACK_SIZE,
                "TaskA",
                tskIDLE_PRIORITY + 1,
                NULL);

    xTaskCreate(TaskResource,
                "TaskB",
                configMINIMAL_STACK_SIZE,
                "TaskB",
                tskIDLE_PRIORITY + 1,
                NULL);

    xTaskCreate(TaskResource,
                "TaskC",
                configMINIMAL_STACK_SIZE,
                "TaskC",
                tskIDLE_PRIORITY + 1,
                NULL);

    vTaskStartScheduler();

    while (1);
}

注:

如果printf1输出结果很乱,可以使用临界区加锁保障输出时不会发生任务切换。

通过这个示例可以清楚地看到:计数信号量的核心作用,就是管理"有限数量的资源"。

但这种场景,并不多见,了解即可。

互斥信号量

在之前的学习中,我们已经学习过 临界区(Critical Section)

临界区的作用是:

在一小段代码执行期间,暂时禁用低优先级中断或禁止任务调度。

典型形式如下:

c 复制代码
taskENTER_CRITICAL();

/* 访问共享资源 */

taskEXIT_CRITICAL();

通过这种方式,可以确保:

在执行临界区代码期间,不会发生任务切换,也不会被低优先级中断打断。

这样就可以保证 共享资源访问的原子性,从而保证线程安全。


但是,临界区也存在明显的局限性。

临界区的特点是:执行期间 CPU 不能被打断,无论中断亦或者其他任务,都无法获得CPU执行权。

基于这样的特点,临界区的使用必须做到:

临界区的代码必须 尽快执行完成并退出,避免持续占用CPU导致破坏系统的实时性,甚至整个系统运行出现问题。

因此:

临界区只能用于保护非常短小的、执行迅速的代码片段。


但在实际系统中,还有另一种情况:某个资源需要被任务长时间占用

例如:

  1. 串口发送数据
  2. 访问 I2C 总线
  3. 访问 SPI Flash
  4. 操作 OLED(本质上还是通信)
  5. ...

这些操作往往需要:

  1. 几百微秒
  2. 几毫秒
  3. 甚至更长时间

如果使用临界区保护这些操作,就会出现严重问题:整个系统在这段时间内 无法进行任务切换,也不能响应中断。

这显然是不可接受的。


因此,FreeRTOS 提供了另一种机制:

互斥信号量(Mutex)。

互斥信号量的核心思想是:

允许任务在等待资源时进入阻塞态,而不是一直占用 CPU。

当任务获取互斥信号量时:

  1. 如果共享资源空闲,任务会立即获得资源
  2. 如果资源被其他任务占用,当前任务会进入 阻塞态,主动放弃CPU执行权。
  3. 当共享资源释放后,阻塞任务会被唤醒。

这样:

在共享资源被占用的时候,CPU无需等待资源释放,还可以继续执行其他任务。

系统的 实时性和效率 都不会受到影响。

互斥信号量的本质

互斥信号量,本质上是一种特殊的二值信号量。

在 FreeRTOS 中:

互斥信号量同样是基于 消息队列机制实现的。

在创建互斥信号量时,内部最终同样会调用:

c 复制代码
xQueueGenericCreate()

源码如下图所示:

因此,从底层实现结构来看:

互斥信号量和二值信号量在消息队列层面是完全一致的。

具体表现为:

  1. 队列长度为 1
  2. 队列元素大小为 0 字节

同时,互斥信号量的状态同样是通过队列结构体中的成员 uxMessagesWaiting 的取值来表示。

从当前这些内容来看,互斥信号量和二值信号量几乎没有区别。

但是需要注意:

虽然二者使用的是同一个成员变量 uxMessagesWaiting,但在两种信号量中的 语义含义是完全不同的。

二值信号量 中:

成员 uxMessagesWaiting 的取值表示 信号是否存在

  1. uxMessagesWaiting = 1 时,表示 信号存在
  2. uxMessagesWaiting = 0 时,表示 信号不存在

而在 互斥信号量 中:

成员 uxMessagesWaiting 的取值表示 资源是否被占用:

  1. 当uxMessagesWaiting = 1时,表示 互斥信号量可用(即资源空闲)
  2. 当uxMessagesWaiting = 0时,表示 互斥信号量已被占用(即资源不可用)

因此:

当某个任务成功获取互斥信号量后,uxMessagesWaiting 会从 1 变为 0,表示资源已经被占用。

此时如果其他任务再次尝试获取该互斥信号量:

由于信号量已经不可用,这些任务就会进入 阻塞状态,等待资源释放。

只有当当前持有资源的任务调用 xSemaphoreGive() 释放互斥信号量后,其他等待的任务才有机会再次获取该资源。

因此,互斥信号量的主要作用是:

当多个任务需要访问同一个共享资源时,通过互斥信号量保证同一时刻只有一个任务能够访问该资源,从而实现对共享资源的线程安全访问。

从这个角度出发,在很多情况下,我们也会把 互斥信号量 直接称为:

FreeRTOS 中的"互斥锁"(Mutex Lock)。

当某个任务成功获取互斥信号量后,就相当于 获得了这把锁,并且 独占对应的共享资源。

只要该任务 不主动释放这把锁,其他任务就无法获取该互斥信号量,也就无法进入对共享资源的访问代码。

互斥锁可以保证某一段代码在同一时刻只会被一个任务执行。

这段被保护的代码区域,也可以称之为 临界区(Critical Section)。(思考一下和之前的临界区有啥区别)

例如,下列伪代码就展示了多个任务通过互斥锁访问共享资源的过程:

c 复制代码
// 任务A
获取互斥锁        // 成功获取锁,独占共享资源
....              // 对共享资源进行访问
释放互斥锁        // 资源使用完毕,释放锁


// 任务B
获取互斥锁        // 若此时锁已被任务A占用,则任务B会阻塞等待
....              // 成功获取锁后,访问共享资源
释放互斥锁

任务A获取互斥锁后 ,如果 任务B尝试获取该锁 ,由于资源已经被占用,任务B就会进入 阻塞状态,直到任务A释放互斥锁为止。

反之亦然。

互斥锁的实现原理

上面我们讲到:

互斥信号量本质上可以看作是一种 互斥锁

如果某个任务已经获取了互斥锁,那么:

其余任务再次尝试获取该锁时,就无法成功获取锁,只能 进入阻塞状态,等待锁被释放。

但是这里就会产生一个非常重要的问题:

如果互斥信号量只是一个简单的二值信号量结构,那么它显然无法实现真正的"互斥锁"机制。

原因其实很简单:

系统必须知道:这把锁当前到底是被哪个任务持有。

如果系统不知道锁的持有者是哪个任务,那么系统甚至无法判断当前任务是否持有锁,无法决定当前任务能否访问共享资源。

因此,在 FreeRTOS 中:

互斥信号量在普通信号量的基础上,额外增加了用于记录锁持有者的信息。

在源码中可以看到如下结构:

其中最关键的成员是:xMutexHolder

它用于记录:当前持有互斥锁的任务句柄(TaskHandle_t)。

此后,如果其他任务再次尝试获取该互斥锁,而锁已经被占用,则该任务会进入阻塞状态,等待锁释放。

下面总结一下互斥锁的实现原理:

  1. 通过 uxMessagesWaiting 表示互斥锁的占用状态。
    1. uxMessagesWaiting = 1 时,表示锁未被占用(资源可用);
    2. uxMessagesWaiting = 0 时,表示锁已被占用(资源不可用)。
  2. 通过 xMutexHolder 记录当前持有互斥锁的任务。
    1. 系统可以根据该成员判断 锁当前的持有者是谁,从而确定当前任务是否具有共享资源的访问权。
    2. 如果当前任务不具有共享资源的访问权,那么当前任务会进入阻塞状态,主动下CPU。

可以看到,二值配合,才真正实现了 互斥锁的机制。

优先级反转问题

FreeRTOS是一种多任务环境,优先级抢占式实时操作系统。

这意味着,正常情况下,优先级越高的任务越先执行,而且只要该任务不主动放弃CPU,那么它会一直执行下去。

但系统中一旦引入互斥信号量,使用互斥锁,就不可避免的会带来------优先级反转问题(Priority Inversion)

什么是优先级反转?

高优先级任务反而被低优先级任务"间接阻塞",明明高优先级任务处于就绪态,却始终无法上CPU,这种异常现象就是优先级反转。

下面具体来看一个场景(只是为了演示优先级反转问题,实际上FreeRTOS中不会这么运行)。

假设系统中有三个任务:

  1. TaskHigh(高优先级5)
  2. TaskMedium(中优先级3)
  3. TaskLow(低优先级1)

同时有一个共享资源,例如 串口,使用互斥信号量进行保护。

系统执行过程可能如下:

第一步:

系统启动时,只有低优先级任务 TaskLow 处于就绪态,其余任务还没有进入就绪状态。

于是低优先级的任务,先执行。

于是低优先级的任务,获取互斥锁,共享资源被TaskLow任务使用。

第二步:

高优先级任务 TaskHigh 变为就绪态,并尝试获取同一个 互斥锁。

但是:

互斥锁 已经被 TaskLow 占用,且没有被释放。

因此:

TaskHigh 只能进入阻塞态等待。

第三步:

这时 TaskMedium 变为就绪态。

由于:

TaskMedium 的优先级 高于 TaskLow ,因此调度器会让 TaskMedium 运行

而且任务 TaskMedium 不会主动放弃CPU,它会一直运行下去。

此时整个系统中的三个任务,它们的运行情况是:

  1. TaskHigh → 等待获取互斥锁,等待共享资源(阻塞)
  2. TaskMedium → 一直运行,一直占着CPU不放。
  3. TaskLow → 处于就绪态,但优先级低,无法获取CPU,没有执行权。

结果就是:

真正持有互斥锁的 TaskLow 无法运行,自然也无法释放 Mutex。

而 TaskHigh 又必须等待 Mutex。

于是就出现了一种奇怪的情况:高优先级任务,反而被中优先级任务间接阻塞了,高优先级的任务反而没有机会执行了。

这就是:优先级反转问题。

简单一句话总结:

高优先级任务因为资源竞争,被更低优先级任务间接阻塞执行。

这种情况显然是不合理的,那么如何解决这种优先级反转问题呢?

互斥信号量的重要机制:优先级继承

为了解决 优先级反转问题,FreeRTOS 在 互斥信号量 中实现了一种重要机制:优先级继承(Priority Inheritance)。

其核心思想其实非常简单:

当高优先级任务因等待互斥锁而进入阻塞状态时,系统会 临时提升当前锁持有任务的优先级,

使其优先级 提升到与等待该锁的最高优先级任务相同。

我们仍然用刚才的三个任务来举例:

  1. TaskHigh(优先级5)
  2. TaskMedium(优先级3)
  3. TaskLow(优先级1)

系统的真实运行过程如下:

第一步:

系统启动时,只有低优先级任务 TaskLow 处于就绪态,其余任务还没有进入就绪状态。

于是低优先级的任务,先执行。

于是低优先级的任务,获取互斥锁,共享资源被TaskLow任务使用。

第二步:

高优先级任务 TaskHigh 变为就绪态,并尝试获取同一个 互斥锁。

但是:互斥锁 已经被 TaskLow 占用,且没有被释放。

因此:TaskHigh 只能进入阻塞态等待。

这时 FreeRTOS 会发现:

高优先级任务正在等待一个被低优先级任务占用的 互斥锁。

于是系统会执行 优先级继承机制

临时把 TaskLow 的优先级提升到 TaskHigh 的优先级。

也就是说,原本的优先级关系是:

TaskHigh 5

TaskMedium 3

TaskLow 1

触发优先级继承后变成:

TaskHigh 5 (阻塞)

TaskLow 5 (临时提升)

TaskMedium 3

第三步:

此时即使 TaskMedium 进入就绪态,由于 TaskLow 的优先级已经被提升到 5 ,因此 TaskLow 会优先运行

这样 TaskLow 执行完共享资源访问操作,并释放 互斥锁。

第四步:

互斥锁 释放后:

  1. TaskHigh 立即被唤醒
  2. TaskHigh 立刻抢占 CPU(优先级最高)

与此同时:

释放互斥锁后,TaskLow 的优先级会恢复为原来的优先级。

系统优先级关系恢复正常。

因此可以用一句话总结 优先级继承机制

当高优先级任务等待互斥资源时,系统会临时提升资源持有者的优先级,使其尽快释放资源。

需要特别注意的一点是:

优先级继承只存在于互斥信号量中,其余信号量没有这样的机制。

所以我们可以得出一个结论:

如果需求是针对单一共享资源的保护,推荐使用互斥信号量。

互斥信号量的操作API

首先,互斥信号量机制在 FreeRTOS 中是 可裁剪配置的功能

它的使用依赖于下面这个宏是否开启:

c 复制代码
#define configUSE_MUTEXES 1

如果该宏被设置为 1,则表示 开启互斥信号量功能

如果该宏为 0,则表示 关闭互斥信号量功能

需要注意的是:

这个宏的默认值是 0,也就是说:互斥信号量在 FreeRTOS 中默认是关闭的任务间通信机制。

如果需要在工程中使用计数信号量,就需要手动在 FreeRTOSConfig.h 文件中显式开启该宏。


互斥信号量的API只需要学习一个新的。

创建互斥信号量:

c 复制代码
SemaphoreHandle_t xSemaphoreCreateMutex(void);

该函数用于 创建一个互斥信号量(互斥锁),返回值是一个互斥信号量句柄。

需要注意的是:

互斥信号量在创建成功后,其内部状态默认是 可用状态,也就是:

c 复制代码
uxMessagesWaiting = 1

表示当前没有任务占用该锁。

当任务需要访问共享资源时,可以调用如下函数 获取互斥锁

c 复制代码
BaseType_t xSemaphoreTake(
 SemaphoreHandle_t xSemaphore,
 TickType_t xTicksToWait
);

如果互斥锁当前 没有被其他任务占用,那么任务就可以成功获取锁,并进入临界区访问共享资源。

如果互斥锁 已经被其他任务持有 ,那么当前任务就会根据 xTicksToWait 的设置:

  1. 立即返回失败
  2. 或进入 阻塞状态等待锁释放

当任务使用完共享资源后,需要调用以下函数 释放互斥锁

c 复制代码
BaseType_t xSemaphoreGive(
    SemaphoreHandle_t xSemaphore
);

释放互斥锁后,系统会:

  1. 将锁状态恢复为 可用
  2. 并唤醒等待该锁的任务

需要特别注意的是:

互斥锁必须由"锁的持有者任务"进行释放。

也就是说:

获取互斥锁的任务,必须由 同一个任务调用 xSemaphoreGive() 释放锁

否则将会导致系统行为异常。


最后总结一下互斥信号量的基本使用流程:

c 复制代码
创建互斥锁 (xSemaphoreCreateMutex)
    ↓
任务获取互斥锁 (xSemaphoreTake)
    ↓
访问共享资源
    ↓
释放互斥锁 (xSemaphoreGive)

通过这种方式,就可以在多任务环境下实现 对共享资源的互斥访问。

一个很重要的经验总结

到这里为止,三种常见的信号量机制:二值信号量、计数信号量、互斥信号量,我们已经全部学完了。

我们可以对 信号量相关的操作 API 做一个简单的总结。

有以下几条:

  1. 三种信号量的创建函数各不相同,但本质上都是创建消息队列,返回消息队列句柄。
  2. 释放信号量都使用xSemaphoreGive()函数:
    1. 对于二值信号量而言,释放信号量表示信号状态从无到有,从0到1。
    2. 对于计数信号量和互斥信号量而言,释放信号量表示资源被释放,可用资源增加。
    3. 本质上,都是uxMessagesWaiting++
  3. 获取信号量都使用xSemaphoreTake()函数:
    1. 对于二值信号量而言,获取信号量表示信号状态从有到无,从1到0。
    2. 对于计数信号量和互斥信号量而言,获取信号量表示资源被获取占用,可用资源减少。
    3. 本质上,都是uxMessagesWaiting--
  4. 牢牢记住:Give 表示释放,Take 表示获取,三种信号量使用同样的函数进行获取和释放。

记住这几点,在理解和使用信号量机制时,很多问题都会变得更加清晰。

优先级继承:代码演示

对于下列演示代码:

Buzzer.h头文件:

c 复制代码
#ifndef __BUZZER_H
#define __BUZZER_H

#include "stm32f10x.h"

/* 初始化蜂鸣器 IO口接入PA0引脚 低电平工作 */
void Buzzer_Init(void);

/* 翻转蜂鸣器状态 */
void Buzzer_Toggle(void);

#endif

Buzzer.c实现源文件:

c 复制代码
#include "Buzzer.h"

/**
 * @brief  初始化蜂鸣器 GPIO
 * @note   蜂鸣器接在 PA0,低电平响
 */
void Buzzer_Init(void) {
    /* 1. 开启 GPIOA 时钟 */
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);

    /* 2. 配置 PA0 为推挽输出 */
    GPIO_InitTypeDef GPIO_InitStructure;

    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_2MHz;

    GPIO_Init(GPIOA, &GPIO_InitStructure);

    /* 默认关闭蜂鸣器 */
    GPIO_SetBits(GPIOA, GPIO_Pin_0);
}


/**
 * @brief  翻转蜂鸣器状态
 */
void Buzzer_Toggle(void) {
    if (GPIO_ReadOutputDataBit(GPIOA, GPIO_Pin_0) == Bit_SET) {
        GPIO_ResetBits(GPIOA, GPIO_Pin_0);
    } else {
        GPIO_SetBits(GPIOA, GPIO_Pin_0);
    }
}

main.c源文件:

c 复制代码
#include "stm32f10x.h"
#include "Delay.h"
#include "Buzzer.h"
#include "DebugUSART1.h"

#include "FreeRTOS.h"
#include "task.h"
#include "semphr.h"

/* 串口互斥信号量 */
SemaphoreHandle_t UartMutex;

/*
任务优先级关系:
    TaskLow     低优先级,先获取互斥锁
    TaskMedium  中优先级,模拟普通任务不断运行
    TaskHigh    高优先级,后续尝试获取互斥锁

    用于演示:

    1. 低优先级任务先持有锁
    2. 高优先级任务随后申请锁,被阻塞
    3. 中优先级任务本来会抢占低优先级任务
    4. 但由于使用的是 Mutex,具有优先级继承机制,低优先级任务会临时提升优先级
*/

/* -------------------- 低优先级任务:先持有互斥锁,使用共享串口 -------------------- */
void TaskLow(void *argument) {
    while (1) {
        printf1("\r\n[TaskLow] try take mutex\r\n");

        /* 低优先级任务先获取互斥锁 */
        xSemaphoreTake(UartMutex, portMAX_DELAY);

        printf1("[TaskLow] take mutex\r\n");

        /*
            模拟持锁工作较长时间

            这里故意分多次延时,
            让调度器有机会切换到别的任务,
            方便观察优先级继承现象。
        */
        for (int i = 1; i < 6; i++) {
            printf1("[TaskLow] working %d\r\n", i);
            Delay_Ms(1000); // TIM2实现的延时,会占用CPU进行延时
        }

        printf1("[TaskLow] give mutex\r\n");

        /* 释放互斥锁 */
        xSemaphoreGive(UartMutex);
    }
}

/* -------------------- 高优先级任务:后续申请互斥锁,使用共享串口 -------------------- */
void TaskHigh(void *argument) {
    /*
        先延时一段时间,
        确保 TaskLow 先上CPU拿到锁
    */
    vTaskDelay(500);

    while (1) {
        printf1("\r\n[TaskHigh] try take mutex\r\n");

        /*
        此时锁通常还在 TaskLow 手里,
        所以 TaskHigh 会进入阻塞态等待。
        */
        xSemaphoreTake(UartMutex, portMAX_DELAY);

        printf1("[TaskHigh] take mutex\r\n");

        for (int i = 1; i < 6; i++) {
            printf1("[TaskHigh] working %d\r\n", i);
            Delay_Ms(1000); // TIM2实现的延时,会占用CPU进行延时
        }

        printf1("[TaskHigh] give mutex\r\n");

        xSemaphoreGive(UartMutex);

        // 延时主动让出CPU
        // 否则高优先级任务会一直获取锁
        vTaskDelay(1000);
    }
}

/* -------------------- 中优先级任务:不使用共享串口,使用一个蜂鸣器代表工作 -------------------- */
void TaskMedium(void *argument) {
    /*
        先延时一段时间,
        确保 TaskLow 先上CPU拿到锁
    */
    vTaskDelay(1000);   // 注意这里的延时时间更长

    while (1) {
        /*
            中优先级任务不访问共享串口资源,
            只做蜂鸣器周期工作。
        */
        Buzzer_Toggle();
        vTaskDelay(200);
        //Delay_Ms(200);    
    }
}

/* -------------------- main -------------------- */
int main(void) {
    // 初始化串口和TIM2定时器
    DebugUSART1_Init();
    Delay_Init();
    Buzzer_Init();

    /* 创建互斥锁 */
    UartMutex = xSemaphoreCreateMutex();

    /* 创建低优先级任务 */
    xTaskCreate(TaskLow,
                "TaskLow",
                configMINIMAL_STACK_SIZE,
                NULL,
                tskIDLE_PRIORITY + 1,
                NULL);

    /* 创建中优先级任务 */
    xTaskCreate(TaskMedium,
                "TaskMedium",
                configMINIMAL_STACK_SIZE,
                NULL,
                tskIDLE_PRIORITY + 2,
                NULL);

    /* 创建高优先级任务 */
    xTaskCreate(TaskHigh,
                "TaskHigh",
                configMINIMAL_STACK_SIZE,
                NULL,
                tskIDLE_PRIORITY + 3,
                NULL);

    /* 启动调度器 */
    vTaskStartScheduler();

    while (1);
}

其中Delay_Ms是前面定时器阶段讲过,写过的,基于TIM2定时器实现的CPU阻塞式忙等待延时。

也就是说:使用Delay_Ms进行延时,会占用CPU进行阻塞。

这段代码的设计是这样的:

  1. 高优先级和低优先级任务竞争同一个串口资源。
  2. 中等优先级的任务,控制蜂鸣器翻转工作。

通过这段代码,你可以自行测试一下互斥信号量的工作机制原理。

互斥信号量 VS 计数信号量

在 FreeRTOS 中,互斥信号量 和 计数信号量 在底层实现上其实非常相似,它们本质上都是基于 消息队列机制 实现的。

而且看起来,它们都和资源管理、保护有关系。

但实际上,两者有明显区别。

互斥信号量(Mutex) 用于 保护单一共享资源。

其核心特点是:

  1. 同一时刻 只允许一个任务获取资源
  2. 内核会记录 当前资源持有者
  3. 支持 优先级继承机制,用于解决优先级反转问题

因此,互斥信号量通常用于:

  1. 保护串口
  2. 保护 SPI / I2C 总线
  3. 保护 OLED 的操作
  4. 保护一个全局数据结构,比如一个全局共享的Buffer

本质上,它解决的是:多个任务竞争同一个资源的线程安全问题。


计数信号量(Counting Semaphore) 则用于 管理多个相同资源的数量

其核心特点是:

  1. 同时允许 多个任务获取资源
  2. 资源数量由 计数值控制
  3. 没有 优先级继承机制

例如:

如果系统中有 3 个相同资源 ,那么最多允许 3 个任务同时获取资源,当资源耗尽时,新的任务必须等待。

因此,计数信号量解决的是:

多个任务竞争多个同类资源的问题。


可以用一句话简单总结两者的区别:

互斥信号量:保护一个共享资源。

计数信号量:管理多个同类型资源,避免在资源已经耗尽的情况下,任务仍然继续去抢占资源。

在嵌入式开发这种资源紧张的前提背景下,互斥信号量显然是更常用的。

互斥信号量是学习信号量的重点。

其余需要了解的细节

优先级反转优先级继承 讲完之后,关于互斥信号量,其实还有几个比较实用的注意点,可以简单了解一下。

第一,互斥信号量必须由持有者释放

互斥信号量有一个基本规则:谁获取,谁释放。

例如:

c 复制代码
TaskA  xSemaphoreTake(Mutex);
TaskB  xSemaphoreGive(Mutex);   // 不允许,不允许

这种用法会导致系统误以为锁被释放,从而破坏互斥访问,可能导致A和B两个任务同时访问共享资源。

同时这种做法还会破坏优先级继承机制。

总之,互斥锁必须遵循 "谁加锁,谁解锁" 的原则。

第二,互斥信号量不能在中断中使用(重点!!!!!)。

互斥信号量 只能在任务环境中使用,不能在中断服务函数(ISR)中使用。

原因是:

互斥信号量会记录锁持有者的任务句柄,而中断显然不是任务,没有任务句柄。

而且互斥信号量涉及 优先级继承机制,而中断并不是任务,没有任务优先级,也不存在任务调度关系。

因此:不要在中断中尝试使用互斥信号量。

第三,递归和函数嵌套中使用互斥信号量(了解)。

FreeRTOS 还提供了一种特殊的互斥信号量:

递归互斥信号量(Recursive Mutex)

创建函数:

c 复制代码
xSemaphoreCreateRecursiveMutex()

它允许 同一个任务多次获取同一个 Mutex,但释放时也必须释放相同次数。

通过下面的一个成员实现:

这种机制主要用于 函数嵌套调用时的资源保护,实际项目中使用较少,了解即可。

扩展问题:如果中断当中需要保护共享资源怎么办?

这是一个很好的问题,甚至很值得在面试中问一问。

在一个嵌入式系统中,共享资源大多是:

  1. 某个外设资源
  2. 某个缓冲区Buffer
  3. 某个全局变量
  4. ...

如果这些共享资源,需要同时被中断和任务访问,甚至被两个中断同时访问。

看起来,如果不采取任何保护机制,就非常有可能出现。

所以,理论上来说,中断访问共享资源时,也需要保护。

但从实际工程角度出来,我们不应该这么设计系统,也就是说:

在实际工程中,优良的设计是:不要在中断中处理复杂共享资源。

设计良好的嵌入式系统中,应当遵循一个原则:尽量不要让中断去操作复杂的共享资源。

具体来说:

  1. 中断只负责产生事件,产生信号,发送通知。
  2. 而不要让中断去处理事件,处理信号,处理通知。
  3. 中断只负责产生数据或事件,而任务负责消费和处理这些数据。

这样设计至少有三点好处:

  1. 中断可以更加短平快,执行时间更短,这符合中断的设计哲学。
  2. 避免复杂的同步问题,而不处理这些问题就可以更好的实现第一条。
  3. 系统实时性更好,这是第一条和第二条带来的好处。

FreeRTOS官方也更推荐使用这种设计方式。

也就是说:解决问题,不仅可以选择解决问题本身,还可以解决问题的提出者。

记住:系统设计远比单纯技术实现更重要。


当然,如果某些情况下 中断确实需要访问共享资源,那么也可以使用最简单的保护方法:

短暂关闭中断,也就是使用临界区。

c 复制代码
// 中断ISR当中
UBaseType_t uxSavedInterruptStatus;

uxSavedInterruptStatus = taskENTER_CRITICAL_FROM_ISR();

/* 访问共享资源 */

taskEXIT_CRITICAL_FROM_ISR( uxSavedInterruptStatus );

但需要注意:临界区必须短平快。


所以总结来说:

如果中断中需要保护共享资源,可以使用临界区。

但更好的系统设计方式是:

让中断只产生事件,由任务去处理资源。

这样既简单,也更安全,也是 FreeRTOS 推荐的设计模式。


总结如下:

  1. 如果共享资源只是 全局变量或 Buffer 缓冲区 ,在中断中使用 临界区简单保护 一下通常问题不大。
  2. 但如果共享资源是 串口、I2C 等重量级通信外设 资源,一般 不建议在中断中直接处理数据。
  3. 更合理的做法是:中断只负责产生事件,任务负责处理数据。也就是:中断做通知,任务做处理。

临界区和互斥信号量的案例对比

如果是对 轻量级资源 的共享竞争,比如竞争某个全局变量、某个缓冲区等,此时使用 临界区 是完全可以的。

因为这类资源的临界区代码通常 非常短、执行很快

虽然理论上临界区会 短时间禁止任务切换,对系统实时性有一定影响,但为了保证数据访问的正确性,这种开销是完全可以接受的。

但如果是对 重量级资源 的共享竞争,比如:

多个任务竞争 某个外设资源(串口、I2C、SPI 等)

此时就更建议使用 互斥信号量 来进行访问控制,从而提升整体系统的运行效率。

你可以这样理解这两种机制的区别:

  1. 临界区的含义是:在临界区代码执行期间,调度器被暂时锁定,其它任务无法进行任务切换,必须等待临界区执行结束。
  2. 互斥信号量的含义是:只有需要竞争该资源的任务才会等待锁,而不涉及该资源的任务仍然可以正常被调度执行。

再用一个更通俗的类比来理解:

  1. 临界区就像是:
    1. 所有人都必须排队竞争同一个资源
    2. 后面的人,都必须等待前面的人完成操作之后,才能得以运行。
    3. 如果每个人的操作都很快,那问题不大,即便后面的人也能很快得到资源。
    4. 但如果把每个人操作的时间都延长,那后面的人想要获取资源就非常困难了。
  2. 互斥信号量则像是:
    1. 愿意竞争资源的人就排队。
    2. 既然你愿意竞争资源,愿意排队,那么即便等得久一些也是你乐意的。
    3. 而不需要这个资源的人,可以继续去做自己的事情,不会受到影响。

经过多次讲解,相信大家已经对临界区和互斥信号量的区别,有了一定的了解。

下面我们再通过两段代码加深对它们区别的理解。

首先,我们仍然继续使用上面代码中写过的蜂鸣器模块代码。

随后我们在系统中,创建3个优先级一致的任务:

  1. 任务A和任务B竞争,同一个串口资源,进行串口输出。
  2. 任务C只负责翻转蜂鸣器状态。
  3. 由于三者优先级一致,采用时间片轮转调度,理论上蜂鸣器能够正常工作。

如果采用临界区来实现:

c 复制代码
#include "stm32f10x.h"
#include "Delay.h"

#include "FreeRTOS.h"
#include "task.h"
#include "Buzzer.h"
#include "DebugUSART1.h"

/* -------------------- 任务Beep:周期性蜂鸣器节拍 -------------------- */
void TaskBeep(void *argument) {
    while (1) {
        /* 翻转蜂鸣器的工作状态 */
        Buzzer_Toggle();

        /* 间隔 200ms */
        vTaskDelay(200);
    }
}


/* -------------------- 任务A:发送大量串口数据 -------------------- */
void TaskA(void *argument) {
    while (1) {
        /*
            进入临界区

            这样做可以保证:
            当前任务在整个打印期间,不会被其它任务打断,
            从而避免 TaskA 和 TaskB 的输出内容交叉混乱。

            但问题是:
            临界区期间,系统调度会受到影响。
        */
        taskENTER_CRITICAL();

        printf1("---------- TaskA START ----------\n");

        for (int i = 0; i < 20; i++) {
            printf1("TaskA running %d : 1234567890 ABCDEFGHIJKLMNOPQRSTUVWXYZ\r\n", i);
        }

        printf1("---------- TaskA END ----------\n\n");

        /* 退出临界区,恢复正常调度 */
        taskEXIT_CRITICAL();

        /* 延时一段时间 */
        vTaskDelay(500);
    }
}

/* -------------------- 任务B:发送大量串口数据 -------------------- */
void TaskB(void *argument) {
    while (1) {
        /*
            TaskB 也采用临界区保护串口发送
        */
        taskENTER_CRITICAL();

        printf1("---------- TaskB START ----------\n");

        for (int i = 0; i < 20; i++) {
            printf1("TaskB running %d : abcdefghij klmnopqrstuvwxyz 1234567890\r\n", i);
        }

        printf1("---------- TaskB END ----------\n\n");

        taskEXIT_CRITICAL();

        vTaskDelay(500);
    }
}

int main(void) {
    DebugUSART1_Init();
    Buzzer_Init();

    /* 串口任务 */
    xTaskCreate(TaskA,
                "TaskA",
                configMINIMAL_STACK_SIZE,
                NULL,
                tskIDLE_PRIORITY + 1,
                NULL);

    xTaskCreate(TaskB,
                "TaskB",
                configMINIMAL_STACK_SIZE,
                NULL,
                tskIDLE_PRIORITY + 1,
                NULL);

    /* 蜂鸣器节拍任务 */
    xTaskCreate(TaskBeep,
                "TaskBeep",
                configMINIMAL_STACK_SIZE,
                NULL,
                tskIDLE_PRIORITY + 1,
                NULL);

    vTaskStartScheduler();

    while (1);
}

在这个实现中:

蜂鸣器任务实际上 和串口资源毫无关系,它根本不需要参与串口资源的竞争。

但由于 临界区会锁定调度器,在任务A或任务B执行串口输出期间,其它任务都无法进行任务切换。

而串口打印本身是一个 相对耗时的操作,因此临界区会持续较长时间。

最终就会导致:

蜂鸣器任务无法按时获得CPU执行时间,表现为 断断续续、不规律、甚至卡顿式的工作状态

如果采用互斥信号量来实现:

c 复制代码
#include "stm32f10x.h"

#include "FreeRTOS.h"
#include "task.h"
#include "semphr.h"

#include "DebugUSART1.h"
#include "Buzzer.h"

/* 串口互斥信号量句柄 */
SemaphoreHandle_t UartMutex;

/* -------------------- 任务A:发送大量串口数据 -------------------- */
void TaskA(void *argument) {
    while (1) {
        /*
            申请串口资源

            如果串口当前被其它任务占用,
            当前任务会进入阻塞态等待,
            不会一直占着 CPU 不放。
        */
        xSemaphoreTake(UartMutex, portMAX_DELAY);

        printf1("---------- TaskA START ----------\n");

        for (int i = 0; i < 20; i++) {
            printf1("TaskA running %d : 1234567890 ABCDEFGHIJKLMNOPQRSTUVWXYZ\r\n", i);
        }

        printf1("---------- TaskA END ----------\n\n");

        /* 释放串口资源 */
        xSemaphoreGive(UartMutex);

        // vTaskDelay(500);
    }
}

/* -------------------- 任务B:发送大量串口数据 -------------------- */
void TaskB(void *argument) {
    while (1) {
        /* 申请串口资源 */
        xSemaphoreTake(UartMutex, portMAX_DELAY);

        printf1("---------- TaskB START ----------\n");

        for (int i = 0; i < 20; i++) {
            printf1("TaskB running %d : abcdefghij klmnopqrstuvwxyz 1234567890\r\n", i);
        }

        printf1("---------- TaskB END ----------\n\n");

        /* 释放串口资源 */
        xSemaphoreGive(UartMutex);

        // vTaskDelay(500);
    }
}

/* -------------------- 任务Beep:周期性蜂鸣器节拍 -------------------- */
void TaskBeep(void *argument) {
    while (1) {
        /* 翻转蜂鸣器的工作状态 */
        Buzzer_Toggle();

        /* 间隔 200ms */
        vTaskDelay(200);
    }
}

/* -------------------- main -------------------- */
int main(void) {
    /* 初始化硬件 */
    DebugUSART1_Init();
    Buzzer_Init();

    /* 创建互斥信号量 */
    UartMutex = xSemaphoreCreateMutex();

    /* 创建任务A */
    xTaskCreate(TaskA,
                "TaskA",
                configMINIMAL_STACK_SIZE,
                NULL,
                tskIDLE_PRIORITY + 1,
                NULL);

    /* 创建任务B */
    xTaskCreate(TaskB,
                "TaskB",
                configMINIMAL_STACK_SIZE,
                NULL,
                tskIDLE_PRIORITY + 1,
                NULL);

    /* 创建蜂鸣器任务 */
    xTaskCreate(TaskBeep,
                "TaskBeep",
                configMINIMAL_STACK_SIZE,
                NULL,
                tskIDLE_PRIORITY + 1,
                NULL);

    /* 启动调度器 */
    vTaskStartScheduler();

    while (1);
}

在这种实现方式中:

只有 任务A 和 任务B 需要竞争串口资源,因此它们会通过 互斥信号量 进行访问控制。

当某个任务获取不到互斥信号量时,它会 进入阻塞态等待,不会持续占用 CPU。

而 蜂鸣器任务并不参与串口资源的竞争,因此它不会被迫等待阻塞。

蜂鸣器任务仍然可以按照 时间片轮转调度 正常获得执行机会,从而保持稳定的节拍工作。

信号量总结

到这里,我们已经把 FreeRTOS 中最常用的三类信号量机制全部讲完了,分别是:

  1. 二值信号量(Binary Semaphore)
  2. 计数信号量(Counting Semaphore)
  3. 互斥信号量(Mutex)

虽然这三种机制在使用形式上比较相似,都是通过 Take / Give 进行操作,但它们在 设计目的和使用场景 上是有所区别的。

简单总结如下:

类型 本质 主要作用
二值信号量 长度为1、元素大小为0的消息队列 任务同步 / 事件通知
计数信号量 长度>1、元素大小为0的消息队列 管理同类型的多个资源
互斥信号量 记录持有者,实现优先级继承机制的特殊二值信号量 保护重量级共享资源

从使用角度来看,可以这样理解:

  1. **二值信号量,**常用于 任务同步,例如:中断通知任务、任务之间的事件触发。
  2. 计数信号量 ,用于 管理同类型的多个资源,例如:多个缓冲区、多个连接、多个设备资源等。
  3. 互斥信号量 ,用于 保护重量级共享资源,例如:串口、I2C、SPI 等外设资源访问控制。

不过在实际的嵌入式开发中,真正 最重要、也最常使用的,往往是 互斥信号量(Mutex),也就是我们常说的 互斥锁。

计数信号量 用于管理多个同类型资源,在嵌入式系统中这样的场景很少见,因此计数信号量的使用频率也很低。

而 二值信号量 在很多场景下,已经被 任务通知(Task Notification)机制 所替代。

这是 FreeRTOS 官方非常力推的一种任务间通信方式。

下一章节,我们就来详细看看 任务通知 是如何工作的。

相关推荐
大阳1231 小时前
ARM6.(时钟设置,EPIT定时器)
单片机·嵌入式硬件·gpt·arm·时钟·imx6ull·epit
抓虾爪1 小时前
STM32F407VGT6一站式配齐丨粤科源兴ST分销商,同系列F4/F7/H7均可配套
stm32·单片机·嵌入式硬件
minglie12 小时前
zynq的网口和串口透传
学习
神奇的小猴程序员2 小时前
学习查理・芒格思维模型,整理自用资料查阅渠道
学习
项目題供诗2 小时前
STM32-输入捕获模式测频率&PWMI模式测频率占空比(十五)
stm32·单片机·嵌入式硬件
xian_wwq2 小时前
【学习笔记】提示词注入完全指南:五种变体,一套防御体系
笔记·学习·ai安全
做cv的小昊2 小时前
计算机图形学:【Games101】学习笔记06——几何(曲线和曲面、网格处理)、阴影图
c++·笔记·学习·游戏·图形渲染·几何学·光照贴图
AOwhisky2 小时前
MySQL 学习笔记(第二期):SQL 语言之库表操作与数据类型
linux·运维·数据库·笔记·sql·学习·mysql
段一凡-华北理工大学2 小时前
工业领域的Hadoop架构学习~系列文章11:Kerberos安全认证
数据仓库·hadoop·学习·架构·高炉炼铁·工业智能体·高炉炼铁智能化