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) |
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() 函数里。我简化一下流程:
- 找最高优先级就绪任务:从
pxReadyTasksLists[优先级]链表里取第一个任务。 - 如果同优先级有多个任务:轮转,每个任务跑一个时间片(由
configTICK_RATE_HZ决定)。 - 如果当前任务被抢占:把它放回就绪链表尾部,换新任务执行。
这里有个关键数据结构——就绪链表数组:
static List_t pxReadyTasksLists[ configMAX_PRIORITIES ];
每个优先级对应一个链表。调度器查任务时,从最高优先级往下找,找到第一个非空链表,取表头任务。这个查找过程是O(1)的,非常快。
configMAX_PRIORITIES 不要设太大。我一般设32或64,够用就好。设太大浪费内存,而且查找链表虽然O(1),但遍历位图也有开销。
下面这张图,是我手绘的调度器核心流程,你看完就明白了:
嗯,这里要注意一点:GD32V是RISC-V架构,它的中断返回和任务切换用的是 mret 指令,和ARM的 BX LR 完全不同。我在移植 portRESTORE_CONTEXT 宏时,折腾了整整一个下午,就是因为没注意RISC-V的栈帧里 mepc 和 mstatus 的保存位置。
portable/GCC/RISC-V/portmacro.h 里的 portSTACK_GROWTH 定义。RISC-V的栈是向下增长的,但有些移植版写反了。我曾经因为这个bug,任务一创建就进异常。
最后,我把这一章的知识点串一下。你记住三句话:
- 源码结构:核心在
tasks.c和portable/,其他都是辅助。 - 内存管理:默认用heap_4,别折腾。
- 调度器:优先级抢占 + 时间片轮转,核心是TCB链表和位图查找。