← 返回教程库

FreeRTOS 任务:让硬件同时做好几件事

最后更新 2026-06-22
L3 · 联网与 IoT ⏱ 约 18 分钟 🟢 软件/低风险
你将学到
  • 用 xTaskCreate 在 app_main 里建出两个并发运行的任务,看着它们"同时"跑
  • 搞懂 xTaskCreate 每个参数,尤其栈大小单位是"字"不是字节、优先级数字越大越高
  • 理解任务函数为什么必须 for(;;) 死循环或 vTaskDelete(NULL) 自删,跑到底就崩
  • 知道任务不跑/栈溢出/喂不了看门狗这几类故障怎么从日志里认出来

你在 L1 让 LED 闪起来了,那段代码长这样:一个 while(1),里面亮、延时、灭、延时,循环往复。它能跑,但有个天花板——它一次只能干一件事。你想让灯闪的同时,每秒往串口打一行心跳日志?在那个单 while 里你就开始纠结了:延时的 500 毫秒里 CPU 在睡觉,日志怎么插进去?把两件事的时序硬揉进一个循环,越写越乱,这就是裸机 superloop 的死胡同。

这一节捅破它。ESP-IDF 底下跑着 FreeRTOS 这个实时操作系统,它的核心能力就是让你的硬件"同时"做好几件事——每件事是一个独立的任务(Task),各跑各的循环,由调度器替你切换。闪灯归闪灯任务,打日志归日志任务,互不打扰。这就是从"玩具单线程"跨到"产品级并发"的分水岭,也是为什么联网那篇能在后台自动重连、传感器能边采集边上报——它们全建立在任务模型这块地基上。

读这篇前,你需要已经跑通过点亮第一个 LED——ESP-IDF 环境装好了、idf.py build flash monitor 这条链路通了、能看懂串口日志。本篇不接任何额外的线,板载或外接一颗 GPIO2 上的 LED 即可,调试全靠串口看 ESP_LOGI 打出来的心跳。


第一步:先把双任务跑通

下面这段是完整可烧录的程序——它在 app_main 里建两个任务:一个负责让 GPIO2 的 LED 每 500 毫秒翻转一次,另一个每秒往串口打一行带计数的心跳日志。直接放进工程的 main/main.c

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/gpio.h"
#include "esp_log.h"

#define LED GPIO_NUM_2
static const char *TAG = "rtos";

// 任务一:闪灯。自己的死循环,每 500ms 翻一次电平
void blink_task(void *arg)
{
    gpio_set_direction(LED, GPIO_MODE_OUTPUT);
    int level = 0;
    for (;;) {                                  // 任务体必须是死循环,不能跑到底
        level = !level;
        gpio_set_level(LED, level);
        vTaskDelay(pdMS_TO_TICKS(500));         // 睡 500ms,期间让出 CPU
    }
}

// 任务二:心跳日志。和闪灯任务并行跑,互不干扰
void log_task(void *arg)
{
    int count = 0;
    for (;;) {
        ESP_LOGI(TAG, "心跳 #%d,闪灯任务此刻还在自己闪", count++);
        vTaskDelay(pdMS_TO_TICKS(1000));        // 每秒打一次
    }
}

void app_main(void)
{
    // app_main 自己也是一个任务,它在这里"生"出另外两个任务
    xTaskCreate(blink_task, "blink", 2048, NULL, 5, NULL);
    xTaskCreate(log_task,   "log",   2048, NULL, 5, NULL);
    // app_main 到这就没活了,可以直接返回——上面两个任务会继续跑
}

在工程目录下一条命令编译、烧录、看日志:

idf.py build flash monitor

(第一次用要先 idf.py set-target esp32s3 选好芯片型号。按 Ctrl + ] 退出监视。)

你应该看到什么

两件事同时发生,这是本篇的高光时刻:

  • GPIO2 的 LED 稳定地一秒一个节拍闪烁(亮 0.5 秒、灭 0.5 秒),一刻不停。
  • 与此同时,串口里每秒滚出一行心跳,计数往上走:
I (1010) rtos: 心跳 #0,闪灯任务此刻还在自己闪
I (2010) rtos: 心跳 #1,闪灯任务此刻还在自己闪
I (3010) rtos: 心跳 #2,闪灯任务此刻还在自己闪

盯住这个画面想一秒:日志在稳定地每秒打一行,同时灯在每半秒翻一次,两个节奏完全不一样、谁也没卡住谁。这件事在 L1 那个单 while(1) 里是做不到的——单循环里你得把"打日志"和"翻灯"的时序手动算着塞进同一个延时节拍,稍微复杂点就乱成一团。现在它俩是两段独立的死循环,各睡各的、各醒各的,CPU 在它们之间飞快地来回切,你的眼睛就以为它们在"并行"。

💡 提示

注意 app_main 里建完两个任务就没别的代码了,它直接返回——但 LED 照闪、日志照打。这说明任务一旦被 xTaskCreate 生出来,就脱离 app_main 独立活着了,不靠 app_main 续命。这点和你写普通 C 程序"main 返回了进程就结束"的直觉相反,得掰过来。


第二步:把 xTaskCreate 这行讲透

整篇的核心就是这一行,拆开每个参数看:

xTaskCreate(blink_task, "blink", 2048, NULL, 5, NULL);
//          ①任务函数   ②名字   ③栈   ④参数 ⑤优先级 ⑥句柄

① 任务函数:要跑的那段循环

blink_task 是你写的函数,签名固定是 void task(void *arg)。任务被调度器选中时,就从这个函数的第一行开始跑。

② 名字:给人看的,调试用

"blink" 这个字符串名字给调试器和日志用——崩溃时的栈回溯、vTaskList() 打印任务表,都靠它认人。随便起,但起得有意义,出问题时你会感谢自己。

③ 栈大小:单位是"字"不是字节(头号新手坑)

这是全篇最容易栽的地方,划重点:第三个参数 2048 的单位是"字(word)",不是字节。在 ESP32-S3 上一个字是 4 字节,所以 2048 实际是 2048 × 4 = 8192 字节 ≈ 8KB 的栈空间。

很多人看到别人代码里写 4096 就跟着抄,心里以为"分了 4KB",其实是 16KB。这个栈是这个任务专属的私房钱——它在里面放局部变量、函数调用的返回地址、传参。栈给小了,任务一调用层数深一点的函数、或者声明个大数组,栈就溢出,直接崩(怎么认这个崩,看故障表)。给大了不崩,但白白吃内存——ESP32-S3 内存就那么点,建十几个任务每个都甩 16KB,很快就 heap 见底。

经验值:纯翻 GPIO、打日志这种轻活,2048~4096 字够用;任务里要用 WiFi/TLS、跑 JSON 解析、调大库的,往 4096~8192 字起步。拿不准就先给大点跑通,再用 uxTaskGetStackHighWaterMark() 看实际用了多少、回头削。站内的「FreeRTOS 任务栈估算」工具能按你任务里干的活给个起步建议。

④ 参数:开机时塞给任务的一个指针

NULL 是传给任务函数那个 void *arg 的值。不传就 NULL;要传,就塞一个指针进去——任务函数里再把它转回原类型用。这是给任务"带点初始数据"的口子,本篇用不上,但变体里会演示怎么用它一次性建俩共用同一份代码、行为不同的任务。

⑤ 优先级:数字越大越优先

5 是优先级。数字越大,优先级越高——这点别记反了。0 是最低,留给系统的 idle(空闲)任务,谁都没活干时它才跑。ESP-IDF 默认上限是 25 左右(configMAX_PRIORITIES - 1)。

优先级决定"谁该先上 CPU":只要有个高优先级任务就绪(不在睡、有活干),调度器立刻把 CPU 从低优先级任务那抢过来给它——这叫抢占。本篇两个任务都给了 5,是故意的:同优先级时,调度器做时间片轮转,每人轮流跑一小片,看起来就像并行。要是把闪灯设成 5、日志设成 1,只要闪灯任务在 vTaskDelay 睡觉,CPU 才轮得到日志任务。

⑥ 句柄:任务的"遥控器"

最后这个 NULLTaskHandle_t *,给你回填一个句柄用的。不需要事后操作这个任务,就填 NULL;想之后挂起它、删它、查它,就传一个 TaskHandle_t 变量的地址进去,拿到句柄当遥控器使(变体二会用上)。


第三步:任务函数为什么必须死循环

blink_task 的形态——for (;;),一个永不退出的循环。这不是写法偏好,是硬规矩:FreeRTOS 的任务函数绝对不能"跑到底返回"。

普通 C 函数跑完最后一行就 return,调用者接着往下走。但任务不一样,它没有"调用者在等它返回"——调度器是把它当成一段应该一直活着的执行流。要是你的任务函数跑到了 } 末尾、自然返回了,FreeRTOS 不知道该把控制权交给谁,行为是未定义的,轻则任务"凭空消失"再不执行,重则系统 abort 崩溃。

所以任务体只有两种合法收尾:

// 写法一:永远循环下去,常驻任务都这么写
void blink_task(void *arg) {
    for (;;) {
        // ... 干活 ...
        vTaskDelay(pdMS_TO_TICKS(500));
    }
}

// 写法二:干完一次性的活,主动自删,不留尾巴
void once_task(void *arg) {
    ESP_LOGI(TAG, "我就跑一次初始化");
    // ... 干一次性的活 ...
    vTaskDelete(NULL);          // 传 NULL = 删我自己,到这任务干净退场
}

记住:要么 for(;;) 死循环常驻,要么 vTaskDelete(NULL) 自删退场,绝不能让任务函数自然跑到 } vTaskDelete(NULL) 里那个 NULL 是"删调用者自己"的意思,它会回收这个任务占的栈和控制块,干净利落。

vTaskDelay 才是任务能"共存"的关键

再盯一眼两个任务里的 vTaskDelay。它表面是"延时 500 毫秒",真正的作用是 主动让出 CPU——vTaskDelay 期间这个任务进入"阻塞睡眠"状态,调度器趁机把 CPU 调给别的就绪任务。这才是闪灯和日志能"同时"跑的物理原因:闪灯任务睡那 500 毫秒里,CPU 没闲着,跑去执行日志任务了。

这里藏着一个夺命坑:任务里千万别用"空转死等"代替 vTaskDelay。比如你想延时,写成 for (volatile int i=0; i<10000000; i++); 这种忙等——它在烧 CPU 而从不让出,同优先级的别的任务根本轮不上,更要命的是 idle 任务也轮不上,而 idle 任务负责"喂狗"(喂任务看门狗 TWDT)。喂不上狗,几秒后系统判定卡死、直接触发看门狗复位重启。该睡就用 vTaskDelay 让出去,这是 RTOS 编程的肌肉记忆。


第四步:为什么深一层——app_main 和调度器到底在干嘛

往下挖一层,你会对整个 RTOS 模型通透很多。

app_main 本身就是一个任务。 你一直以为它是"程序入口",其实在 ESP-IDF 里,系统启动时先把 FreeRTOS 调度器跑起来,然后由它创建一个叫 main 的任务,这个任务干的活就是调用你的 app_main()。所以你从一开始写的每段代码,都已经活在一个任务里了——只是 L1/L2 时你只用了这一个任务,没意识到而已。本篇的 xTaskCreate 不过是让这个"老大任务"再生出两个"兄弟任务"。

调度器在干嘛? 它是 FreeRTOS 的心脏,干一件事:在所有"就绪"的任务里,挑优先级最高的那个给 CPU 跑;同优先级的,按时间片轮流。每个系统节拍(tick,ESP-IDF 默认 100Hz,即每 10 毫秒一次)它都会重新评估一次,或者某个任务主动 vTaskDelay/被事件唤醒时立刻评估。这套机制让"多任务并发"成立——单核 CPU 物理上一次只跑一条指令流,但切得足够快(毫秒级),人眼和大多数硬件时序就感知为"同时"。

和裸机 superloop 的本质区别:Arduino 那种 loop() 里把所有事串成一长串、靠 delay() 卡时序的写法,本质是"一个人轮流干所有活,谁卡住全卡住"。RTOS 是"每件事派一个专人(任务),专人自己睡自己醒,调度器当工头分配 CPU"。后者的代价是要懂任务、栈、优先级、任务间怎么安全传数据(下一篇的队列);换来的是可扩展——加一件要做的事,就再起一个任务,不用去动别人的循环时序。产品级固件几十个任务并行是常态,superloop 撑不住那个复杂度。

双核与 xTaskCreatePinnedToCore

补一句 ESP32-S3 的特点:它是双核的(两个 CPU 核心)。xTaskCreate 建的任务,调度器会自动分配到任意一个核上跑。如果你想把某个任务钉死在指定核(比如把时间敏感的活钉到一个核、别让它和别的任务抢),用 xTaskCreatePinnedToCore(fn, name, stack, arg, prio, &handle, core_id),最后多一个 core_id(01,或 tskNO_AFFINITY 不指定)。本篇先不展开,知道有这回事、以后做高实时性的活会用到即可。


故障排查:任务行为不对,按这个查

多任务出问题,日志里的现象很有特征,照这张表认:

现象 / 日志 最可能的原因 怎么办
串口刷 ***ERROR*** A stack overflow in task blink 然后重启 这个任务栈给小了(溢出) xTaskCreate 第三个参数(栈,单位字)调大,如 20484096;用 uxTaskGetStackHighWaterMark 看实际余量
刷一段 Guru Meditation Error + 栈回溯然后重启 栈溢出/野指针/任务函数跑到底返回了 先确认任务体是 for(;;) 不会自然结束;再查栈大小;回溯里的任务名定位是哪个任务崩的
某个任务压根不跑(日志/灯没动静) 优先级被饿死:有更高优先级任务一直不 vTaskDelay,霸着 CPU 给霸占 CPU 的那个任务加 vTaskDelay 让它让出;或调整优先级
跑几秒后刷 Task watchdog got triggered / E (xxx) task_wdt 然后复位 任务里忙等/死循环没 vTaskDelay,喂不了看门狗 把空转 for 忙等换成 vTaskDelay;长耗时活拆开、中间让出 CPU
xTaskCreate 返回失败、建不出任务 堆内存不够(栈要的太多/任务建太多) 把每个任务的栈削到够用;ESP_LOGIesp_get_free_heap_size() 看剩多少堆
两个任务都给了高优先级,但有一个偶尔卡顿 同优先级时间片轮转,某任务单次干太久没及时让出 把长任务拆短、勤 vTaskDelay;或按重要性拉开优先级
🚧 避坑

***ERROR*** A stack overflow in task 这条是新手最常撞的。它的根因九成是栈大小那个参数给小了,或者你忘了它单位是"字"——以为给了 4KB 其实够、结果任务里调了个吃栈的库函数就爆。栈溢出有时不会当场崩、而是悄悄踩坏隔壁内存,导致后面莫名其妙的怪 bug,所以见到这条别犹豫,先把栈翻倍试。


变体:两个进阶玩法

变体一:给任务传参数(用上第④个参数)

前面 xTaskCreate 第四个参数一直传 NULL。它的真正用法是:塞一个指针进去,任务函数里转回来用。这样同一份任务代码可以建出多个行为不同的任务——比如建两个闪灯任务,各闪不同的 GPIO、不同的周期:

typedef struct { gpio_num_t pin; int period_ms; } blink_cfg_t;

void blink_task(void *arg)
{
    blink_cfg_t *cfg = (blink_cfg_t *)arg;      // 把 void* 转回结构体指针
    gpio_set_direction(cfg->pin, GPIO_MODE_OUTPUT);
    int level = 0;
    for (;;) {
        level = !level;
        gpio_set_level(cfg->pin, level);
        vTaskDelay(pdMS_TO_TICKS(cfg->period_ms));
    }
}

void app_main(void)
{
    // 注意:这两个配置必须是 static 或全局,不能是 app_main 的局部变量
    static blink_cfg_t a = { GPIO_NUM_2,  500 };
    static blink_cfg_t b = { GPIO_NUM_4,  150 };
    xTaskCreate(blink_task, "blinkA", 2048, &a, 5, NULL);
    xTaskCreate(blink_task, "blinkB", 2048, &b, 5, NULL);   // 同一份代码,闪得不一样
}
🚧 避坑

传参那个结构体必须活得比任务久——上面用 static 把它放进静态存储区就稳了。要是写成 app_main 里的普通局部变量,app_main 一返回它就被回收,任务再去读那块地址就是读野指针,行为全乱。这是传参的头号坑。

变体二:用句柄挂起/恢复任务

第⑥个参数那个句柄是任务的遥控器。拿到它,就能在别处控制这个任务的运行:

TaskHandle_t blink_handle = NULL;

void app_main(void)
{
    xTaskCreate(blink_task, "blink", 2048, NULL, 5, &blink_handle);  // 回填句柄

    for (;;) {
        vTaskDelay(pdMS_TO_TICKS(3000));
        vTaskSuspend(blink_handle);     // 挂起:闪灯任务被冻住,灯停在当前状态
        ESP_LOGI(TAG, "闪灯暂停 3 秒");
        vTaskDelay(pdMS_TO_TICKS(3000));
        vTaskResume(blink_handle);      // 恢复:从冻住的地方接着闪
        ESP_LOGI(TAG, "闪灯恢复");
    }
}

vTaskSuspend 把任务"按下暂停键",它彻底不再被调度、也不耗 CPU,直到 vTaskResume 把它唤醒、从断点接着跑。做"按钮一按就停止某后台任务"这类交互时很常用。

本篇代码为参考实现,需结合你所用的最新 ESP-IDF 文档与实物自校,尤其 xTaskCreate 的栈单位、优先级上限、xTaskCreatePinnedToCore 的核编号随版本/配置可能微调,以官方 FreeRTOS 文档为准。


动手挑战

别只看,动手改一个:

  1. 把第一步两个任务的优先级改成不一样:闪灯设 5、日志设 2。观察日志的节奏有没有变。然后把闪灯任务里的 vTaskDelay 注释掉(改成空转),看是不是马上触发任务看门狗 Task watchdog got triggered 复位——亲手制造一次这个经典故障,以后一眼就认得。
  2. 用变体一的传参法,建三个任务驱动三个 GPIO 上的 LED,周期分别 200ms / 500ms / 900ms,做出"三灯各闪各的、永不同步"的效果。再用 uxTaskGetStackHighWaterMark(NULL) 在任务里打一次,看你给的 2048 字栈实际只用了多少。

卡住了?把你的代码、idf.py monitor 的完整日志(尤其崩溃时的栈回溯和任务名)、想要的效果一起发给 AI,描述清楚"我看到了什么、期望看到什么",它定位会准得多。


小结 · 你现在掌握了什么

  • 你能用 xTaskCreateapp_main 里建出多个任务,亲眼看着闪灯和心跳日志同时跑——这是单 while(1) superloop 做不到的并发。
  • 你搞懂了 xTaskCreate 六个参数,尤其栈大小单位是"字"(ESP32-S3 上 4 字节)不是字节优先级数字越大越高这两个高频坑。
  • 你知道了任务函数必须 for(;;) 死循环或 vTaskDelete(NULL) 自删,绝不能跑到底返回;vTaskDelay 让出 CPU 是任务能共存、也是喂得了看门狗的关键。
  • 你理解了 app_main 自己也是个任务、调度器靠优先级抢占 + 同级时间片轮转调度,以及这套模型和裸机 superloop 的本质区别,还知道双核可用 xTaskCreatePinnedToCore 钉核。
  • 你会看栈溢出(stack overflow in task)、看门狗复位(Task watchdog got triggered)、任务饿死这几类故障的日志特征。

现在你的两个任务能各跑各的了,但有个绕不开的新问题:它们之间怎么安全地传数据? 比如一个任务读到了温度、要交给另一个任务上报——你不能让它俩同时去碰一个全局变量(那会出竞态)。FreeRTOS 给任务间通信备了专门的工具,下一步学FreeRTOS 队列:任务间安全传数据,把这块补上,多任务才算真正用得起来。

想看 L3 这一级还有哪些课、整条进阶路线长什么样,回L3 关卡总览完整路线图

📄 来源 / 自校链接

本文为公开资料整理,非亲测。关键参数与代码请结合实物与下列官方来源验证。

内容有错、看不懂、或想看下一期?告诉我们 →

本文为公开资料的学习整理,非亲测。涉接线/花钱/合规的步骤请结合实物与官方最新资料验证,风险自负。见免责声明