第3章 FreeRTOS源码结构:核心文件、内存管理、任务调度、队列、信号量等组件概览

好,咱们这一章来聊聊FreeRTOS的源码结构。说实话,我第一次打开FreeRTOS源码包的时候,也被那一堆文件夹和文件搞得有点懵。但别怕,这东西其实没那么复杂。我个人的习惯是,先看骨架,再看血肉。

FreeRTOS的源码,说白了就是一套用C语言写成的实时内核。它麻雀虽小,五脏俱全。你想想看,一个能在几十KB的MCU上跑起来的系统,它的设计一定是精炼到极致的。

3.1 核心文件:内核的骨架

咱们先看最核心的两个文件:tasks.clist.c

  • tasks.c:这是整个系统的灵魂。任务创建、删除、挂起、恢复,还有任务调度器本身,全在这里。我刚开始移植的时候,第一个要搞定的就是这个文件里的配置宏。
  • list.c:别小看这个文件。FreeRTOS里所有的任务队列、事件等待、延时列表,底层都是靠这个双向链表实现的。说白了,它就是内核的数据结构基石。

除了这两个,还有 queue.ctimers.cevent_groups.c 这些。它们各自负责一块功能。嗯,这里要注意,timers.c 里的软件定时器,其实底层也是靠任务和队列来实现的,并不是硬件定时器。

核心文件清单(我常用的):

  • tasks.c — 任务管理
  • list.c — 链表操作
  • queue.c — 队列与信号量
  • timers.c — 软件定时器
  • event_groups.c — 事件组
  • croutine.c — 协程(老版本用得多,现在基本不用了)

3.2 内存管理:heap_x.c 的选择题

内存管理这块,FreeRTOS提供了5种方案,放在 portable/MemMang/ 目录下。分别是 heap_1.cheap_5.c

我个人在项目里用得最多的是 heap_4.c。为什么?因为它支持释放内存,而且不会产生碎片——至少比 heap_2 好得多。我曾经在一个需要长期运行的血糖仪项目里用了 heap_2,结果跑了三天,系统因为内存碎片挂掉了。从那以后,我再也不敢在长期项目里用 heap_2 了。

方案 特点 适用场景
heap_1 只能分配,不能释放 任务、队列等静态创建,永不删除
heap_2 可分配可释放,但会产生碎片 临时分配,且分配大小固定
heap_3 直接调用标准库 malloc/free 需要线程安全,且编译器支持
heap_4 合并相邻空闲块,减少碎片 大多数通用场景,我强烈推荐
heap_5 支持多个不连续的内存区域 外部RAM + 内部RAM混合使用

我的小技巧: 在项目初期,先用 heap_4 跑起来。如果发现内存不够,再考虑换成 heap_5 或者自己实现内存管理。别一开始就追求完美,先让系统动起来再说。

3.3 任务调度:谁先跑,谁后跑

任务调度是RTOS的心脏。FreeRTOS默认是抢占式调度,优先级高的任务先跑。同优先级的任务,就轮着来——这叫时间片轮转。

调度器的核心逻辑在 tasks.c 里的 vTaskSwitchContext() 函数。每次系统节拍中断(SysTick)到来时,这个函数就会被调用,决定下一个要运行的任务是谁。

你想想看,如果两个任务优先级相同,而且都没有阻塞,那它们就会轮流占用CPU。每个任务跑一个时间片,然后切换。这个时间片长度由 configTICK_RATE_HZ 决定。比如设置成1000,那每个时间片就是1ms。

避坑指南: 我曾经在一个项目里把 configTICK_RATE_HZ 设成了10000,也就是0.1ms一个节拍。结果系统负载直接飙到80%以上,因为频繁的上下文切换消耗了大量CPU。后来我改成1000,一切正常。所以,别盲目追求高节拍率,够用就行。

3.4 队列:任务间通信的管道

队列是FreeRTOS里最常用的IPC(进程间通信)机制。它的实现文件是 queue.c

队列本质上是一个环形缓冲区,加上阻塞机制。发送方可以往队列里放数据,接收方可以从队列里取数据。如果队列满了,发送方可以选择等待;如果队列空了,接收方也可以选择等待。

我举个例子。在血糖仪里,ADC采样任务把血糖值通过队列发给显示任务。采样任务只管往队列里塞数据,显示任务只管从队列里取数据。两者互不干扰,完美解耦。

// 创建一个能存放10个uint16_t数据的队列
QueueHandle_t xGlucoseQueue;
xGlucoseQueue = xQueueCreate(10, sizeof(uint16_t));

// 发送任务
uint16_t glucoseValue = read_adc();
xQueueSend(xGlucoseQueue, &glucoseValue, portMAX_DELAY);

// 接收任务
uint16_t receivedValue;
xQueueReceive(xGlucoseQueue, &receivedValue, portMAX_DELAY);

注意: 队列拷贝的是数据本身,而不是指针。如果你的数据很大(比如一个结构体),建议用队列传递指针,或者改用消息缓冲区(Message Buffer)。

3.5 信号量:资源管理的利器

信号量本质上是一个特殊的队列。二值信号量就是长度为1的队列,计数信号量就是长度大于1的队列。

二值信号量常用于同步。比如,一个任务等待外部中断发生,中断里释放信号量,任务获取到信号量后开始处理。这比轮询标志位优雅多了。

计数信号量则用于资源管理。比如,系统有5个DMA通道,可以用计数信号量来管理。任务需要DMA时,先获取信号量,用完再释放。信号量的计数值就代表当前可用的DMA通道数。

我记得有一次,一个同事在项目里用全局变量做同步,结果出现了竞态条件。我帮他改成二值信号量后,问题立刻解决。说白了,RTOS提供的这些机制,就是帮你避开裸机编程的那些坑。

3.6 其他组件:事件组与任务通知

除了队列和信号量,FreeRTOS还提供了事件组(Event Groups)和任务通知(Task Notifications)。

  • 事件组:适合一个任务等待多个条件的场景。比如,血糖仪需要等待“按键按下”和“测量完成”两个事件同时发生,才进行下一步操作。用事件组,一个API就能搞定。
  • 任务通知:这是FreeRTOS v8.2.0之后引入的轻量级IPC。每个任务都有一个32位的通知值,可以直接用 xTaskNotifyGive()ulTaskNotifyTake() 来操作。它比信号量更快,因为不需要创建额外的内核对象。

我的建议: 在资源紧张的MCU上,优先使用任务通知。它几乎不占额外内存,而且速度极快。我最近的一个项目里,所有任务间的简单同步都改成了任务通知,系统响应速度明显提升。

好了,这一章咱们把FreeRTOS的源码结构过了一遍。核心文件、内存管理、任务调度、队列、信号量,还有事件组和任务通知。这些东西,说白了就是RTOS的七种武器。你用得越熟,写出来的代码就越稳。

下一章,咱们要动手移植了。我会带着你一步步把FreeRTOS跑在你的开发板上。到时候,这些理论知识都会变成实实在在的代码。