本节摘要:把 FreeRTOS 源码包拆开看,核心只有六个 C 文件加一个移植层目录;把 FreeRTOSConfig.h 读懂,系统九成的运行行为就已在编译前定型。本节先解源码分层,再逐组讲解高频配置宏的含义与取值依据,最后从零搭一个双任务的入门工程并跑通调度。读完你手上会有一份可运行的基线工程,后面七章的每个实验都能在它上面做。
上一节说内核"小到可以完整读懂"——本节就来兑现这句话:先看代码怎么分层,再看配置文件怎么决定系统行为,最后亲手把第一个工程跑起来。它是全册的动手起点,第 2 章讲任务时将直接复用这里建好的工程。
下载内核源码解压后,值得关心的目录只有三块。
内核源码目录:存放着真正的内核。核心文件是任务管理与调度、队列、软件定时器、事件组、流缓冲、链表这六个 C 文件——注意一个容易惊讶的事实:信号量与互斥量没有独立源文件,它们是用队列结构实现的(第 4 章会解释为什么这是个聪明的设计)。另有一个协程文件属于历史遗留,现代工程直接忽略。每个 C 文件配套一个同名头文件放在接口目录里,全部 API 声明集中在单一总头文件中,你的应用只需包含它一个。
移植层目录:按"编译器加芯片架构"两级组织,例如 GNU 编译器下的 Cortex-M3、IAR 下的 Cortex-M4F 各占一个子目录。每个子目录里三个文件最重要:移植宏定义头文件(声明栈生长方向、滴答字长、切换接口怎么映射到指令)、移植实现文件(上下文切换的汇编与收尾)、以及可选的汇编文件。旁边的内存管理目录里躺着五种堆方案源文件,第 5 章会逐一解剖。移植层是内核与真实芯片之间的全部胶水——也就几百行代码,第 3 章的移植复盘会带着你写一遍。
演示工程目录:每个官方支持的芯片带一个最小例程,新手上路时找到自己芯片的演示工程直接改造,比从空目录开始快得多。

分层有一条铁律:依赖只允许自上而下。应用层永远不直接碰移植层的符号,内核核不包含任何具体芯片的头文件。你以后判断"这段代码算不算内核的一部分",看它是否向上依赖即可。
内核被设计成"一个二进制服务千万种产品",产品差异全部浓缩在一份配置头文件里。下面按功能组过一遍高频宏,每组给出典型值与取值依据。
调度行为组。抢占开关(默认建议开)决定高优先级任务就绪时是否立刻剥夺处理器;时间片开关决定同优先级任务轮流执行的粒度;滴答频率决定时间分辨率与调度开销的平衡——1 千赫兹是 Cortex-M 上的常见选择,意味着 1 毫秒的时间粒度与每秒一千次调度检查,降到 100 赫兹可减轻小核负担,但延时精度同步变粗。最大优先级数直接决定就绪表的数组长度,设到实际用到的最高优先级加一即可,常见 5 到 16。
内存组。最小栈尺寸(以字为单位,注意不是字节)是空闲任务等内核任务的栈基准;堆总大小只在采用内核自带堆方案时生效;静态分配支持开关决定是否编入"无堆创建"那一族接口。任务名最大长度按调试需求设,通常 16 够用。
功能裁剪组。互斥量、计数信号量、递归互斥量、任务通知、软件定时器、队列集、低功耗无滴答模式——每个特性一个开关,不用就关,代码与内存都不背。还包括一整族 INCLUDE 开关,控制单个 API(如任务删除、任务挂起)是否编入链接,极致裁剪时靠它把没用到的函数从镜像里挤出去。
诊断组。栈溢出检测(两级,开销与灵敏度不同,第 2 章实测)、内存分配失败钩子、断言宏、跟踪设施与运行时统计——这些开关在开发期全开、发布期按需收缩,是第 7 章观测体系的地基。
| 宏(意译) | 典型值 | 取值依据 |
|---|---|---|
| 抢占调度 | 开 | 实时性要求高必开;纯协作式仅限极简单系统 |
| 滴答频率 | 1000 赫兹 | 精度与开销平衡;低功耗产品可降到 100 甚至更低 |
| 最大优先级数 | 5~16 | 够用即可,每级多一条就绪链表 |
| 最小栈尺寸 | 128 字 | 参考移植层文档,空闲任务实际用量实测后收紧 |
| 堆总大小 | 8~32 千字节 | 按任务数乘栈深估算,预留 20% 余量 |
| 栈溢出检测 | 1 或 2 | 开发期必开 2,量产可降 1 或关闭省开销 |
| 静态分配支持 | 开 | 认证类项目必开;学习期可先关走动态路线 |
配置文件还有一条隐规则值得点名:它必须被内核源文件看到。集成到既有工程时,把配置文件所在目录加入头文件搜索路径是第一件事,漏掉的典型症状是编译器报"缺配置定义"的错误。
⚠️ 最经典的三个新手坑:最大优先级数设得比实际用到的低,创建高优先级任务会静默失败(返回错误码而不是崩溃);堆总大小忘了任务栈也从这里出,任务一多就触发分配失败钩子;16 位滴答开关按默认留给 8 位机,32 位平台上务必关闭,否则长延时会提前到期。三个坑的共同点:症状都不在出错的那一行。
工具链任选——GCC 交叉工具链加 CMake 或 Make、IAR、Keil 都行;没有开发板也可以先用官方提供的 Windows 或 POSIX 模拟端口,在桌面环境直接跑内核,学习阶段完全够用。工程里只需要做三件事:加入内核六个源文件、加入你选定的移植层目录与堆方案文件、提供上面那份配置头文件。
先看最小可运行的主程序骨架:
#include "FreeRTOS.h" #include "task.h" /* 任务一:点亮与熄灭 LED,周期 500 毫秒 */ static void vTaskBlink(void *pvParameters) { const TickType_t xPeriod = pdMS_TO_TICKS(500); TickType_t xLastWakeTime = xTaskGetTickCount(); for (;;) { /* 任务函数永远是无限循环 */ led_toggle(); vTaskDelayUntil(&xLastWakeTime, xPeriod); /* 精确周期延时 */ } } /* 任务二:每秒打印一次心跳计数 */ static void vTaskHeartbeat(void *pvParameters) { uint32_t counter = 0; for (;;) { printf("alive %u\r\n", (unsigned)counter++); vTaskDelay(pdMS_TO_TICKS(1000)); /* 相对延时,简单场景够用 */ } } int main(void) { hardware_init(); /* 时钟、串口、LED 初始化 */ /* 参数依次:任务函数、任务名、栈深(字)、参数、优先级、句柄 */ if (xTaskCreate(vTaskBlink, "blink", 128, NULL, 2, NULL) != pdPASS) { error_halt("create blink failed"); } if (xTaskCreate(vTaskHeartbeat, "beat", 256, NULL, 1, NULL) != pdPASS) { error_halt("create beat failed"); } vTaskStartScheduler(); /* 启动调度器,正常情况下不再返回 */ for (;;) { } /* 走到这里说明内核启动失败 */ }
五个观察点比代码本身更重要。第一,任务函数签名固定:接收一个空指针参数、永不返回——返回了就等于任务"掉出世界",内核会捕获并停机(第 2 章讲任务生命周期时细说)。第二,两个任务用了两种延时:精确周期版把"上次唤醒时刻"作为基准,误差不累积;相对版简单但每次误差都会滚存,周期严格的地方不要用。第三,栈深单位是字不是字节,在 32 位平台上 128 字即 512 字节。第四,创建任务的返回值必须检查——上面那三个配置坑的第一个就从这里暴露。第五,调度器启动后主函数的余生只是个死循环摆设,真正的执行权在任务手里。
预期运行结果:LED 以约 1 秒为周期明暗交替(500 毫秒亮 500 毫秒暗),串口每秒打印一行递增的心跳。把心跳任务的优先级改成 3(高于点灯),现象不变——两个任务都大部分时间在睡,不存在争抢;这个"改优先级试试"的实验建议亲手做一次,它是理解"阻塞让路"的最直觉入口。
给后续章节准备的不只是能跑,还要能观测。在基线工程里再补三件套:打开栈溢出检测与分配失败钩子并让钩子打印出错任务名;把断言宏接到"打印加停机";串口打印走一个独立低优先级任务而不是直接在业务任务里格式化。这套桩位在第 2 章抓栈溢出、第 7 章做统计时直接复用,成本只有几十行。
配置取值的复盘也定个规矩:最小栈与堆大小这类数值,工程启动时按保守值起步,第一次集成测试后用水位测量工具收紧(第 2 章给出工具用法),并在代码评审里把"魔法数字"背后的测量依据写成注释。带着测量依据的配置文件,才敢交给下一个项目复用。