本节目标:理解任务 / 优先级 / 阻塞延时,会用队列、信号量(互斥量)、事件组、直达任务通知这五件最常用的 FreeRTOS 工具
一、本节要解决的问题
ESP-IDF 本身就是基于 FreeRTOS 的框架:app_main() 其实就是系统创建的一个任务,Wi-Fi、日志、LVGL 等组件内部也都在跑任务。不掌握 FreeRTOS,后面的例程会看不懂。
裸机程序把所有功能塞进一个大循环:某个模块一旦需要延时等待,整个循环都被拖住。用状态机可以缓解,但代码复杂、可读性差。RTOS 的思路是把每个功能模块写成独立任务:需要等待时调用 vTaskDelay() 主动让出 CPU,调度器立即切去运行别的任务,整体程序还是"并发"的。
一句话:裸机是"排队死等",RTOS 是"该等等、该跑跑"。
二、相关知识:任务、状态与优先级
2.1 任务的四个状态
| 状态 | 含义 |
|---|---|
| 运行(Running) | 正在占用 CPU;单核上同一时刻只有一个任务处于运行态 |
| 就绪(Ready) | 条件已满足,但 CPU 正被同级或更高优先级任务占用,排队等待 |
| 阻塞(Blocked) | 正在等待时间或事件(队列、信号量、事件组、通知);不消耗 CPU,通常带超时 |
| 挂起(Suspended) | 被 vTaskSuspend() 显式挂起,没有超时,只能由 xTaskResume() 恢复 |
vTaskDelay() 是"等时间",xQueueReceive() 拿不到数据、xSemaphoreTake() 拿不到信号量是"等事件"------它们都让任务进入阻塞态,把 CPU 让给别人。这正是 RTOS 与 while(!flag) 死循环轮询最本质的区别。
2.2 优先级、Tick 与双核
- 优先级数字越大越优先,范围是 0 到
configMAX_PRIORITIES - 1,低优先级数字表示低优先级任务,空闲任务优先级固定为 0;具体上限由 ESP-IDF menuconfig 中 FreeRTOS 的配置决定(生成到 build 目录的 FreeRTOSConfig.h)。 - 本工程
CONFIG_FREERTOS_HZ=100,即 1 个 tick = 10ms;毫秒换算成 tick 用pdMS_TO_TICKS(),注意换算会向下取整成整数个 tick。 - ESP32 是双核,示例统一用
xTaskCreatePinnedToCore()把任务固定到 CORE1 上运行,避免任务在核间迁移。
2.3 任务创建
c
//以下创建两个任务,任务栈大小为2048字节,优先级为3,并设定运行在CORE1上
xTaskCreatePinnedToCore(taskA,"taskA",2048,NULL,3,NULL,1);
xTaskCreatePinnedToCore(taskB,"taskB",2048,NULL,3,NULL,1);
参数依次是:任务函数(原型 void fun(void *param))、任务名(调试打印用)、栈大小、任务参数、优先级、返回的任务句柄(不关心可传 NULL)、绑定到哪个核心。
提示:教程原型注释里写"栈空间大小单位是字节",这对 ESP-IDF 来说是对的;但很多其他 FreeRTOS 资料里的单位是"字(word)",照搬别家例子时别搞混。
三、知识点
3.1 队列(Queue)
队列是任务间通信的主要形式,可作为线程安全的 FIFO 使用,也能在中断和任务之间传消息。数据按值拷贝 入队:发送时会把 uxItemSize 字节的内容整体复制进队列自己的存储区,发送方的局部变量随后销毁也不影响接收。
与教程不一致 :教程把
xQueueSend注释为"向队列头部发送一个消息",实际 FreeRTOS 中xQueueSend与xQueueSendToBack完全等价,都是发到队尾 ;要发到队头得用xQueueSendToFront。以官方文档和头文件为准。
3.2 信号量(Semaphore)
信号量用来保护共享资源、或在任务/中断之间同步。教程的类比:雨伞池------池子里一共 6 把伞(信号量总数),已经在用伞的人(持有信号量的任务)达到 6 个以后,后来者只能等;有人还伞(Give)时,等待者之一才能拿到伞(Take)继续执行。用完必须释放信号量。
- 二值信号量:总数只有 1,相当于"池子里只有一把伞";
- 计数信号量:总数可自定义,适合管理多个同类资源或对事件计数。
FreeRTOS 中信号量本质上就是队列,只是"只关心数量、不关心内容"。
3.3 互斥量、临界资源与优先级继承
临界资源 :多任务系统中同一时刻只能被一个任务使用的资源,必须互斥访问。临界区:代码中访问临界资源的那一段。readme 里的类比很形象:临界资源 = 公共卫生间(所有人都能用,但一次只能进一个人),临界区 = "进门 → 锁门 → 使用 → 开门 → 出门"这一整套不能被打断的操作。
为什么不保护就会出错?经典例子:两个任务都对全局变量 count = 0 执行 count++。任务 A 读到 0、还没写回就被切走;任务 B 也读到 0,加 1 写回 1;A 恢复后也写回 1。两次自增结果却是 1------这就是竞态条件,这类 bug 复现随机、排查难度极高。
FreeRTOS 里三种保护方式:
| 方式 | 特点 | 适用场合 |
|---|---|---|
taskENTER_CRITICAL() / taskEXIT_CRITICAL() |
关任务调度(双核还会获取自旋锁),期间别的任务完全得不到 CPU | 只改一个变量/标志位,快进快出 |
互斥量 xSemaphoreCreateMutex() |
拿不到锁的任务进入阻塞、让出 CPU,锁释放后被唤醒 | 操作外设、读写大缓存等耗时较长的临界区 |
| 关中断 | 最极端,影响所有中断响应 | 只在中断服务程序与任务共享资源时使用,普通代码不推荐 |
提示 :ESP-IDF 是双核环境,官方更推荐用显式自旋锁
portENTER_CRITICAL(&spinlock)/portEXIT_CRITICAL(&spinlock)保护临界区;教程里的taskENTER_CRITICAL()是 FreeRTOS 通用形式,具体以所用 IDF 版本的头文件和文档为准。
互斥量(Mutex)与二值信号量的区别 :互斥量有"归属"------谁 Take 谁负责 Give,只能由持有者释放,并且支持优先级继承 。优先级继承解决的是优先级反转:高优先级任务 H 在等一把被低优先级任务 L 持有的锁,而中优先级任务 M 不停抢占 CPU,导致 L 迟迟释放不了锁、H 被无限期推迟。开启优先级继承后,H 一开始等待,L 就被临时提升到 H 的优先级运行,M 抢不过它;L 尽快释放锁,其优先级随即复原。
与教程不一致 :教程正文写"互斥锁:与二进制信号量类似,但会发生优先级翻转",这个表述有误------互斥锁带优先级继承,正是用来避免/缓解优先级反转的;普通二值信号量才没有这个能力。保护共享资源应优先用互斥量。
readme 口诀:改变量、标状态 → 用临界区,快进快出;操作外设、跑复杂逻辑 → 用互斥量,等待让权。
3.4 事件组(Event Group)
事件组是一组事件位(事件标志),按位编号引用,FreeRTOS 可用 24 位(教程图里画的 24 位就是这个)。它适合"等某个/某几个事件组合"的场合:xEventGroupWaitBits() 可设置"任意一位置位就返回"(xWaitForAllBits = pdFALSE)或"所有等待位都置位才返回"(pdTRUE),还能通过 xClearOnExit 参数在返回时自动清除。
3.5 直达任务通知(Task Notification)
每个任务自带一组通知状态:一个"挂起/非挂起"标志和一个 32 位通知值,不需要额外创建任何对象,是 FreeRTOS 里最轻量的任务间通信方式。发送用 xTaskNotify(),接收用 xTaskNotifyWait();eAction 决定怎么处理通知值:
| eAction | 执行的操作 |
|---|---|
eNoAction |
只唤醒目标任务,通知值不更新,此时不使用 ulValue |
eSetBits |
目标任务的通知值按位或上 ulValue |
eIncrement |
目标任务的通知值自增 1(类似信号量的 Give 操作) |
eSetValueWithOverwrite |
无条件把目标任务的通知值设置为 ulValue |
eSetValueWithoutOverwrite |
仅当目标任务没有挂起通知时才写入 ulValue |
四、代码分析
4.1 工程结构
rtos/
├── CMakeLists.txt
├── main/
│ ├── CMakeLists.txt # idf_component_register(SRCS "main.c" INCLUDE_DIRS ".")
│ └── main.c # 五种 freeRTOS 特性的例程都在这一个文件里
└── sdkconfig
5 个例程写在同一个 main.c 中,app_main() 里注释掉其余的、只留一个:
c
//入口函数
void app_main(void)
{
/*
以下是每种freeRTOS特性的测试例程的初始化函数,建议每次只开一个,否则有太多打印影响体验
*/
//rtos_task_sample();
//rtos_queue_sample();
rtos_sem_sample();
//rtos_event_sample();
//rtos_notify_sample();
}
与教程不一致:教程 PDF 只抽讲了少量 API,给人的印象是"零散示例";仓库里其实完整实现了任务、队列、信号量、事件组、任务通知 5 个例程,注释也很全,建议对照源码逐段阅读。另外教程没提醒"5 个例程要互斥使用"------仓库代码里当前默认打开的是信号量例程。
4.2 任务例程
taskA 每 500ms 打印一次 this is taskA,taskB 每 700ms 打印一次;两者同优先级、同核心,靠 vTaskDelay() 放弃 CPU,串口上两条日志按各自的节奏交替出现。
4.3 队列例程
生产者 queue_taskA 每秒组装一个 queue_packet(只含一个 int 成员),发送后打印再延时:
c
queue_packet packet;
packet.num = test_cnt++;
//发送queue_packet数据
xQueueSend(s_testQueue,&packet,pdMS_TO_TICKS(200));
消费者 queue_taskB 自己不做延时,阻塞在接收上,数据一到立刻被唤醒:
c
BaseType_t ret = xQueueReceive(s_testQueue,&packet,pdMS_TO_TICKS(200));
if(ret == pdTRUE)
{
//如果收到数据就打印出来
ESP_LOGI(TAG,"taskB receive packet,num:%d",packet.num);
}
队列深度 5、每项大小 sizeof(queue_packet),一行创建:
c
//初始化一个队列,队列单元内容是queue_packet结构体,最大长度是5
s_testQueue = xQueueCreate(5,sizeof(queue_packet));
4.4 信号量例程
c
s_testBinSem = xSemaphoreCreateBinary();
s_testCountSem = xSemaphoreCreateCounting(5,0);
s_testMuxSem = xSemaphoreCreateMutex();
sem_taskA 每 2 秒 Give 三种信号量:二值信号量 1 次;计数信号量 5 次(相当于把 5 把伞都还回池子);互斥量 1 次。sem_taskB 依次:无限等待二值信号量 → 用 do-while 把计数信号量一次拿到拿空(每次最多等 200ms,拿不到就退出循环)→ 无限等待互斥量。
与教程不一致(代码写法隐患) :本工程互斥量用
xSemaphoreCreateMutex()创建,却使用了递归 APIxSemaphoreTakeRecursive()/xSemaphoreGiveRecursive()。FreeRTOS 要求递归 API 只能配合xSemaphoreCreateRecursiveMutex()创建的递归互斥量使用,这里属于不规范用法;自己写代码时创建函数和 Take/Give 函数一定要配对。
另外注意这个示例的语义:B 任务取走的信号量不会归还(没有对应的 Give),所以它打印完 5 个计数信号量后就退出等待;下一轮要等 A 再次 Give。真实工程里"借伞一定要还伞"。
4.5 事件组例程
event_taskA 先置 BIT0,等 1 秒再置 BIT1,再等 1.5 秒进入下一轮;event_taskB 用一次 xEventGroupWaitBits() 同时等两位:
c
ev = xEventGroupWaitBits(s_testEvent,BIT0|BIT1,pdTRUE,pdFALSE,portMAX_DELAY);
if(ev & BIT0)
{
ESP_LOGI(TAG,"Event BIT0 set");
}
if(ev& BIT1)
{
ESP_LOGI(TAG,"Event BIT1 set");
}
pdTRUE 表示返回时自动清除等待位,pdFALSE 表示任意一位置位即可返回。任务侧用两个独立的 if 而不是 if/else,因为某一刻可能多位同时被置上。
4.6 直达任务通知例程
要通知某个任务,必须先拿到它的句柄,所以创建任务时传入了 &s_notifyTaskAHandle:
c
xTaskCreatePinnedToCore(notify_taskA,"notify_taskA",2048,NULL,3,&s_notifyTaskAHandle,1);
notify_taskB 每秒把自增值直接"投递"给任务 A:
c
xTaskNotify(s_notifyTaskAHandle, notify_val, eSetValueWithOverwrite);
notify_val++;
vTaskDelay(pdMS_TO_TICKS(1000));
任务 A 阻塞等待并读出通知值:
c
if (xTaskNotifyWait(0x00, ULONG_MAX, &rec_val, pdMS_TO_TICKS(1000)) == pdTRUE)
{
ESP_LOGI(TAG,"receive notify value:%lu",rec_val);
}
与教程不一致(注释与代码相反) :源码里
notify_taskA的注释写的是"用于定时向任务通知B直接传输数据",但实际 A 是接收方、B 才是发送方,注释恰好写反;阅读时以代码为准。
五、关键 API 速查
| API | 作用 | 所在头文件 |
|---|---|---|
xTaskCreatePinnedToCore() |
创建任务并绑定运行核心 | freertos/task.h |
vTaskDelay() / pdMS_TO_TICKS() |
阻塞延时(毫秒换算成 tick) | freertos/task.h |
xQueueCreate() |
创建队列(队列长度 × 单项字节数) | freertos/queue.h |
xQueueSend() |
向队尾发送数据(等价于 xQueueSendToBack) | freertos/queue.h |
xQueueReceive() |
从队头接收数据,可阻塞等待 | freertos/queue.h |
xQueueSendFromISR() |
中断服务程序中的发送版本 | freertos/queue.h |
xSemaphoreCreateBinary() |
创建二值信号量(数量最大为 1) | freertos/semphr.h |
xSemaphoreCreateCounting() |
创建计数信号量(最大数、初始数) | freertos/semphr.h |
xSemaphoreCreateMutex() |
创建互斥量(支持优先级继承) | freertos/semphr.h |
xSemaphoreTake() / xSemaphoreGive() |
获取 / 释放信号量 | freertos/semphr.h |
xSemaphoreTakeRecursive() / xSemaphoreGiveRecursive() |
递归互斥量的获取 / 释放 | freertos/semphr.h |
xEventGroupCreate() |
创建事件组 | freertos/event_groups.h |
xEventGroupWaitBits() |
等待一个或多个事件位 | freertos/event_groups.h |
xEventGroupSetBits() / xEventGroupClearBits() |
置位 / 清除事件位 | freertos/event_groups.h |
xTaskNotify() |
直达任务通知:发送 | freertos/task.h |
xTaskNotifyWait() |
直达任务通知:等待并读取通知值 | freertos/task.h |
六、编译与现象验证
bash
cd esp32-board/rtos
idf.py build
idf.py -p COM<端口> flash monitor
该工程 sdkconfig 当前目标为 esp32(经典款) 。若你的板子芯片型号不同,先执行
idf.py set-target <型号>再编译。
依次打开 5 个例程(每次只留一个)应该看到:
rtos_task_sample():this is taskA每 500ms 一条、this is taskB每 700ms 一条,交替打印;rtos_queue_sample():每秒一行taskA send packet,num:x和一行taskB receive packet,num:x,两行序号一致;rtos_sem_sample():每 2 秒看到take binary semaphore、5 行take count semaphore,count:1~5、take Mutex semaphore;rtos_event_sample():先打印Event BIT0 set,约 1 秒后打印Event BIT1 set,然后循环;rtos_notify_sample():每秒一行receive notify value:N,N 递增。