前面章节里我们一直在用 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 的参数逐个讲:

  1. 任务函数:签名必须是 void func(void *arg);
  2. 名字:最多 16 字符,调试/监控用;
  3. 栈大小:字节数。任务自己需要局部变量、调用函数时压栈
    (第 1 章讲过栈)。太小 → 栈溢出 → 崩溃;太大 → 浪费内存;
  4. 参数:可以传一个指针进去(比如传电机对象);
  5. 优先级:数字越大越先跑(0~24);
  6. 句柄:用来以后操作这个任务(删除、挂起、通知)。句柄(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 想一想 + 小测验

想一想:

  1. "阻塞"和"空转等待"在 CPU 占用上有什么区别?
  2. 为什么队列传数据"天然安全"?
  3. 互斥锁为什么比二值信号量更适合保护共享资源?

小测验:

  1. 任务函数写完后应该?(A. return B. 循环内阻塞 C. exit)
  2. xQueueCreate(10, sizeof(int)) 创建的是什么?
  3. 高优先级任务就绪时会____低优先级任务。
  4. 系统卡死时谁来"救"它?(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 的编程方法。

Logo

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

更多推荐