FreeRTOS 三条核心链路详解:Port 移植、任务调度与内存管理


FreeRTOS 的学习重点并不是记住几个 API,而是理解三条相互关联的底层链路:内核如何适配 CPU、任务如何完成状态切换和抢占、内核对象及业务数据如何分配与回收。本文以 Cortex-M 为例,系统说明 SysTick、PendSV、SVC、任务链表、队列唤醒、heap_4 和固定块内存池。

关键词: FreeRTOS、PendSV、SysTick、SVC、任务切换、上下文切换、Queue、heap_4、内存池


一、FreeRTOS 的三条核心链路

FreeRTOS 中最值得建立完整认识的三条链路是:

  1. Port 移植链路: FreeRTOS 怎样适配不同 CPU;
  2. 任务调度链路: 从系统节拍、任务阻塞与唤醒,到 PendSV 完成上下文切换;
  3. 内存管理链路: 队列对象放在哪里,pvPortMalloc() 如何分配,heap_4 如何合并空闲块,以及固定块内存池怎样设计。

三条链路分别对应硬件适配、运行时调度和资源管理,但实际运行时往往会连成一个完整过程。


二、FreeRTOS Port 移植层

FreeRTOS 内核的大部分代码使用跨平台 C 语言实现,真正与 CPU 架构相关的内容放在 Port 层。

典型目录如下:

FreeRTOS/Source/
├── tasks.c
├── queue.c
├── list.c
├── timers.c
└── portable/
    └── GCC/ARM_CM4F/
        ├── port.c
        └── portmacro.h

Port 层通常需要完成:

  • 产生系统节拍;
  • 实现临界区保护;
  • 初始化任务栈;
  • 保存和恢复任务上下文;
  • 通过中断请求任务切换;
  • 提供内存分配接口;
  • 处理栈方向、数据对齐和 FPU 上下文。

Cortex-M 中三个重要异常

1. SysTick:提供系统节拍

SysTick 周期性触发,典型执行链路为:

SysTick

xPortSysTickHandler()

xTaskIncrementTick()

更新系统 Tick

检查延时任务是否到期

判断是否需要切换任务

必要时挂起 PendSV

例如:

#define configTICK_RATE_HZ 1000

表示每 1 ms 产生一次系统 Tick。

SysTick 的主要作用包括:

  • 更新 xTickCount
  • 唤醒延时到期的任务;
  • 处理同优先级任务的时间片轮转;
  • 判断是否有更高优先级任务进入就绪态;
  • 必要时请求 PendSV。

SysTick 一般不直接完成整个上下文切换,而是请求 PendSV 在合适时机执行。

2. PendSV:执行上下文切换

PendSV 是可挂起的系统异常,非常适合执行任务切换。

它通常被配置为最低中断优先级。这样,即使某个硬件中断请求了调度,PendSV 也会等到更高优先级中断全部退出后再执行。

请求 PendSV 的典型操作是:

SCB->ICSR = SCB_ICSR_PENDSVSET_Msk;

3. SVC:启动第一个任务

调度器刚启动时,还不存在一个普通任务的中断现场可供 PendSV 恢复,因此 Cortex-M Port 通常使用 SVC 启动第一个任务:

vTaskStartScheduler()

xPortStartScheduler()

触发 SVC

vPortSVCHandler()

恢复第一个任务的栈

异常返回进入第一个任务

SVC 主要负责第一次任务启动,之后的普通任务切换由 PendSV 完成。


三、中断优先级配置

Cortex-M 的优先级数值越小,硬件优先级越高。这一点很容易与“数值越大优先级越高”的直觉混淆。

FreeRTOS 常见配置包括:

#define configKERNEL_INTERRUPT_PRIORITY
#define configMAX_SYSCALL_INTERRUPT_PRIORITY

通常要求:

  • PendSV 设置为最低优先级;
  • SysTick 通常也放在内核允许的低优先级范围;
  • 调用 FreeRTOS API 的中断不能高于 configMAX_SYSCALL_INTERRUPT_PRIORITY
  • 高于该阈值的中断不能调用 FreeRTOS API,即使是 FromISR API 也不行。

例如,一个极高优先级的 ADC 中断不能随意调用:

xQueueSendFromISR();
xSemaphoreGiveFromISR();

否则可能在 FreeRTOS 无法屏蔽该中断时破坏内核链表或队列结构。

移植时还应检查:

  • __NVIC_PRIO_BITS
  • 优先级字段是否需要左移;
  • NVIC 优先级分组;
  • CubeMX 与 FreeRTOSConfig.h 的配置是否一致;
  • ISR 实际优先级是否允许调用内核 API。

四、Port 层的内存与临界区接口

FreeRTOS 动态内存接口主要是:

void *pvPortMalloc(size_t size);
void vPortFree(void *ptr);

它们由选定的 heap_x.c 实现。

Port 层还需要定义或提供:

portSTACK_TYPE
portBYTE_ALIGNMENT
portENTER_CRITICAL()
portEXIT_CRITICAL()
portYIELD()
portYIELD_FROM_ISR()

不同 CPU 需要分别处理:

  • 栈增长方向;
  • 栈地址对齐;
  • 需要保存的寄存器;
  • FPU 上下文;
  • 临界区实现;
  • 中断屏蔽方式;
  • CPU 字长与数据对齐。

五、FreeRTOS 调度完整链路

调度过程可以概括为:

事件发生

修改任务状态

高优先级任务进入就绪态

请求 PendSV

保存当前任务上下文

vTaskSwitchContext()

选择最高优先级就绪任务

恢复新任务上下文

异常返回进入新任务

触发调度的事件可能来自:

  • SysTick;
  • 队列发送;
  • 信号量释放;
  • 事件组置位;
  • 任务通知;
  • 任务主动调用 taskYIELD()
  • 中断调用 FromISR API。

六、PendSV 上下文切换过程

Cortex-M 进入异常时,硬件自动把一部分寄存器压入当前任务栈:

R0
R1
R2
R3
R12
LR
PC
xPSR

这部分称为硬件异常栈帧。

PendSV 再由软件保存:

R4 ~ R11

如果使用 FPU,还可能需要保存:

S16 ~ S31

1. 异常入口自动压栈

任务通常使用 PSP,异常处理程序使用 MSP。进入 PendSV 后,硬件已经把基本异常现场压入当前任务的 PSP。

2. 软件保存 R4~R11

PendSV 汇编代码类似:

MRS     R0, PSP
STMDB   R0!, {R4-R11}

保存后的 PSP 被写入当前任务 TCB:

pxCurrentTCB->pxTopOfStack = 当前PSP;

pxTopOfStack 通常是 TCB 的第一个成员,便于汇编代码直接访问。

3. 选择下一个任务

PendSV 调用:

vTaskSwitchContext();

它负责:

  • 查找最高优先级就绪任务;
  • 更新同优先级任务的轮转位置;
  • 更新 pxCurrentTCB

4. 恢复新任务上下文

从新任务 TCB 中取出栈顶:

PSP = pxCurrentTCB->pxTopOfStack;

恢复 R4~R11:

LDMIA   R0!, {R4-R11}
MSR     PSP, R0

最后执行异常返回,硬件自动恢复:

R0~R3、R12、LR、PC、xPSR

CPU 随后从新任务上次停止的位置继续执行。


七、新任务第一次运行的栈帧

调用:

xTaskCreate(task_entry, ...);

时,FreeRTOS 会在新任务栈中预先构造一个伪造的异常现场,例如:

xPSR = 初始状态
PC   = task_entry
LR   = 任务异常退出处理函数
R0   = 任务参数
R4~R11 = 初始值

这样调度器第一次恢复该任务时,就像它之前已经被中断过一样。

异常返回后:

PC = task_entry
R0 = pvParameters

任务由此开始运行。


八、FreeRTOS 的任务链表

FreeRTOS 使用双向链表管理任务状态。

1. 就绪链表

每个优先级都有一条就绪链表:

List_t pxReadyTasksLists[configMAX_PRIORITIES];

概念上类似:

优先级 4:TaskA → TaskB
优先级 3:TaskC
优先级 2:TaskD → TaskE → TaskF
优先级 1:空
优先级 0:Idle

调度器总是选择最高优先级的非空就绪链表。

同优先级存在多个任务时,通过链表游标轮转:

TaskA → TaskB → TaskA → TaskB

这就是同优先级时间片调度。

FreeRTOS 常使用:

vListInsertEnd();
listGET_OWNER_OF_NEXT_ENTRY();

将任务插入就绪链表尾部,并在同优先级任务之间轮换。资料中所说的“摇尾操作”很可能指链表尾插或链表游标轮转。

2. 延时链表

任务调用:

vTaskDelay(100);

后,会从就绪链表移入延时链表。延时链表按照唤醒 Tick 排序:

TaskA:100 Tick
TaskB:120 Tick
TaskC:200 Tick

SysTick 每次调用 xTaskIncrementTick() 时检查链表头部。唤醒时间到达后:

延时链表 → 就绪链表

如果被唤醒任务的优先级高于当前任务,则请求 PendSV。

FreeRTOS 通常使用两条延时链表:

  • 当前延时链表;
  • Tick 溢出延时链表。

这样可以处理 TickType_t 计数器回绕。

3. 事件等待链表

任务等待以下对象时,会进入对应事件链表:

  • 队列;
  • 信号量;
  • 互斥量;
  • 事件组;
  • 任务通知。

例如:

xQueueReceive(queue, &data, portMAX_DELAY);

如果队列为空:

当前任务从就绪链表删除

进入队列的 xTasksWaitingToReceive

如果存在超时,同时进入延时链表

调度其他任务

任务通常同时具有状态链表节点和事件链表节点,这样既能等待事件,也能处理等待超时。


九、队列发送为什么可能立即抢占

假设:

  • TaskLow:优先级 2,当前正在运行;
  • TaskHigh:优先级 5,阻塞等待队列;
  • TaskLow 向队列发送数据。

执行过程为:

TaskLow 调用 xQueueSend()

数据写入队列

发现 TaskHigh 正在等待接收

TaskHigh 从事件链表移除

TaskHigh 加入优先级 5 就绪链表

TaskHigh 优先级高于 TaskLow

请求 PendSV

切换到 TaskHigh

所以 xQueueSend() 不只是复制数据,还可能改变任务状态并触发抢占。

内部逻辑可以概念化为:

if (xTaskRemoveFromEventList(&queue->xTasksWaitingToReceive)) {
    queueYIELD_IF_USING_PREEMPTION();
}

在中断中发送队列

中断中必须使用:

BaseType_t xHigherPriorityTaskWoken = pdFALSE;

xQueueSendFromISR(queue, &data, &xHigherPriorityTaskWoken);

portYIELD_FROM_ISR(xHigherPriorityTaskWoken);

其中:

  • xQueueSendFromISR() 发送数据,并判断是否唤醒高优先级任务;
  • xHigherPriorityTaskWoken 表示是否需要调度;
  • portYIELD_FROM_ISR() 请求 PendSV;
  • 真正的上下文切换在中断退出后完成。

不能在中断中调用普通 xQueueSend(),因为普通 API 可能阻塞或使用不适合中断上下文的调度逻辑。


十、Queue 实例到底放在哪里

1. QueueHandle_t 只是一个指针

代码:

QueueHandle_t queue;

这里的 queue 本身只是句柄,本质上通常类似:

typedef struct QueueDefinition *QueueHandle_t;

句柄变量放在哪里取决于声明位置:

QueueHandle_t queue;        // 全局变量,通常在 .bss

static QueueHandle_t queue; // 通常也在 .bss

void func(void)
{
    QueueHandle_t queue;    // 句柄变量在当前任务栈上
}

但句柄指向的真正队列对象不一定与句柄放在同一个位置。

2. 动态创建队列

例如:

QueueHandle_t queue;

queue = xQueueCreate(10, sizeof(uint32_t));

宏最终会调用类似:

xQueueGenericCreate(10, sizeof(uint32_t), queueQUEUE_TYPE_BASE);

动态创建时通常通过 pvPortMalloc() 分配内存。

典型分配大小为:

sizeof(Queue_t) + 队列长度 × 每项大小

内存布局可以理解为:

Heap 中的一整块内存
┌───────────────────────┐
│ Queue_t 队列控制块     │
├───────────────────────┤
│ Item 0                │
│ Item 1                │
│ Item 2                │
│ ...                   │
└───────────────────────┘

因此动态创建时:

  • QueueHandle_t 是指针;
  • Queue_t 实例位于 FreeRTOS Heap;
  • 队列数据缓冲区通常也位于同一块 Heap 内存;
  • 删除队列时由 vQueueDelete() 释放。

3. Queue_t 中有什么

简化后可以理解为:

typedef struct QueueDefinition {
    int8_t *pcHead;
    int8_t *pcWriteTo;

    union {
        QueuePointers_t xQueue;
        SemaphoreData_t xSemaphore;
    } u;

    List_t xTasksWaitingToSend;
    List_t xTasksWaitingToReceive;

    volatile UBaseType_t uxMessagesWaiting;
    UBaseType_t uxLength;
    UBaseType_t uxItemSize;
} Queue_t;

它包含:

  • 队列缓冲区起始地址;
  • 写指针和读指针;
  • 当前消息数量;
  • 队列容量;
  • 单个元素大小;
  • 等待发送任务链表;
  • 等待接收任务链表。

FreeRTOS 的队列、信号量和互斥量共享了大量底层实现,因此 Queue_t 中还存在信号量相关联合体。

4. 静态创建队列

StaticQueue_t queueControlBlock;
uint8_t queueStorage[10 * sizeof(uint32_t)];

QueueHandle_t queue =
    xQueueCreateStatic(
        10,
        sizeof(uint32_t),
        queueStorage,
        &queueControlBlock
    );

此时:

  • queueControlBlock 由用户提供;
  • queueStorage 由用户提供;
  • 不调用 pvPortMalloc()
  • 存储位置由变量的声明方式决定;
  • 内存大小固定,运行行为更加确定。

在安全性和实时性要求较高的项目中,静态创建更容易进行内存预算。


十一、FreeRTOS 的五种 Heap 实现

实现 特点
heap_1.c 只能分配,不能释放,最简单
heap_2.c 可以释放,但不合并相邻空闲块
heap_3.c 封装标准库 malloc/free
heap_4.c 支持释放,并合并相邻空闲块
heap_5.c 类似 heap_4,但支持多个不连续内存区域

一般嵌入式项目最常讨论的是 heap_4


十二、heap_4 的内存结构与分配过程

heap_4 通常定义一个静态数组:

static uint8_t ucHeap[configTOTAL_HEAP_SIZE];

例如:

#define configTOTAL_HEAP_SIZE (20 * 1024)

表示 FreeRTOS Heap 总大小为 20 KB。这块内存一般位于 RAM 的 .bss 段。

1. 内存块头

每一块内存前面都有管理头:

typedef struct A_BLOCK_LINK {
    struct A_BLOCK_LINK *pxNextFreeBlock;
    size_t xBlockSize;
} BlockLink_t;

可以理解为:

┌────────────────────────┐
│ BlockLink_t             │
│ - 下一空闲块地址         │
│ - 当前块大小             │
├────────────────────────┤
│ 返回给用户的有效空间     │
└────────────────────────┘

pvPortMalloc() 返回的是块头后面的用户区域,而不是块头地址。

2. 分配过程

假设申请:

ptr = pvPortMalloc(100);

大致流程如下:

  1. 加上 BlockLink_t 头部大小;
  2. portBYTE_ALIGNMENT 向上对齐;
  3. 遍历空闲链表;
  4. 找到第一个足够大的空闲块;
  5. 如果剩余空间足够,将大块拆分;
  6. 把已分配块从空闲链表移除;
  7. 设置已分配标志;
  8. 返回块头后面的地址。

概念上:

分配前:

空闲块 256 字节
┌─────────────────────────────┐
│           Free 256          │
└─────────────────────────────┘

申请 100 字节后:

┌──────────────┬──────────────┐
│ 已分配块      │ 剩余空闲块    │
│ 100 + 块头    │              │
└──────────────┴──────────────┘

heap_4 使用按地址排序的空闲链表,通常采用首次适配方式寻找可用块。


十三、heap_4 的空闲块合并与碎片问题

释放内存:

vPortFree(ptr);

时,heap_4 会:

  1. 根据用户地址找到前面的 BlockLink_t
  2. 清除已分配标志;
  3. 把该块插回按地址排序的空闲链表;
  4. 检查前一个空闲块是否与它地址连续;
  5. 检查后一个空闲块是否与它地址连续;
  6. 如果连续,则合并。

例如:

释放前:

[空闲 100][已用 80][空闲 120]

释放中间 80 后:

[空闲 100][空闲 80][空闲 120]

合并后:

[空闲 300]

合并判断本质上类似:

if ((uint8_t *)previous + previous->xBlockSize ==
    (uint8_t *)current) {
    合并 previous 和 current;
}

然后再检查当前块与后一个块是否连续。

heap_4 能否彻底解决碎片

不能。

heap_4 可以合并地址相邻的空闲块,但无法把分散在已使用块之间的空闲区域搬到一起。

例如:

[空闲 100][使用 50][空闲 100][使用 50][空闲 100]

总空闲空间为 300 字节,但没有任何一个连续的 200 字节块。此时:

pvPortMalloc(200);

仍然可能失败。

常用监控接口包括:

xPortGetFreeHeapSize();
xPortGetMinimumEverFreeHeapSize();

第一个表示当前剩余总空间,第二个表示历史最低剩余空间。但只看总空间仍不能判断最大连续空闲块有多大。


十四、为什么不能在 ISR 中随意动态分配

pvPortMalloc()vPortFree() 通常不是为中断上下文设计的。

它们可能:

  • 遍历空闲链表;
  • 暂停调度器;
  • 修改共享内存管理结构;
  • 执行时间不固定;
  • 产生较长临界区。

因此一般不要在 ISR 中调用:

pvPortMalloc();
vPortFree();
xQueueCreate();
vQueueDelete();

中断中更适合:

  • 使用预先分配的对象;
  • 使用静态队列;
  • 使用固定块内存池;
  • 把分配请求发送给任务,由任务完成分配。

十五、固定块内存池的基本原理

固定块内存池会把一整块内存提前切成相同大小的块:

Memory Pool
┌──────────┐
│ Block 0  │
├──────────┤
│ Block 1  │
├──────────┤
│ Block 2  │
├──────────┤
│ Block 3  │
└──────────┘

空闲块通过单链表连接:

freeHead

Block0 → Block2 → Block3 → NULL

分配时取出链表头:

void *pool_alloc(void)
{
    Block *block = freeHead;

    if (block != NULL) {
        freeHead = block->next;
    }

    return block;
}

释放时插回空闲链表:

void pool_free(void *ptr)
{
    Block *block = ptr;

    block->next = freeHead;
    freeHead = block;
}

优点

  • 分配时间接近 O(1);
  • 释放时间接近 O(1);
  • 没有外部碎片;
  • 行为确定;
  • 适合实时系统;
  • 容易统计最大使用量。

缺点

  • 块大小固定;
  • 小对象使用大块会产生内部浪费;
  • 不同大小对象可能需要多个内存池;
  • 必须处理重复释放和越界;
  • 池耗尽后不能自动扩展。

十六、设计内存池必须考虑的问题

1. 块大小和对齐

块大小至少满足:

块大小 ≥ 最大对象大小

并按照 CPU 或 DMA 要求对齐:

alignas(32) uint8_t pool[...];

如果用于 DMA,还要考虑 Cache Line 对齐。

2. 并发保护

多个任务访问内存池时,需要使用:

  • 临界区;
  • 互斥量;
  • 自旋锁;
  • 原子操作。

如果 ISR 和任务共同访问,必须使用 ISR 安全的同步设计,不能在 ISR 中使用可能阻塞的互斥量。

3. 所有权

需要明确:

  • 谁分配;
  • 谁释放;
  • 是否允许所有权转移;
  • 队列中传递的是数据副本还是指针;
  • 接收方是否负责归还内存块。

4. 防止重复释放

重复释放同一块可能形成循环链表:

BlockA → BlockA → BlockA

导致后续多次分配返回同一个地址。

调试版本可以增加:

  • 魔数;
  • 已分配标志;
  • 边界保护;
  • 使用计数;
  • 最大占用量;
  • 越界检测。

5. 池耗尽策略

内存池耗尽时,需要提前确定:

  • 返回 NULL
  • 阻塞等待;
  • 丢弃数据;
  • 覆盖最旧数据;
  • 触发断言;
  • 记录故障并复位。

实时系统不能等到运行时才考虑耗尽策略。


十七、把三条链路串起来

一个中断通过队列唤醒高优先级任务的完整过程如下:

硬件中断产生数据

ISR 调用 xQueueSendFromISR()

数据写入 Queue 缓冲区

高优先级接收任务解除阻塞

任务进入就绪链表

xHigherPriorityTaskWoken = pdTRUE

portYIELD_FROM_ISR() 请求 PendSV

中断退出

PendSV 保存当前任务上下文

vTaskSwitchContext() 选择高优先级任务

恢复新任务上下文

接收任务处理队列数据

如果队列中传递的是动态内存指针,还会继续涉及:

内存池或 pvPortMalloc()

发送指针到队列

接收任务取得所有权

使用完毕

归还内存池或调用 vPortFree()

三条核心链路分别回答:

链路 核心问题
Port 移植 CPU 怎样产生节拍、保护临界区和切换上下文
调度链路 任务怎样从阻塞态进入就绪态并抢占运行
内存链路 内核对象和数据缓冲区放在哪里,怎样分配和回收

最关键的理解是:

SysTick 负责时间推进,队列和事件负责改变任务状态,PendSV 负责真正切换上下文;QueueHandle_t 只是指针,动态队列实例位于 FreeRTOS Heap;heap_4 通过按地址排列的空闲链表合并相邻空闲块,而固定块内存池通过预切分空间换取确定的分配时间和更可控的实时性。