4. GD32V处理器架构

好,咱们直接切入正题。这一章我打算带你把FreeRTOS的源码“大卸八块”,看看里面到底藏了什么门道。很多初学者拿到源码包,第一反应就是懵——文件夹一堆,文件几百个,从哪看起?

我个人习惯,拿到一个RTOS源码,先不看细节,先看骨架。骨架就是它的目录结构和核心文件。搞清楚了这些,你才知道调度器是怎么跑起来的,内存是怎么分配的,任务是怎么被“管”住的。

4.1 核心文件与目录:别被文件夹吓到

你解压FreeRTOS源码后,会发现顶层就两个核心目录:FreeRTOS 和 FreeRTOS-Plus。咱们做移植和调优,99%的时间都在第一个目录里折腾。

我把它拆成三块来讲:

目录/文件 作用 我的经验
Source/include/ 核心头文件,定义数据类型、宏、API声明 这里面 FreeRTOS.h 是总入口,必读
Source/tasks.c 任务创建、删除、调度、延时……调度器核心 这是整个RTOS的心脏,我调bug时最常翻它
Source/list.c 链表操作,TCB就是挂在链表上的 别看它小,调度器的基石就是它
Source/queue.c 队列、信号量、互斥量的底层实现 任务间通信全靠它,性能瓶颈常在这
Source/timers.c 软件定时器 我一般不用,但有些场景确实方便
Source/portable/ 移植层,放编译器、MCU相关的代码 咱们移植GD32V,主要改这里
Source/portable/MemMang/ 内存管理方案,heap_1.c ~ heap_5.c 选错了方案,系统可能跑着跑着就崩了
我的小技巧: 拿到一个新平台,我第一步就是看 portable/ 目录下有没有现成的移植。如果有,直接抄作业;如果没有,就从最接近的架构开始改。GD32V是RISC-V核,你可以参考 portable/GCC/RISC-V/ 下的文件。

4.2 内存管理方案:heap_1 到 heap_5 怎么选?

内存管理,说白了就是RTOS怎么给你分配任务栈、队列空间。FreeRTOS提供了5种方案,放在 portable/MemMang/ 下。你想想看,如果选错了,要么浪费内存,要么系统跑着跑着就崩了。

我直接给你一张对比表,看完你就知道怎么选了:

方案 分配方式 释放 碎片 适用场景
heap_1 简单数组,线性分配 不支持 无 任务和队列在启动时创建,永不删除
heap_2 最佳匹配链表 支持 有 任务大小固定,频繁创建删除
heap_3 包装标准 malloc/free 支持 取决于编译器 你已经在用标准库,想简单点
heap_4 首次匹配 + 合并相邻空闲块 支持 较少 最推荐,通用场景
heap_5 同heap_4,但支持多块不连续内存 支持 较少 内存分布在多个区域(如外部SDRAM + 内部SRAM)
我曾经踩过的坑: 在一个产品里用了heap_2,任务频繁创建删除,跑了三天后系统突然卡死。查了两天才发现是内存碎片导致分配失败。后来换成heap_4,问题解决。所以,没有特殊理由,默认用heap_4。

4.3 任务控制块(TCB)设计:任务的“身份证”

每个任务在RTOS里都有一个“身份证”,就是TCB。FreeRTOS的TCB定义在 tasks.c 里,结构体名字叫 tskTaskControlBlock。我挑几个关键字段给你讲讲:

typedef struct tskTaskControlBlock {
    volatile StackType_t *pxTopOfStack;    // 栈顶指针,切换任务时最关键
    ListItem_t xStateListItem;             // 挂在就绪/阻塞/挂起链表上
    ListItem_t xEventListItem;             // 挂在事件链表上(如队列等待)
    UBaseType_t uxPriority;               // 任务优先级
    StackType_t *pxStack;                  // 栈起始地址
    char pcTaskName[configMAX_TASK_NAME_LEN]; // 任务名,调试用
    // ... 还有一堆,但上面这几个是核心
} tskTCB;

为什么说 pxTopOfStack 最关键?因为任务切换时,CPU的寄存器全部压栈,然后 pxTopOfStack 指向这个栈帧。调度器切换任务,说白了就是换一个TCB,然后从 pxTopOfStack 恢复寄存器。

我在GD32V上移植时,最头疼的就是这个栈帧布局。RISC-V的寄存器压栈顺序和ARM Cortex-M完全不同,你得对着手册一个一个对。

4.4 调度器核心算法:优先级抢占 + 时间片轮转

FreeRTOS的调度算法,说白了就两招:优先级抢占 和 时间片轮转。但你别小看这两招,组合起来能玩出花。

调度器的核心逻辑在 tasks.c 的 vTaskSwitchContext() 函数里。我简化一下流程:

  1. 找最高优先级就绪任务:从 pxReadyTasksLists[优先级] 链表里取第一个任务。
  2. 如果同优先级有多个任务:轮转,每个任务跑一个时间片(由 configTICK_RATE_HZ 决定)。
  3. 如果当前任务被抢占:把它放回就绪链表尾部,换新任务执行。

这里有个关键数据结构——就绪链表数组:

static List_t pxReadyTasksLists[ configMAX_PRIORITIES ];

每个优先级对应一个链表。调度器查任务时,从最高优先级往下找,找到第一个非空链表,取表头任务。这个查找过程是O(1)的,非常快。

性能调优关键点: 在GD32V上,如果任务数量多、优先级多,configMAX_PRIORITIES 不要设太大。我一般设32或64,够用就好。设太大浪费内存,而且查找链表虽然O(1),但遍历位图也有开销。

下面这张图,是我手绘的调度器核心流程,你看完就明白了:

FreeRTOS调度器核心流程 调度器核心流程 vTaskSwitchContext() 查找最高优先级就绪任务 遍历 uxTopReadyPriority 位图 同优先级 有多个任务? 时间片轮转 取链表下一个任务 切换 pxCurrentTCB 恢复任务上下文 (portRESTORE_CONTEXT) 任务开始执行 pxCurrentTCB 指向当前运行任务

嗯,这里要注意一点:GD32V是RISC-V架构,它的中断返回和任务切换用的是 mret 指令,和ARM的 BX LR 完全不同。我在移植 portRESTORE_CONTEXT 宏时,折腾了整整一个下午,就是因为没注意RISC-V的栈帧里 mepc 和 mstatus 的保存位置。

避坑指南: 如果你在GD32V上移植,记得检查 portable/GCC/RISC-V/portmacro.h 里的 portSTACK_GROWTH 定义。RISC-V的栈是向下增长的,但有些移植版写反了。我曾经因为这个bug,任务一创建就进异常。

最后,我把这一章的知识点串一下。你记住三句话:

  • 源码结构:核心在 tasks.c 和 portable/,其他都是辅助。
  • 内存管理:默认用heap_4,别折腾。
  • 调度器:优先级抢占 + 时间片轮转,核心是TCB链表和位图查找。