第 13 章 FreeRTOS 多任务:让芯片“一心多用“
前面章节里我们一直在用
vTaskDelay,但没讲透它背后的系统。
这一章把 ESP-IDF 自带的实时操作系统 FreeRTOS 讲清楚:
任务是什么、调度器怎么决定谁先跑、为什么"阻塞"比"空转"好、
任务之间怎么安全地传数据(队列)、怎么互斥(信号量/互斥锁)。
这是"只看一本书就能做真实项目"的关键一章。
13.1 为什么需要操作系统
回想第 12 章那个综合程序:app_main 里一个 while(true) 剧本,把
“正转加速→停 1 秒→反转加速→停→刹车"排成一条时间线顺序执行。
它能跑,但只有一个"主角”——剧本在等那 1 秒时,芯片什么也别干,
否则就得打断这段排队。想"一边转电机、一边看按键、一边上报数据"?
while 顺序结构办不到:同一时刻只能有一串代码在往下走。
一个应用往往要同时做几件事:读传感器、控制电机、响应按键、上报数据。
如果顺序执行:读传感器要等 10ms,期间按键按了没人理——体验差、逻辑乱。
FreeRTOS 提供"任务(task)“:每个任务是一段独立的循环,
由调度器(scheduler) 决定某一时刻 CPU 跑哪个任务。
看起来就像"同时做多件事”(实际上单核 CPU 在同一时刻只跑一个任务,
只是切换得飞快)。
时间轴 ────────────────────────────►
任务A: ████ ████ ████
任务B: ████████ ████████
任务C: ██ ██
↑ 调度器在任务间快速切换(微秒级)
13.2 任务:一个"无限循环函数"
先看一个完整、能直接抄走编译的例子(一个闪灯任务)。
本章借用引脚:只有闪灯演示用的 GPIO48(与第 4 章同一个脚、同一套外接
LED 电路:GPIO48 → 330Ω → LED → GND,见 4.8)。它是 4.6 排除法选出的干净 IO,
不与电机 16/17/18 和第 14、15 章的示例引脚冲突。
// main/blink_task.cpp —— 完整可编译
#include "freertos/FreeRTOS.h" // FreeRTOS 核心(放最前面)
#include "freertos/task.h" // xTaskCreate / vTaskDelay
#include "driver/gpio.h" // gpio_config / gpio_set_level
#include "esp_err.h" // esp_err_t / ESP_ERROR_CHECK
#include "esp_log.h" // ESP_LOGI
#define LED_PIN GPIO_NUM_48 // 本章借用的唯一引脚(同第 4 章,外接 LED)
static const char *TAG = "task";
static void task_led(void *arg) { // 任务函数:参数是 void*
while (1) {
gpio_set_level(LED_PIN, 1);
vTaskDelay(pdMS_TO_TICKS(500)); // 主动"睡觉",把 CPU 让出去
gpio_set_level(LED_PIN, 0);
vTaskDelay(pdMS_TO_TICKS(500));
}
// 注意:任务函数不允许 return!(return 等于删除自己)
}
extern "C" void app_main(void) {
// 先把 LED 引脚配成输出(第 4 章的 gpio_config 老朋友)
gpio_config_t io = {};
io.pin_bit_mask = 1ULL << LED_PIN;
io.mode = GPIO_MODE_OUTPUT;
ESP_ERROR_CHECK(gpio_config(&io));
TaskHandle_t h = NULL;
BaseType_t ok = xTaskCreate(
task_led, // 任务函数指针
"led", // 任务名字(调试用)
4096, // 任务栈大小(字节数,见 13.4)
NULL, // 传给函数的参数
1, // 优先级(0~24,越大越优先)
&h); // 拿回任务句柄
ESP_ERROR_CHECK(ok == pdPASS ? ESP_OK : ESP_FAIL);
// app_main 到这里就"交棒"给调度器。它在 app_main 返回后会自动
// 删除 app_main 所在的任务——不要在这里写 vTaskDelete(NULL)!
// (那是自己删自己,既多余也容易误导。)
ESP_LOGI(TAG, "task_led 已创建,app_main 马上返回");
}
// main/CMakeLists.txt —— 这个示例需要的依赖
idf_component_register(
SRCS "blink_task.cpp"
INCLUDE_DIRS "."
REQUIRES esp_driver_gpio # driver/gpio.h
# freertos、esp_log、esp_err 都是每个组件默认就有的公共依赖,不用写
)
【预期输出 / 没输出怎么办】
- 预期输出:外接在 GPIO48 的 LED 每 0.5 秒亮灭一次;串口开机日志里
出现一行I (123) task: task_led 已创建,app_main 马上返回
(123是开机毫秒数,会随板子略有不同)。- 没输出/灯不亮:① 确认 LED 电路是 GPIO48→330Ω→LED 长脚→GND(反接不亮);
② 完全无日志、乱码 → 查波特率/监视器,见 2.11;③ 有日志但灯不动 → 引脚写错或虚接。
一个坑先记住:
vTaskDelay的精度受"系统节拍(tick)"限制。
v6.0.1 里CONFIG_FREERTOS_HZ默认是 100,也就是一拍 = 10ms。
vTaskDelay(pdMS_TO_TICKS(500))= 等 50 拍,正好 500ms;但
pdMS_TO_TICKS(1)会算成 0 拍,等于没延时。要 1ms 级的精准延时,
别用 vTaskDelay,用 13.9 的 esp_timer。
xTaskCreate 的参数逐个讲:
- 任务函数:签名必须是
void func(void *arg); - 名字:最多 16 字符,调试/监控用;
- 栈大小:字节数。任务自己需要局部变量、调用函数时压栈
(第 1 章讲过栈)。太小 → 栈溢出 → 崩溃;太大 → 浪费内存; - 参数:可以传一个指针进去(比如传电机对象);
- 优先级:数字越大越先跑(0~24);
- 句柄:用来以后操作这个任务(删除、挂起、通知)。句柄(handle)就像
医院取号小票——创建任务时给你一张,凭票才能找回"刚才排的那个队",
后续vTaskDelete(h)、uxTaskGetStackHighWaterMark(h)都靠它定位。
任务创建 = 分配一块独立的栈 + 登记到调度器。之后任务
就从"等待运行"状态开始,由调度器决定何时真正执行。
双核 S3:把任务钉在某个核上(xTaskCreatePinnedToCore)
上面讲的 xTaskCreate 在单核芯片(比如 ESP32-C3)上够用,但
ESP32-S3 是双核。这里有个很多初学者忽略的事实:
xTaskCreate(以及它的钉核版xTaskCreatePinnedToCore传
tskNO_AFFINITY)创建的任务,默认不绑定某个核——调度器可以在
Core 0 和 Core 1 之间来回搬它(术语叫"亲和性 affinity")。- 大多数应用这完全没问题,甚至更好(哪个核空就跑哪个)。
但当你要精确控制时序或避开某个核上的繁忙任务时,就把它
钉死在某个核上:
// (片段,接在 13.2 完整示例上,不能单独编译——沿用它的 task_led 和 h)
// 和 xTaskCreate 参数一模一样,只是末尾多一个"绑哪个核"
// core 0 或 core 1;想不绑就传 tskNO_AFFINITY(等价于 xTaskCreate)
xTaskCreatePinnedToCore(task_led, "led", 4096, NULL, 1, &h, 1 /*钉在 Core 1*/);
什么时候需要钉核?
【进阶·可先跳过,照抄不钉核即可】 下面三种才是钉核的真正理由,
涉及几个术语,逐个括注:
- 时序特别敏感的任务:这里说的"抖动"是时序抖动(每次执行时刻
有微秒级前后漂移,跟第 4 章按键的"电平抖动/接触弹跳"是两回事,别混),
不想被核间迁移带来的 cache(缓存,就近存常用指令的小仓库)冷启动
影响 → 钉一个核,让它的缓存一直是热的;- **位带(bit-banging,用软件一条条 GPIO 手动模拟通信协议)**之类
对间隔要求苛刻的通信 → 钉核防止被别的任务打断拉长某一位;- 需要独占一个核跑实时任务 → 把杂活都赶到 Core 0,实时任务钉 Core 1。
- 反过来要避开的:ESP-IDF 的 Wi-Fi 协议栈内部任务默认优先级约 23、
且钉在 Core 0(v6.0.1 文档核实)。你若做网络 + 控制混合项目,
把对延迟敏感的任务钉到 Core 1,就别和 Wi-Fi 抢 Core 0。
⚠️ 别把钉核当默认习惯。99% 的任务用
xTaskCreate让调度器自己
分配反而更稳。只有明确理由(时序抖动、独占、避开 Wi-Fi 栈)才钉核。
13.3 调度:优先级、抢占、时间片
FreeRTOS 是抢占式调度:任何时刻,就绪队列里优先级最高的任务
获得 CPU。
任务A(优先级3):就绪 → 运行
任务B(优先级2):就绪 → 等 A 让出
- 优先级相同:默认时间片轮转——每个任务跑一个时间片
(若干拍,一拍 10ms,见 13.2)后让给下一个; - 高优先级任务就绪:立刻抢占低优先级任务(低的被挂起);
- 低优先级任务要等所有更高优先级任务阻塞或结束才轮到。
阻塞:把 CPU 让出去的正确姿势
vTaskDelay(pdMS_TO_TICKS(500)); // 睡眠指定时间 → 状态变为"阻塞"
- 阻塞 = 任务"不占用 CPU"地等待某件事(延时/队列有数据/信号量被给);
- 阻塞中的任务不耗 CPU,调度器跑其他任务;
- 区别:空转(
while(1){}等时间)烧 CPU;阻塞零成本。
结论:实时系统里,等待必须用阻塞,不要用空转。这是
"看起来同时做很多事"的实现基础。
一个任务的三种状态怎么流转
被调度器选中
就绪 ──────────────► 运行
▲ │
│ 时间片用完/被抢占 │ 调用 vTaskDelay / 等队列 / 等信号量
│◄────────────────── ┴─────────────► 阻塞(不占 CPU,干等事件)
│ │
└────────────────────────────────────────┘
延时到期 / 队列来数据 / 信号量被给 → 回到"就绪"排队
- 就绪:万事俱备,只等 CPU;运行:正占着 CPU;
- 阻塞:主动让出 CPU 去等某个事件,这段时间一点 CPU 都不耗。
- 若高优先级任务一直就绪或一直占着 CPU 不让出,低优先级任务就永远
停在"就绪"排队、轮不到——这叫饿死(starvation)。
13.4 任务栈:多大才够?
- 默认我们给 2048~4096 字节。任务里如果放大数组(如
uint8_t buf[4096]),
栈必须对应加大; - 检测溢出:启用
CONFIG_FREERTOS_CHECK_STACKOVERFLOW,溢出会打印
***ERROR*** A stack overflow in task ... has been detected; - 调试时可用
uxTaskGetStackHighWaterMark(handle)看"还剩多少栈"。
⚠️ 栈溢出是嵌入式最常见的隐藏崩溃之一:症状是"偶尔乱跑、无规律崩"。
出现这种症状,先查每个任务的栈水位。
13.5 队列:任务之间安全地传数据
任务 A 产生数据(如按键次数),任务 B 需要消费(如更新显示)。
直接用全局变量 + 两个任务读写?有风险:任务 A 写一半任务 B 来读,
读到一半数据。队列(queue) 解决这个问题:数据复制进出,一次一条,
先进先出,并且自带阻塞。
// main/queue_demo.cpp —— 完整可编译
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "esp_log.h"
static const char *TAG = "app";
static QueueHandle_t q;
static void task_producer(void *arg) { // 产生者
int n = 0;
while (1) {
vTaskDelay(pdMS_TO_TICKS(100));
n++;
xQueueSend(q, &n, portMAX_DELAY); // 把 n 复制进队列;满则阻塞等待
}
}
static void task_consumer(void *arg) { // 消费者
int got = 0;
while (1) {
xQueueReceive(q, &got, portMAX_DELAY); // 取一条;空则阻塞等待
ESP_LOGI(TAG, "收到: %d", got);
}
}
extern "C" void app_main(void) {
q = xQueueCreate(10, sizeof(int)); // 容量 10 条,每条 sizeof(int) 字节
xTaskCreate(task_producer, "prod", 2048, NULL, 2, NULL);
xTaskCreate(task_consumer, "cons", 2048, NULL, 1, NULL);
}
// main/CMakeLists.txt —— 队列只用 FreeRTOS,全部是公共组件,不用写 REQUIRES
idf_component_register(SRCS "queue_demo.cpp" INCLUDE_DIRS ".")
【预期输出 / 没输出怎么办】
- 预期输出:串口每约 100ms 打印一行,数字从 1 递增:
I (123) app: 收到: 1/I (223) app: 收到: 2/I (323) app: 收到: 3…- 没输出:① 只有开机引导、没有"收到"行 → 检查两个
xTaskCreate是否都成功、
优先级别让生产者饿死;② 一行都不打、像死机 → 多半忘了xQueueCreate或句柄传错;
③ 完全乱码 → 见 2.11。
本章后面所有示例都占 0 个新引脚(队列/信号量/事件组是纯软件机制)。
xQueueCreate(容量, 每条大小):创建队列(内存来自堆);xQueueSend(q, &data, 等待时间):发送(复制数据);xQueueReceive(q, &buf, 等待时间):接收(复制数据);portMAX_DELAY= 永远等下去(阻塞直到成功);- 数据是复制的:任务 A 发送后改自己的变量,队列里那份不受影响——
天然安全,不需要锁。
队列是嵌入式最常用的任务间通信(IPC)手段:解耦 + 安全 + 可阻塞。
13.6 信号量与互斥锁:保护"共享资源"
先给两个生活类比记住分工:信号量像游乐场的"令牌"——手里有令牌才能进场玩,
玩完把令牌还回去,谁捡到令牌谁进;互斥锁像"带名字的钥匙"——只有拿钥匙的人
能锁门/开门,必须谁拿谁还,而且它能记住持有者(所以能防优先级反转,见本节末)。
互斥锁(Mutex):一次只让一个任务进去
两个任务都要操作同一个 I2C 总线(共享资源)时,必须互斥:
否则 A 读到一半 B 来插一脚,数据错乱。
// main/mutex_demo.cpp —— 完整可编译
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h" // xSemaphoreCreateMutex 在这里
#include "esp_log.h"
static const char *TAG = "mutex";
static SemaphoreHandle_t s_i2c_lock;
// 每个要碰共享总线的任务,都把这段"独占操作的关键区域"
// (正式名称"临界区",15.6 细讲)用锁包起来
static void task_use_bus(void *arg) {
const char *name = (const char *)arg; // 用参数区分两个任务
while (1) {
xSemaphoreTake(s_i2c_lock, portMAX_DELAY); // 拿到锁(否则阻塞等)
ESP_LOGI(TAG, "%s 进入独占区", name); // 进:这两行之间绝不交叉
vTaskDelay(pdMS_TO_TICKS(5)); // 独占操作:这段时间没人能插进来
ESP_LOGI(TAG, "%s 离开独占区", name);
xSemaphoreGive(s_i2c_lock); // 释放锁
vTaskDelay(pdMS_TO_TICKS(100));
}
}
extern "C" void app_main(void) {
s_i2c_lock = xSemaphoreCreateMutex(); // 创建互斥锁
xTaskCreate(task_use_bus, "busA", 4096, (void *)"A", 2, NULL);
xTaskCreate(task_use_bus, "busB", 4096, (void *)"B", 2, NULL);
}
(CMakeLists:只用 FreeRTOS 与 esp_log,都是默认公共依赖,idf_component_register
不写额外 REQUIRES。)
【预期输出 / 没输出怎么办】
- 预期输出:串口交替打印,且"进入/离开"永远成对、不交叉,例如
I (…) mutex: A 进入独占区→I (…) mutex: A 离开独占区→
I (…) mutex: B 进入独占区→I (…) mutex: B 离开独占区循环。
关键:看不到"A 进入"后面紧跟"B 进入"——锁挡住了插队。- 没输出/交叉了:① 两行交叉 → 检查 Take/Give 是否配对、是否用了 mutex 而非二值信号量;
② 一个任务进得去另一个永远进不来 → 多半只有 Take 没有 Give(死锁);
③ 全无日志 → 见 2.11。
二值信号量(Binary Semaphore):一个"一次性的旗子"
适合"通知一次"场景(如 ISR 告诉任务"有事件"——第 15 章重点)。
// (片段,接在 13.6 互斥锁完整示例上,不能单独编译——沿用它的 include 骨架,
// 只需把 semphr.h 一并带上,它已给了 xSemaphoreCreateBinary)
static SemaphoreHandle_t s_event;
// 任务:等"有人给我举旗"
static void task_wait_event(void *arg) {
while (1) {
xSemaphoreTake(s_event, portMAX_DELAY); // 阻塞直到有人 Give
// handle_event(); // 醒来后处理这次事件
}
}
extern "C" void app_main(void) {
s_event = xSemaphoreCreateBinary(); // 创建"空旗"(初始无人 Give)
xTaskCreate(task_wait_event, "evt", 4096, NULL, 2, NULL);
// 别处(另一个任务、或第 15 章的 ISR)xSemaphoreGive(s_event); 举一次旗
}
区别:互斥锁强调"独占"(谁拿了谁还);二值信号量强调"通知"。
混用会出逻辑错误,注意语义。
优先级反转与互斥锁的"继承"
低优先级任务拿了锁 → 高优先级任务来等锁 → 中优先级任务趁机
“插队"一直跑 → 高优先级被饿死。这叫优先级反转。
FreeRTOS 的互斥锁带优先级继承:低优先级任务拿锁期间,
临时把优先级提到等待者最高——缓解反转。这是为什么"共享资源
优先用互斥锁而不是信号量”。
13.7 事件组:等"几个条件都满足"
事件组 = 一面墙上并排的几个"小旗"(bit),每个 bit 代表一个条件。
有的任务负责"举旗"(置位),有的任务负责"等几面旗一起举起"(等待)。
下面是一个完整、抄下去就能看到输出的最小例子:task_setter 在第
1 秒举"按钮旗"、第 3 秒举"定时器旗";task_wait_both 等两面旗都举起
(xWaitForAllBits = pdTRUE 就是"与",要全满足)。
// main/event_group_demo.cpp —— 完整可编译
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/event_groups.h" // 事件组
#include "esp_log.h"
#define EVT_BUTTON (1 << 0) // 用不同的 bit 代表不同条件
#define EVT_TIMER (1 << 1)
static const char *TAG = "evt";
static EventGroupHandle_t evt;
// 等待任务:等"按钮且定时器"两个 bit 都被置 1
static void task_wait_both(void *arg) {
EventBits_t bits = xEventGroupWaitBits(evt, EVT_BUTTON | EVT_TIMER,
pdTRUE /*退出时清位*/, pdTRUE /*两位都要(与)*/, portMAX_DELAY);
ESP_LOGI(TAG, "两个条件都满足了(bit 值=0x%02X),开始干活", (int)bits);
vTaskDelete(NULL); // 教学示例:干完活自删(正式写法,别用 return)
}
// 置位任务:分别在第 1 秒、第 3 秒举两面旗
static void task_setter(void *arg) {
vTaskDelay(pdMS_TO_TICKS(1000));
xEventGroupSetBits(evt, EVT_BUTTON);
ESP_LOGI(TAG, "1s:已置位按钮 (EVT_BUTTON)");
vTaskDelay(pdMS_TO_TICKS(1000)); // 到第 2 秒,只满足了一半条件
ESP_LOGI(TAG, "2s:定时器还没到,等待任务仍在睡觉");
vTaskDelay(pdMS_TO_TICKS(1000)); // 到第 3 秒
xEventGroupSetBits(evt, EVT_TIMER);
ESP_LOGI(TAG, "3s:已置位定时器 (EVT_TIMER)");
vTaskDelete(NULL);
}
extern "C" void app_main(void) {
evt = xEventGroupCreate(); // 创建事件组(初始两面旗都没举)
xTaskCreate(task_wait_both, "wait", 4096, NULL, 2, NULL);
xTaskCreate(task_setter, "set", 4096, NULL, 2, NULL);
}
// main/CMakeLists.txt —— 只用 FreeRTOS 与 esp_log(默认公共依赖),不写额外 REQUIRES
idf_component_register(SRCS "event_group_demo.cpp" INCLUDE_DIRS ".")
【预期输出 / 没输出怎么办】
- 预期输出:开机后约第 1 秒、第 2 秒、第 3 秒各打印一行 setter 日志,
紧接着在第 3 秒打印等待任务那句"两个条件都满足了"(时间戳(...)内是毫秒):关键点:I (1099) evt: 1s:已置位按钮 (EVT_BUTTON) I (2099) evt: 2s:定时器还没到,等待任务仍在睡觉 I (3099) evt: 3s:已置位定时器 (EVT_TIMER) I (3099) evt: 两个条件都满足了(bit 值=0x03),开始干活0x03= 二进制11,两面旗都举起;"两个条件都满足了"只在第 3 秒
出现一次——这正是xWaitForAllBits = pdTRUE(与)的效果。- 没输出/顺序不对:① 三秒内没有任何"两个条件"行 → 检查
xEventGroupCreate
是否在建任务之前调用、句柄evt是否被两个任务共用同一个;② 只等到第 1 秒就打印
→xWaitForAllBits误写成pdFALSE(那就成了"或");③ 完全无日志 → 见 2.11。
xEventGroupCreate():创建事件组(内存来自堆);xEventGroupSetBits(evt, 掩码):把掩码对应的 bit 举旗(普通任务里调用);
ISR 里要用xEventGroupSetBitsFromISR(第 15 章);xEventGroupWaitBits(evt, 掩码, 退出清位, 是否全等, 超时):等位;
第 3 个参数pdTRUE表示"等成功后顺手把这些 bit 清零",方便下一轮再等。
13.8 看门狗:系统"卡死"时的救命机制
看门狗(Watchdog) = 一个计数器 + 定时器:程序必须定期"喂狗"
(重置计数器);如果超过时间没喂,系统认定卡死,自动复位。
正常: 任务喂狗 ──► 计数器清零 ──► 继续跑
卡死: (没人喂)──► 计数器数到头 ──► 芯片复位!
ESP-IDF 里:
// main/wdt_demo.cpp —— 完整可编译
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_task_wdt.h" // 任务看门狗
#include "esp_err.h"
#include "esp_log.h"
static const char *TAG = "wdt";
// 一个"怕它卡住"的任务:加入看门狗,循环里定期喂狗
static void task_risky(void *arg) {
ESP_ERROR_CHECK(esp_task_wdt_add(NULL)); // 把当前任务交给看门狗盯着
while (1) {
ESP_LOGI(TAG, "干活一圈"); // 正常业务:单圈耗时不超过超时就没问题
ESP_ERROR_CHECK(esp_task_wdt_reset()); // 喂狗:告诉看门狗"我还活着"
vTaskDelay(pdMS_TO_TICKS(500));
}
}
extern "C" void app_main(void) {
xTaskCreate(task_risky, "risky", 4096, NULL, 5, NULL);
}
// main/CMakeLists.txt —— 不用写额外 REQUIRES
idf_component_register(SRCS "wdt_demo.cpp" INCLUDE_DIRS ".")
// v6.0.1 里 esp_task_wdt.h 属于 esp_system 组件,而 esp_system 和
// freertos/esp_log 一样是每个组件默认就有的公共依赖,无需手写 REQUIRES。
【预期输出 / 没输出怎么办】
- 预期输出:正常运行时每约 500ms 一行
I (…) wdt: 干活一圈,因为循环里
按时喂了狗,看门狗不会触发。把vTaskDelay那行删掉(模拟卡死),
约 5 秒后就会看到E (…) esp_task_wdt: Task watchdog got triggered. The following tasks did not reset the watchdog in time并复位重启。- 没看到"干活一圈"/复位 → ① 检查
esp_task_wdt_add(NULL)是否在任务里调用;
② 完全无日志 → 见 2.11;③ 一开机就复位 → 你的循环本身耗时超过 5s 超时。
- 任务看门狗:
esp_task_wdt_add监控任务;卡死/饿死的任务触发复位。 - 关于
esp_task_wdt_init——通常你根本不用调:v6.0.1 里
CONFIG_ESP_TASK_WDT_INIT默认就是 y,系统启动时看门狗已经自动初始化好
(默认超时CONFIG_ESP_TASK_WDT_TIMEOUT_S= 5 秒)。只有当你想改默认参数、
或关掉了自动初始化时,才手动esp_task_wdt_init(&cfg),配置结构长这样
(字段以esp_system/include/esp_task_wdt.h为准):
// (片段,接在 13.8 看门狗完整示例上,不能单独编译——演示手动初始化参数用)
esp_task_wdt_config_t cfg = {
.timeout_ms = 5000, // 多久没喂狗就超时
.idle_core_mask = 0, // 0=不额外监控空闲任务;BIT(0)/BIT(1)=监控对应核 idle
// (空闲任务 idle:每个核优先级最低、别的任务都在阻塞没活干时
// 兜底跑的后台任务;把它也纳入监控,连"整个核都卡住"都能抓到)
.trigger_panic = true, // true=超时直接 panic 复位;false=只打印警告
};
// esp_task_wdt_init(&cfg); // 必须在调度器启动后调用,且只能初始化一次
- 中断看门狗(IWDT):监控中断是否卡死(默认开启);
- 开发阶段建议开、发布阶段保留(防现场死机)。
什么时候会触发?死循环(阻塞了比 timeout 还久)、任务被饿死。
看门狗日志:Task watchdog got triggered. The following tasks did not reset the watchdog in time。
13.9 esp_timer:高精度"软定时器"(另一个常用定时工具)
// main/timer_demo.cpp —— 完整可编译
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_timer.h" // 软件定时器
#include "esp_err.h"
#include "esp_log.h"
static const char *TAG = "t";
static esp_timer_handle_t s_th;
// 回调在 esp_timer 系统任务里执行——同样要"快"(见第 15 章)
static void timer_cb(void *arg) {
ESP_LOGI(TAG, "1 秒到了");
}
extern "C" void app_main(void) {
esp_timer_create_args_t args = {
.callback = timer_cb,
.arg = NULL,
.dispatch_method = ESP_TIMER_TASK, // 在系统任务里跑回调
.name = "my_timer",
};
ESP_ERROR_CHECK(esp_timer_create(&args, &s_th));
ESP_ERROR_CHECK(esp_timer_start_periodic(s_th, 1000000)); // 周期 1 秒(单位:微秒)
}
// main/CMakeLists.txt
idf_component_register(SRCS "timer_demo.cpp" INCLUDE_DIRS "."
REQUIRES esp_timer)
【预期输出 / 没输出怎么办】
- 预期输出:每 1 秒一行
I (…) t: 1 秒到了,时间戳依次约 1000、2000、3000…- 没输出:① 确认
REQUIRES esp_timer写了(否则编译期就报找不到esp_timer.h);
② 有编译但没打印 →esp_timer_start_periodic的周期参数单位是微秒,
写成1等于 1µs 疯狂触发、1000000才是 1 秒;③ 全无日志 → 见 2.11。
- 精度高(微秒级)、不占你自己的任务栈;
- 回调运行在系统 esp_timer 任务上下文,回调里同样要"快"(见第 15 章);
- 适合"周期事件 + 不需要任务状态"的场景(心跳、轮询传感器);
- 补 13.2 的坑:想要 1ms 级的精确定时,用这里(
esp_timer_start_once
单次 /esp_timer_start_periodic周期,参数单位是微秒),别用 vTaskDelay。
13.10 选型表:什么时候用什么
| 需求 | 用什么 |
|---|---|
| 周期/独立的工作 | 任务 + vTaskDelay |
| 一次性延迟 | vTaskDelay / esp_timer 单次 |
| 传数据(复制安全) | 队列 |
| 保护共享资源 | 互斥锁(带优先级继承) |
| ISR 通知任务 | 二值信号量 / 队列 FromISR(第 15 章) |
| 多个条件组合等待 | 事件组 |
| 高精度周期回调 | esp_timer |
| 防卡死 | 看门狗 |
13.11 常见问题速查
| 现象 | 原因 | 解决 |
|---|---|---|
| 任务只跑一个 | 低优先级被高优先级饿死 | 检查优先级、阻塞是否真阻塞 |
| 无规律崩溃 | 栈溢出 | 开溢出检测,查 high-water-mark |
| 数据错乱 | 共享变量无保护 | 队列 / 互斥锁 |
| 看门狗复位 | 死循环 / 饿死 | 找没喂狗的任务,检查阻塞时间 |
| vTaskDelay 后不准 | 节拍粒度(CONFIG_FREERTOS_HZ 默认 100 → 一拍 10ms,pdMS_TO_TICKS(1) 会算成 0) | 需要 1ms 级用 esp_timer(13.9) |
13.12 想一想 + 小测验
想一想:
- "阻塞"和"空转等待"在 CPU 占用上有什么区别?
- 为什么队列传数据"天然安全"?
- 互斥锁为什么比二值信号量更适合保护共享资源?
小测验:
- 任务函数写完后应该?(A. return B. 循环内阻塞 C. exit)
xQueueCreate(10, sizeof(int))创建的是什么?- 高优先级任务就绪时会____低优先级任务。
- 系统卡死时谁来"救"它?(A. 看门狗 B. 队列 C. 事件组)
13.13 本章总结
- 任务 = 独立的无限循环函数,由调度器按优先级抢占调度;
- 默认节拍 10ms(
CONFIG_FREERTOS_HZ=100):vTaskDelay做不到 1ms 级,
高精度定时交给 esp_timer; - 双核 S3 任务默认在两核间漂移,需要时用
xTaskCreatePinnedToCore
钉核(避开钉在 Core 0、优先级约 23 的 Wi-Fi 栈); - 阻塞不耗 CPU(延时/队列/信号量),空转才耗;
- 队列 = 复制式先进先出,任务间安全传数据;
- 互斥锁 = 独占 + 优先级继承,保护共享资源;
- 事件组组合条件、看门狗防卡死、esp_timer 高精度周期回调。
下一章:常用外设——定时器、ADC、UART、I2C、SPI 的编程方法。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)