本文整理自个人 FreeRTOS 学习笔记,面向已经会写裸机程序、想平滑过渡到 RTOS 多任务开发的同学。从"为什么要用 RTOS"讲起,覆盖三种任务调度、任务四态、队列通信(含结构体指针传递与内存安全),并给出一个"串口指令 + 按键 + LED"的多对一通信完整工程。基于 STM32 + CubeMX + HAL 库 + CMSIS-RTOS v2。

一、为什么要用 RTOS:裸机 vs RTOS
1.1 裸机程序
裸机 = 不搭载任何操作系统的固件,由永不停歇的 while(1) 无限循环 + 中断服务函数(ISR)构成骨架,直接操作硬件寄存器。软硬件高度耦合,任务调度、内存管理、时间分配全部由程序员提前写成"死代码"。

核心逻辑:顺序执行(轮询) + 事件打断(中断);
标志位:只有 0/1 两种状态的二进制开关;为节省空间常把多个标志位塞进同一个整数,每一位代表一个独立开关,叫位掩码;
中断内不能写长代码或延时,所以 ISR 里只置标志位,跳出后由主循环检测执行。
裸机的痛点:

延时 = 空等待浪费 CPU;
用状态机可改善,但代码冗杂,且裸机没有独立堆栈空间——延时期间去执行其他函数,当前函数的局部变量会被释放、已有结果会丢失;
中断响应以"一次大循环"为最小周期,实时性差。
1.2 RTOS
RTOS(Real-Time Operating System)是处理实时事件的轻量级操作系统内核,引入"任务(Task)"概念,由任务调度器(Scheduler)决定 CPU 这个"打工人"在多个任务之间如何切换,解决的是并发与实时性问题。

核心逻辑:并发运行(分时/抢占) + 主动让权(vTaskDelay/等信号量时交出 CPU)与强制剥夺(高优先级就绪时抢占);
RTOS 把"硬件时间"转化为"任务时间片"和"事件驱动",程序员只需设置优先级;
时间片:内核允许一个任务连续占用 CPU 而不被剥夺的最大连续运行时长;同优先级任务以时间片为最小单位依次循环,宏观上"同时运行"。
1.3 裸机与 RTOS 对比
裸机 RTOS
结构 前后台系统(前台 ISR,后台大循环) 分而治之的多任务
延时 空等浪费 CPU 延时即调度(阻塞不占 CPU)
实时性 一次大循环为最小响应周期 高优先级任务瞬间响应
栈 无独立栈 每个任务独立任务栈
二、FreeRTOS 简介
FreeRTOS 是免费开源的嵌入式实时操作系统:

免费开源:商用无风险;
可裁剪:核心代码 9000+ 行,仅在 3 个 .c 文件中;
简单易用、可移植性好;
优先级分配无限制(多任务可同一优先级)、可创建任务数无软件限制;
支持抢占式 / 协程式 / 时间片三种调度。
三、CMSIS 与构建工具链
3.1 CMSIS 是什么
CMSIS 是 ARM 为 Cortex-M 系列制定的软件接口标准——不是软件或工具,而是一套规范与代码库,定义处理器内核与外设的通用访问方式。

实现原理:以 C 语言头文件为基础,靠条件编译 + 宏定义工作——按 __CC_ARM/GNUC/ICCARM 选择不同编译器的底层实现;按 USE_FREERTOS/USE_RTX 把 osThreadNew 映射到 xTaskCreate/rtxThreadNew。
核心目标:软件可移植性——不同 MCU 厂商的 Cortex-M 芯片通过统一 API 访问内核、调试、RTOS 接口。
组成:CMSIS-Core(NVIC/SysTick)、CMSIS-DSP、CMSIS-RTOS(RTOS 标准 API,FreeRTOS 封装成此标准)、CMSIS-Driver、CMSIS-SVD。
3.2 构建系统与工具链
工具链把源码按顺序转成机器码,共五阶段:

预处理:头文件递归插入、宏展开、#ifdef 裁剪、去注释 → .i;
编译:词法/语法/语义分析、优化、目标代码生成 → .s 汇编文件;
汇编:助记符翻译成二进制机器码 → .o 目标文件(含符号表、重定位表);
链接:读链接脚本做地址分配、符号解析、重定位 → .elf;
格式转换:ELF 转 HEX(带地址校验,供烧录)或 BIN(纯二进制,常用于 OTA 升级)。
为什么需要构建系统:① 编译器/链接器/文件格式有平台差异;② 链接器从左到右只扫描一遍、绝不回头,先出现的未定义函数会被忽略,构建系统须把 .o 排成"不会被忽略"的顺序;③ 库依赖、芯片特定配置要显式化;④ 构建意图可重复化——把"怎么编译"写进 Makefile/CMakeLists,任何人任何环境都能可复现地生成相同固件(工程化核心:过程可复现、结果可信任)。

四、第一个 FreeRTOS 任务
4.1 CubeMX 一条龙移植
STM32CubeMX → Pinout&Configuration → Middleware and Software Packs → 选择 FreeRTOS;
注意事项:SysTick 会被 FreeRTOS 占用,需把 HAL 库的系统时钟源改到某个不常用的定时器(SysTick 是每个 Cortex-M 芯片都有的计数器,RTOS 用它做时钟基准最方便);
调试配置:FreeRTOS → Config parameters → 开启 RECORD_STACK_HIGH_ADDRESS(记录栈地址)和 GENERATE_RUN_TIME_STATS(生成运行时统计),供 CLion 集成调试。
4.2 osThreadNew 创建任务
c osThreadId_t osThreadNew(osThreadFunc_t func, void *argument, const osThreadAttr_t *attr);

func:任务入口函数(主体基本是死循环 for(;😉);
argument:传给入口函数的参数指针,无需传数据填 NULL;
attr:线程属性(名称/优先级/栈大小),NULL 用系统默认;
返回值:新线程唯一 ID;创建失败(如内存不足)返回 NULL;
注意事项:退出线程用 osThreadTerminate/osThreadExit,不要直接 return;严禁在中断中调用;必须合理设置 stack_size(不足会栈溢出,按局部变量大小与调用深度评估)。
五、任务切换的原理:PC 寄存器
程序运行的本质 = CPU 对内存、外设寄存器的读写与计算,且 CPU 不能直接对内存运算,须先取数到寄存器、算完写回。

PC 寄存器(R15)存 CPU 下一条要执行指令的 Flash 地址,执行完自动指向下一条;
任务切换 = 改寄存器(含 PC):时间片结束 → 把当前任务各寄存器的值(含 PC)保存到该任务创建时分配的任务栈 → 令 PC 指向另一个任务的开头 → 新时间片正常运行 → 再切换时重复保存 → 对已完成的任务"出栈"恢复寄存器,如此循环。
六、任务状态与流转
FreeRTOS 有四种任务状态:

状态 含义 工程中显示
运行态 正占用 CPU(STM32 同一时间仅一个任务) Running
就绪态 可执行但未被调度,等待分配时间片 Ready
阻塞态 延时或等待外部事件/数据,用 osDelay 进入且不占 CPU Delayed
挂起态 类似暂停,vTaskSuspend() 进入、vTaskResume() 解挂 Suspended
转换规则:只有就绪态能转运行态,其他状态想运行必须先转就绪态;
状态列表:就绪列表 pxReadyTasksLists[x](x = 优先级)、阻塞列表 pxDelayedTaskList、挂起列表 xSuspendedTaskList;调度器总在就绪列表选最高优先级任务;
Blocked 的区分:FreeRTOS 把"无超时时间的阻塞态"与挂起态放入同一列表;CLion 中挂起态也显示为 Blocked,通过"是否有等待的事件"区分——有则是阻塞,无则是挂起。
七、优先级与抢占式调度
优先级数字越大越高,范围 0 ~ (configMAX_PRIORITIES-1);
不同优先级任务在不同队列,高优先级队列整体在前;只有同优先级同队列按时间片轮转;
任务饥饿:若某任务从不进入挂起/阻塞态,低优先级任务永远无法执行,CPU 被独占"类似裸机"。所以除最低优先级外,所有任务必须能通过 osDelay 或等待数据让出 CPU;
抢占机制:高优先级任务恢复就绪时,即使当前任务时间片未结束,也直接把它"赶下"运行态。
金句:实时 = 确定性。 嵌入式程序最重要的就是确定性——关键事件发生时高优先级任务必须能立刻接管 CPU;延迟有上限、可计算、能被验证,工程师才能做好时序预算、留出安全余量。

八、队列:任务间安全通信
8.1 为什么需要队列
任务间直接共享变量,会出现两类问题:

事件丢失(发生次数 > 处理次数):事件被覆盖、处理速度跟不上产生速度(队列溢出)、数据被后来数据覆盖、竞态条件(寄存器已改而共享变量未改时发生切换——FreeRTOS 上下文保存只保存通用寄存器/SP/PC,不保护共享变量)、事件根本没被记录;
事件重复(一次发生多次处理):标志位没清除、同一事件发多次、硬件噪声误判(按键抖动)、通信重发机制、重复注册。
8.2 队列为什么能解决
队列是 FIFO(先进先出)数据结构:

生产者产生多少条、消费者只能取出多少条 → 不会重复;
队列有长度可缓存 → 少丢失;
队列内部做好保护(一个操作完整执行后另一个操作才能开始)→ 解决竞态;
消费者阻塞唤醒:队列空时消费者直接进入阻塞态、不占 CPU;生产者塞入新数据时 FreeRTOS 自动把消费者唤醒到就绪态——这就是"事件驱动",不再像全局变量方案那样一遍遍轮询。
注意:队列长度有限也会溢出,需要根据生产/消费速率取舍。

8.3 队列 API


// 接收 osStatus_t osMessageQueueGet(osMessageQueueId_t mq_id, void msg_ptr, uint8_t msg_prio, uint32_t timeout); // msg_ptr:接收缓冲区(大小必须 ≥ 创建时的单条消息最大字节数);msg_prio:FreeRTOS 中传 NULL ```

8.4 队列传递复杂数据:结构体指针与内存安全
队列底层会把指针所指的整个数据块完整复制一份存入内部存储区。结构体一般较大,通常传结构体指针("零拷贝"),队列单元存的是"指针的指针"&message),保证接收方能修改结构体内部成员。

但传指针必须理解所有权 / 生命周期 / 野指针 / 内存践踏:

所有权:谁负责释放内存;生命周期:指针指向的内存保持有效的时间段;
野指针(悬垂指针):指向已释放或错误地址;内存践踏:野指针读写已释放重分配或他人的地址;
链条:所有权决定生命周期终点 → 生命周期错配产生野指针 → 野指针使用引发内存践踏。
两个反例:

结构体定义在任务内:时间片切换后其内存被释放 → 接收方拿到野指针;
结构体定义为全局变量:每次传同一地址,排队未消费的数据可能被下次循环修改({0,1,2} 变成 {2,2,2})。
两条规范:

静态分配或全局内存:确保被指向的数据在接收方处理完前绝不被销毁/覆盖(全局数组、静态变量、内存池);
动态分配 + 严格释放:必须明确谁释放。常见模式:发送方 malloc → 填数据 → 指针入队 → 接收方 Get → 处理 → 接收方 free。收方忘 free = 内存泄漏;发方立即 free = 接收方访问野指针。
malloc vs pvPortMalloc:malloc 申请自程序默认堆空间,用 free 释放;pvPortMalloc 申请自 FreeRTOS 管理的专用堆空间,用 vPortFree 释放。

8.5 中断里发队列:timeout 必须为 0
中断不能阻塞,须尽快处理尽快结束;中断是硬件层面、不是 FreeRTOS 任务,无法进入任务阻塞态等待。osMessageQueuePut 内部已判断调用环境:非中断用普通发送函数,中断用专门的 ISR 发送函数(无等待参数,若外部等待时间非 0 直接报错)。所以中断里 timeout 必须为 0。

九、实战:串口指令 + 按键 + LED 多对一通信工程
9.1 串口指令解析任务(CommandTask.c)
帧格式:帧头 0xAA + 长度 + 数据 + 校验和。用队列作串口数据缓冲区,osMessageQueueGet 无限超时 → 只有收到数据才工作,否则阻塞。

```c

include
include "usart.h"
include "Types/LEDType.h"
void StartCommandTask(void argument) { UART2_Receive_Start(); uint8_t receive; uint8_t command[30]; uint8_t commandIndex = 0; // 命令索引 uint8_t commandLength = 0; // 命令长度 for (;;) { osMessageQueueGet(CommandQueueHandle, &receive, 0, osWaitForever); if (commandIndex == 0) // 索引为0,检测帧头 { if (receive == 0xAA) command[commandIndex++] = receive; } else if (commandIndex == 1) // 索引为1,比对数据长度 { if (receive < 4 || receive > sizeof(command)) { commandIndex = 0; continue; } commandLength = receive; command[commandIndex++] = receive; } else { command[commandIndex++] = receive; if (commandIndex == commandLength) { uint8_t checksum = 0; for (uint8_t i = 0; i < commandLength - 1; i++) checksum += command[i]; if (checksum == command[commandLength - 1]) // 校验和比对 { for (uint8_t i = 2; i < commandLength - 2; i += 2) { LEDMessage message = malloc(sizeof(LEDMessage)); message->ledColor = command[i] - 1; message->ledState = command[i + 1]; osMessageQueuePut(LEDQueueHandle, &message, 0, osWaitForever); } } commandIndex = 0; commandLength = 0; } } osDelay(10); } } ```

9.2 LED 任务(消费者,收指针 + free)
c void StartLEDTask(void *argument) { for (;;) { LEDMessage *message; osMessageQueueGet(LEDQueueHandle, &message, 0, osWaitForever); switch (message->ledColor) { case LEDRed: HAL_GPIO_WritePin(RedLED_GPIO_Port, RedLED_Pin, message->ledState ? GPIO_PIN_SET : GPIO_PIN_RESET); break; case LEDGreen: HAL_GPIO_WritePin(GreenLED_GPIO_Port, GreenLED_Pin, message->ledState ? GPIO_PIN_SET : GPIO_PIN_RESET); break; case LEDBlue: HAL_GPIO_WritePin(BlueLED_GPIO_Port, BlueLED_Pin, message->ledState ? GPIO_PIN_SET : GPIO_PIN_RESET); break; } free(message); // 消费者负责释放(动态分配规范) } }

9.3 按键任务(时间片轮询版消抖)
```c

define IS_KEY_PRESSED() (HAL_GPIO_ReadPin(Key1_GPIO_Port, Key1_Pin) == GPIO_PIN_RESET)
define KEY_CHECK_INTERVAL 10 // 按键检测间隔
define KEY_DEBOUNCE_TIME 30 // 消抖时间
define KEY_DEBOUNCE_COUNT (KEY_DEBOUNCE_TIME / KEY_CHECK_INTERVAL) // 30/10=3 次
uint8_t isKey1Clicked() { static uint8_t count = 0; // 消抖计数 static uint8_t pressed = 0; // 每次按键只返回一次点击事件 if (IS_KEY_PRESSED() && !pressed) { count++; if (count >= KEY_DEBOUNCE_COUNT && IS_KEY_PRESSED()) { pressed = 1; return 1; // 按键被点击 } } if (!IS_KEY_PRESSED()) { pressed = 0; count = 0; } return 0; }

void StartKeyTask(void argument) { LEDState state = LEDState_Off; for (;;) { if (isKey1Clicked()) { state = !state; LEDMessage message = malloc(sizeof(LEDMessage)); message->ledColor = LEDRed; message->ledState = state; osMessageQueuePut(LEDQueueHandle, &message, 0, osWaitForever); } osDelay(10); } } ```

9.4 工程解析要点
队列作串口数据缓冲区;Get 无限超时 → 消费者"事件驱动";
串口任务、按键任务都是生产者,LED 任务是唯一消费者 → 多对一通信(还有一对多,实际较少用);
队列传结构体指针 = 动态分配 + 严格释放规范的实例(生产者 malloc、消费者 free)。
十、移植与调试要点速查
FreeRTOS.h 包含顺序:包含任何其他功能模块头文件(task.h、queue.h)之前必须首先包含 FreeRTOS.h(功能模块头文件依赖其中已定义的内容);
栈大小:osThreadAttr_t 中必须合理设置 stack_size,不足会栈溢出;
CubeMX 队列配置:FREERTOS → Tasks and Queues → Queues → Add;Queue Size = 队列长度,Item Size = 元素大小(传递数据的类型);
源码结构(MDK 移植):Source 下 include(头文件)、portable(移植文件)、list.c/queue.c/tasks.c 必要;croutine.c/event_groups.c/stream_buffer.c/timers.c 可选;MDK 开发关注 portable 里 Keil→RVDS(内核移植)与 MemMang(内存管理)。
总结
裸机 → RTOS:从"一个 while(1) 前后台""分而治之的多任务",关键转变是用阻塞让出 CPU,用优先级保证实时;
三种调度:抢占式(不同优先级)、时间片(同优先级)、协程式(主动让权,已基本淘汰);
任务四态:只有就绪态能进运行态;调试时靠"是否有等待事件"区分 Blocked 与挂起;
队列:解决事件丢失/重复/竞态,空时消费者阻塞唤醒,实现事件驱动;
传指针要管内存:所有权、生命周期、野指针、内存践踏是四条红线;动态分配时"生产者 malloc、消费者 free";
实时 = 确定性:这是嵌入式开发的核心思想,也是面试中最值得说清楚的一句话。
如果这篇文章对你有帮助,欢迎点赞收藏;如有错误或疑问,欢迎在评论区交流。
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐