GoDm@'s Blog

FreeRTOS的内部机制5

版权信息

warning

本文章为博主原创文章。遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。


本节我们正式开始讲解进程间通信的内容。首先要讲的就是队列了,为什么要先讲队列呢?因为信号量和互斥量都会复用队列的代码,所以先讲队列又利于我们后面的学习。

要想理解队列,只需理解三部分内容——环形缓冲区(存储数据)和链表(任务管理)

1. 队列详解

1.1 队列结构体

queue.c 中,我们可以找到队列的核心控制块 Queue_t(为了清晰,我精简了结构):

typedef struct QueueDefinition
{
    int8_t *pcHead;           /* 指向队列存储区的起始位置 */
    int8_t *pcWriteTo;        /* 下一个要写入的位置 */
    
    union {
        QueuePointers_t xQueue; /* 包含 pcReadFrom 指针,指示下一个要读取的位置 */
        // ... (省略了互斥量特有的变量)
    } u;

    /* 核心:两个链表 */
    List_t xTasksWaitingToSend;    /* 等待发送链表:当队列满了,想塞数据的任务在这排队 */
    List_t xTasksWaitingToReceive; /* 等待接收链表:当队列空了,想拿数据的任务在这排队 */

    volatile UBaseType_t uxMessagesWaiting; /* 队列里现在有几个数据 */
    UBaseType_t uxLength;                   /* 队列总共能装几个数据 */
    UBaseType_t uxItemSize;                 /* 每个数据有多大 (比如 sizeof(int)) */
} xQUEUE;
typedef xQUEUE Queue_t;

1.2 环形缓冲区

队列使用的数据存取结构是环形缓冲区(Circular Buffer,也叫 Ring Buffer)。经典的数据结构,完美解决了两个问题:内存固定(不产生碎片)极速读写(时间复杂度永远是 O(1))

一个标准的环形缓冲区只需要四个核心元素

  1. 一块连续的内存(比如一个数组 buffer[SIZE])。

  2. 读指针(Head / ReadIndex):指向下一个要被读取的数据位置。

  3. 写指针(Tail / WriteIndex):指向下一个新数据要写入的位置。

  4. 求余运算(Modulo %:这是让数组首尾相连变成“环”的关键。当指针走到数组末尾时,index = (index + 1) % SIZE 就能让指针又回到 0。

至于环形缓冲区的读写方法,请自行查阅相关资料。

  1. 和普通数组有何区别?

    假设我们有一个长度为 5 的普通数组用来做缓冲区。

    1. 我们按顺序写入了 3 个数据:[A, B, C, 空, 空]

    2. 现在我们读取了 1 个数据(读走 A)。数组变成了 [空, B, C, 空, 空]

    3. 如果继续接着写,写到末尾时 [空, B, C, D, E],数组尾部就没空间了。

    4. 笨办法:把 B,C,D,E 全部往前挪一格(极度消耗 CPU 时间,也就是内存拷贝)。

    5. 聪明办法(环形缓冲区):尾部写满了?直接把“写指针”绕回数组的第 0 个位置!

  2. 如何判断“空”和“满”?

    如果读指针和写指针重合了(Head == Tail),这到底代表数组是被彻底写满了,还是被彻底读空了?这是一个著名的二义性问题。

    有两种主流的解决办法:

    • 办法 1(牺牲一个存储单元):规定队列里永远留一个空位。如果 (Tail + 1) % SIZE == Head,就认为满了。Linux 内核很多地方用这个。

    • 办法 2(引入计数器):加一个变量 count。写数据 count++,读数据 count--。如果 count == SIZE 就是满,count == 0 就是空。FreeRTOS 的队列就是用这种方式(上一节的 uxMessagesWaiting 吗?)。

1.3 队列的发送和接收(链表操作)

1.3.1 读取/接收

大致的流程是这样的,一图胜千言:其中

绿色箭头代表判断为True

红色则为False

1.3.2 发送

发送也是一样的道理,完全可以类推。

1.4 值传递

FreeRTOS 的队列是 “值传递(Copy by Value)”。使用的是 memcpy 函数

2. 信号量

有了队列,我们看信号量就很简单了,因为他们的核心逻辑都是等待/唤醒机制

我们可以把信号量看作是一个大小为 0 字节的“消息”。以二值信号量为例:

现在我们定义一个特殊的队列。长度为1,消息大小为0。给该队列分配一个0字节大小的环形缓冲区(地址为NULL)。

任务A(消费者)调用 QueueReceive 读队列数据。

然后,一个任务B(生产者)在执行完自己的逻辑后调用 QueueSend 向队列写数据。

任务A开始运行。直到再次因 QueueReceive 被阻塞。

我们把 QueueSend 换成 SemaphoreGiveQueueReceive 换成 SemaphoreTake

任务 A 在等数据(信号量),任务 B 发送数据。这和队列“生产者-消费者”的模型在数学逻辑上是等价的。

这就是二值信号量的逻辑。我们充分利用了队列结构体中的 “uxMessagesWaiting”
成员制作二值信号量。

计数信号量呢?就是一个长度为 N 的队列。

3. 互斥量

互斥量和二值信号量,长得很像,但是完全复用队列的逻辑是不能实现互斥量的机制的。
因为它引入了 “所有权”(Ownership) 的概念,解决“优先级翻转”的问题。这个是队列逻辑无法覆盖的,主要体现在以下三点:

  1. 优先级继承机制 (Priority Inheritance)

    这是互斥量最核心的特性,也是它与信号量/队列最大的区别。

    • 逻辑: 当高优先级任务 A 等待低优先级任务 B 持有的互斥量时,内核会动态地将任务 B 的优先级提升到与 A 相同。

    • 队列无法实现: 普通队列或信号量不关心“谁持有它”。队列只知道“现在有没有东西可取”,它没有记录“是谁取走了东西”,因此无法回溯并提升那个任务的优先级。

  2. 递归锁定 (Recursive Locking)

    • 互斥量: 支持“嵌套”获取。同一个任务可以多次 Take 同一个互斥量而不会把自己死锁,只要对应的 Give 次数相同即可。

    • 队列: 如果你对一个长度为 1 的队列连续执行两次 Take,第二次一定会因为队列为空而导致任务自我死锁。

  3. 解锁权限 (Strict Unlocking)

    • 信号量/队列: 任务 A 可以 Take,然后任务 B 来 Give(常用于任务间同步)。

    • 互斥量: 必须由加锁的任务来解锁。如果任务 A 拿到了互斥量,任务 B 尝试去释放它,RTOS 内核通常会报错或操作无效。这种“所有权”约束是队列逻辑不具备的。

以前我也不理解二值信号量和互斥信号量有什么区别,现在有了更深的理解,下面是一个对比表:

特性 信号量 (Semaphore) / 队列 互斥量 (Mutex)
底层模型 资源计数器 / 生产者-消费者 资源锁 / 所有权模型
主要用途 任务间同步(我发你收) 临界资源保护(我用你等)
所有权 无(谁都能发,谁都能收) (谁加锁谁解锁)
优先级继承 (会导致优先级翻转) (解决优先级翻转)
实现复杂度 较低(基础阻塞机制) 较高(需记录持有者、处理优先级动态调整)

至于互斥量的实现,且听下回分解。


共计约2k字。于2026/04/12首次发布,最后更新于2026/04/14。

本文章为博主原创文章。遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。

  1. 1. 队列详解
    1. 1.1 队列结构体
    2. 1.2 环形缓冲区
    3. 1.3 队列的发送和接收(链表操作)
    4. 1.4 值传递
  2. 2. 信号量
  3. 3. 互斥量